diff --git a/.github/workflows/fortran.yml b/.github/workflows/fortran.yml index edd730ad..40e46d0f 100644 --- a/.github/workflows/fortran.yml +++ b/.github/workflows/fortran.yml @@ -89,3 +89,55 @@ jobs: OMP_NUM_THREADS: "1" OPENBLAS_NUM_THREADS: "1" run: ctest --test-dir build --output-on-failure --parallel 4 + + # Round-off fragility: a result that depends on the BLAS kernel fails here although every + # job above, on the runner's own kernel, passes. Prescott, an SSE3 kernel of the conda-forge + # OpenBLAS, sums in another order than the AVX2 and AVX-512 kernels the runners select. + # This job is advisory: branch protection names the jobs above, not this one. + roundoff: + name: round-off kernel (conda gfortran, ubuntu-latest, Prescott) + runs-on: ubuntu-latest + env: + OPENBLAS_CORETYPE: Prescott + defaults: + run: + shell: bash -el {0} # login shell so the conda env is active + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up the conda toolchain (gfortran, CMake, OpenBLAS, LAPACK) + uses: mamba-org/setup-micromamba@f457c30a868e4760d3a6fcea5f25dc655b8edf39 # v3.2.1 + with: + environment-file: environment.yml + cache-environment: true + + - name: Show toolchain + run: gfortran --version && cmake --version + + - name: Configure + run: cmake -S . -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build --parallel + + - name: Confirm the OpenBLAS kernel + # OPENBLAS_CORETYPE works only on a dynamic-architecture OpenBLAS and is otherwise + # ignored without a word: stop here rather than pass on the runner's own kernel. + run: >- + python tests/check_example_fallbacks.py --driver build/cabledyn --examples examples + --out build/kernel_check --coretype "$OPENBLAS_CORETYPE" --kernel-only + + - name: Test (CTest) + # As in the job above: four tests at once, each single-threaded. The few tests that + # pin a kernel of their own set OPENBLAS_CORETYPE themselves. + env: + OMP_NUM_THREADS: "1" + OPENBLAS_NUM_THREADS: "1" + run: ctest --test-dir build --output-on-failure --parallel 4 + + - name: Example decks without a fallback note + run: >- + python tests/check_example_fallbacks.py --driver build/cabledyn --examples examples + --out build/example_scan --coretype "$OPENBLAS_CORETYPE" --jobs 4 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 032fe55e..5f289870 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -446,7 +446,7 @@ src/ CableDyn_Hydro.f90 Morison loads, Airy and JONSWAP waves, currents CableDyn_WaveSpectra.f90 spectra, spreading, and multi-train seas CableDyn_StreamWave.f90 stream-function regular nonlinear waves - CableDyn_WaveMath.f90 block sine/cosine and exponential of the wave kinematics + CableDyn_WaveMath.f90 block sine/cosine, exponential and lane sums of the wave kinematics CableDyn_Vessel.f90 vessel kinematics, 6-DOF motion, displacement RAOs CableDyn_SeabedContact.f90 C1 normal contact and stick-slip friction laws CableDyn_Bathymetry.f90 gridded bathymetry diff --git a/CHANGELOG.md b/CHANGELOG.md index 345466ad..b6df6ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,135 @@ All notable changes to CableDyn are recorded in this file. The format follows ## [Unreleased] +## [0.4.1] - 2026-11-08 + +### Added + +- In-process stepping in moving water (ABI 1.4). On decks with finite-EI lines, bodies or rods + the C API and Python march a steady deck current (`current`, WaterKin `CurrentMod 1`, MoorDyn-C + `Currents 1`), in the static start and in every step. A host can instead supply the fluid at + every line node: `CableDyn_SetHostFluid`, `CableDyn_NFluidNodes`, + `CableDyn_GetFluidNodePositions` and `CableDyn_UpdateFluidFields`, in Python `set_host_fluid`, + `n_fluid_nodes`, `fluid_node_positions` and `update_fluid_fields`. The field is sampled and held + as in the standalone march and in OpenFAST; in current and waves a stepped cable matches the + standalone driver to the digits of its output. +- `Line.section_curvature()` and the C API quantity `CD_C_LINE_SECTION_CURVATURE` return `CurvX` + and `CurvY` at every node of a finite-EI line in one evaluation. +- `CableDyn_GetInitNotes` in the C API and `CableDyn.init_notes` in Python: the notes of the last + deck initialisation on the point route and the aggregate, for a host that does not read the + standard streams. +- Python: `CableDyn.init_line`, `init_lines` and `calc_output_derivatives` bind the raw-array line + calls and the load Jacobians of the C API. +- Python: `read_output` reads both tables of `.modes.out` into `NaturalModes` and + `ModeShapes`; `DriverResult` gains `modes_output` and `read_modes()`. +- Range graphs in coupled OpenFAST runs: the `LINES` `Outputs` flag `r` and `RangeStart` now write + `.CD.Line.range.out` in a single-turbine run, from the committed `dtM` steps of + `.CD.out`, once per step when OpenFAST retries a step, and across a checkpoint restart. A + turbine's own instance in FAST.Farm writes `.T.CD.Line.range.out`. A FAST.Farm + shared mooring and the C API reject the flag by name. +- An OpenFAST initialisation note when the deepest interval of the SeaState depth grid exceeds 5 % + of the depth and a line lies in it: the field is linear there, unlike a deck depth table. +- An initialisation note names the lines that a current can move along a seabed without friction. +- `WaveKin 7`: each row of `wave_frequencies.txt` may carry its own `beta`, as MoorDyn-C reads it, + so that a component file can describe a short-crested sea; vessel RAOs take each row's heading. +- `WaveRefX` and `WaveRefY` options: the point that an elevation record or a component file + (`WaterKin WaveKinMod 1`, `WaveKin 3` and `7`) refers to. An extension of CableDyn; default 0, 0. +- CI: a Linux job runs the full test suite on the Prescott OpenBLAS kernel and fails on a fallback + note in any shipped example deck. + +### Changed + +- A standalone deck with `TMax 0` no longer needs `dtM`: the static check marches no step, so it + runs without one on every standalone route and writes the same `t = 0` row as with one. A + mixed deck that marches no step no longer prints the `dtM` advisory. +- Wave kinematics cost half to a fifth as much, depending on the compiler: on a 298-node line + in a 4,200 s elevation record of 2,400 components the sum falls from 6.0 to 3.0 ms per step + in the release build and from 18 to 3.6 ms in a GNU AVX2 build. Results change at round-off. +- `CableDyn_GetLineValues` evaluates the position, velocity, acceleration, tension, curvature, + bend moment, declination and azimuth of a line in one pass, without a text token per node. The + values are unchanged bit for bit. A read of `EI = 0` tension was O(n²) and is now linear. +- A coupled run restarts from a checkpoint at any glue step: `ChkptTime` need not be a multiple of + `dtM`, and the restarted run matches the uninterrupted one bit for bit. +- Range graphs are written when the host reports a completed run, so also at FAST.Farm's last + whole `DT_Low` and after `CalcSteady`. An earlier run's range graphs are removed as soon as the + deck is parsed, and a run that stops on an error writes none. +- The Python deck validator reads the files a deck names (WaterKin, the MoorDyn-C kinematics + files, motion and vessel records, the RAO table, bathymetry, Syrope) and refuses what the solver + refuses. +- A direction bin below the smallest normal weight holds no energy: its components are exactly + zero, and a vessel RAO table needs no heading for it. +- The C API refuses the flag `r` on `EI = 0` decks too, which it accepted without writing a file. + +### Fixed + +- Nodal curvature under a tapered bend stiffener: a node took the stiffness of the softer + neighbouring element, which steps at every node of a taper, and so overstated the curvature + by about `h |EI'| / (2 EI)`. It now takes the stiffness `EI(s)` at the node, and the softer + side only at a real step of the section (the bare tip, stiffener rows that do not match, two + line types). +- Element curvature under a tapered bend stiffener: the peaks of `.elements.out` and the node + values of `OutputRecovery element` read the element's interpolation, which bends under one + stiffness per element. Against the section its curvature is off by the same `h |EI'| / (2 EI)`, + high where the curvature falls toward the tip and low where it rises. On an element with a + sleeve they now report the section: the moment over `EI(s)`. +- Curvature and bend moment of a stretched finite-EI line: the node outputs read the conjugate of + the tangent as the moment, which put `Curv` 2 ε and `BendMom` ε below their definitions. + `Curv` is now the geometric curvature of the stretched line and `BendMom` the moment that + balances the forces, `EI κ / (1 + ε)`. +- An `EI = 0` line whose deck chord heads within 45 degrees of +X reported its azimuth in [0, 360) + instead of (-180, 180] outside the standalone `EI = 0` route: in the C API and Python, on the + standalone mixed and multibody routes, and in OpenFAST. Its `LNAzi` channels, the + `Azimuth` column of the static profile and the azimuth of the initialisation summary now follow + the documented range on every route. +- A solver step retry in OpenFAST works at any `dtM/DT` and matches an unretried step exactly, + including across a restart; it rebuilt the model at `dtM = DT` and stopped at `dtM > DT`. +- `CalcSteady` with several operating points no longer stops at the second, works at `dtM` larger + than `DT`, and `.CD.out` and the range graphs run to the end of its run. `-VTKLin` works at `dtM` + larger than `DT`. Linearisation after `CalcSteady` or after a restart keeps its line states. +- A restart before the first commit keeps a body or rod at its start, and a restart no longer + rewrites `.CD.static.out`. +- Every initialisation note of a coupled line reaches the console; long lists were cut or dropped. +- The OpenFAST module driver runs a rotating motion in a single-precision build. +- A range-graph file that cannot be written completely is removed instead of left behind. +- A mean level in a MoorDyn-C kinematics file, a non-zero `omega = 0` row of `WaveKin 7` or the + mean of a `WaveKin 3` record, is added to the elevation alone, as MoorDyn-C does: the wave + numbers stay those of `WtrDpth` and the stretching maps the water under the total elevation. + CableDyn took the wave numbers at `WtrDpth` plus the level. With a 0.8 m level in 50 m of water + and six components in six directions, `WaveKin 7` now matches MoorDyn-C v2.7.2 to 1e-8 instead + of 7.7e-3 m in elevation and 1 % in velocity. The mean of a WaterKin `WaveKinMod 1` record still + raises the still-water level; MoorDyn-F drops it. +- The C API messages of `CableDyn_UpdatePointFluidFields` and `CableDyn_CalcOutputDerivatives` on + the coupled aggregate name its real scope: decks with finite-EI lines, bodies or rods. + +### Upgrading + +- `Curv`, `CurvX`, `CurvY`, `BendMom` and `StfMom`, and the `Curvature` and `BendMoment` of the + static profile and the range graphs, change at nodes inside a bend stiffener. Curvature and + `BendMom` fall by that first-order error, on `lazy_wave_bend_stiffener.dat` by about 5 % on + 0.2 m elements, 4 % on 0.15 m, 2 % on 0.1 m and 0.7 % on 0.05 m; `StfMom` rises by the same + moment, so the two still add up to the section moment. A line end on a conical root + changes likewise, and a step between rows on a taper now compares the two stiffnesses at the + node. Positions, forces, the section moment and every node outside a stiffener are unchanged. +- `PeakCurvature`, `BendMomentAtPeak` and `PeakXi` of `.elements.out`, so `bend_check` and + `element_range_graph` on that table, change on elements with a stiffener sleeve, and so do + `Curv`, `CurvX` and `CurvY` under `OutputRecovery element` at their nodes, where they now equal + the default recovery. The peaks fall by 1.6 / 3.1 / 5.8 % on 0.05 / 0.1 / 0.2 m elements of a + hang-off whose curvature falls toward the tip, and rise by up to 4.4 % on the 0.15 m elements of + `lazy_wave_bend_stiffener.dat`, whose curvature rises. Elements without a sleeve are unchanged, + and `CD_HermiteCable_Dyn_Curvature_Exact` stays the geometric trace. +- `Curv`, `CurvX`, `CurvY`, `BendMom` and `StfMom`, and the `Curvature` and `BendMoment` of the + static profile and the range graphs, rise on every finite-EI line: curvature by twice the axial + strain, the moments by the strain, at most 0.024 % and 0.012 % on the examples. `BendMom` equals + `EI × Curv` only to within the strain. Tension, positions and forces are unchanged, and so are + `OutputRecovery element` values on elements without a stiffener sleeve. +- Azimuth values of the `EI = 0` lines named above read 360 degrees lower on those routes. +- On a `WaveKin 3` or `WaveKin 7` deck whose mean level is not zero, the elevation away from the + origin, the fluid velocity and acceleration, and so the loads and every output, change by the + order of the level over the depth: up to 1 % in velocity at 0.8 m in 50 m. A deck with no mean + level is unchanged bit for bit. +- OpenFAST checkpoints change format: they carry the range graphs and the input of the last + commit, so a checkpoint written by an earlier CableDyn cannot be restarted with this one. + ## [0.4.0] - 2026-11-04 ### Added @@ -689,7 +818,8 @@ Corrections made during the pre-release review. values of the shipped example decks and states which tension (end element or `FairTen` end force) each comparison uses. -[Unreleased]: https://github.com/SMI-Lab-Inha/CableDyn/compare/v0.4.0...HEAD +[Unreleased]: https://github.com/SMI-Lab-Inha/CableDyn/compare/v0.4.1...HEAD +[0.4.1]: https://github.com/SMI-Lab-Inha/CableDyn/releases/tag/v0.4.1 [0.4.0]: https://github.com/SMI-Lab-Inha/CableDyn/releases/tag/v0.4.0 [0.3.0]: https://github.com/SMI-Lab-Inha/CableDyn/releases/tag/v0.3.0 [0.2.0]: https://github.com/SMI-Lab-Inha/CableDyn/releases/tag/v0.2.0 diff --git a/CITATION.cff b/CITATION.cff index f5681351..a9b4ade9 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -23,8 +23,8 @@ authors: repository-code: "https://github.com/SMI-Lab-Inha/CableDyn" url: "https://github.com/SMI-Lab-Inha/CableDyn" license: Apache-2.0 -version: 0.4.0 -date-released: 2026-11-04 +version: 0.4.1 +date-released: 2026-11-08 keywords: - mooring dynamics - cable dynamics diff --git a/CMakeLists.txt b/CMakeLists.txt index ad56bfd6..aebdc15a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,7 +8,7 @@ # cmake_minimum_required(VERSION 3.20) # VERSION is a bare numeric triple matching the release version. -project(CableDynCore VERSION 0.4.0 LANGUAGES C Fortran +project(CableDynCore VERSION 0.4.1 LANGUAGES C Fortran DESCRIPTION "CableDyn geometrically exact cable/mooring solver — Fortran core") option(CABLEDYN_ENABLE_COVERAGE "Instrument the GNU Fortran core for gcov/gcovr source coverage" OFF) @@ -835,6 +835,12 @@ target_link_libraries(test_wave_math PRIVATE cabledyn_core) add_test(NAME wave_math COMMAND test_wave_math) set_tests_properties(wave_math PROPERTIES LABELS "fortran;unit;hydro") +# The fast sum of the component wave kernel against a direct quadruple-precision sum. +add_executable(test_wave_component_sum tests/test_wave_component_sum.f90) +target_link_libraries(test_wave_component_sum PRIVATE cabledyn_core) +add_test(NAME wave_component_sum COMMAND test_wave_component_sum) +set_tests_properties(wave_component_sum PROPERTIES LABELS "fortran;unit;hydro" TIMEOUT 120) + add_executable(test_cable_static tests/test_cable_static.f90) target_link_libraries(test_cable_static PRIVATE cabledyn_core) add_test(NAME cable_static COMMAND test_cable_static) @@ -1081,6 +1087,13 @@ add_test(NAME aggregate_step_atomicity COMMAND test_aggregate_step_atomicity WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_tests_properties(aggregate_step_atomicity PROPERTIES LABELS "fortran;unit;dynamic;capi") +add_executable(test_line_values tests/test_line_values.f90) +target_link_libraries(test_line_values PRIVATE cabledyn_core) +add_test(NAME line_values_match_channels + COMMAND test_line_values ${CMAKE_CURRENT_SOURCE_DIR}/tests/data/lazy_wave_touchdown_planar_coarse.dat + WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +set_tests_properties(line_values_match_channels PROPERTIES LABELS "fortran;unit;dynamic;capi") + add_executable(test_core_input_guards tests/test_core_input_guards.f90) target_link_libraries(test_core_input_guards PRIVATE cabledyn_core) add_test(NAME core_input_guards COMMAND test_core_input_guards WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) @@ -1160,7 +1173,7 @@ set_tests_properties(cable_statics_modes PROPERTIES LABELS "fortran;driver;hermi # clamped hang-off moment under vessel pitch. add_executable(test_vessel_motion tests/test_vessel_motion.f90) target_link_libraries(test_vessel_motion PRIVATE cabledyn_core) -file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/vessel_motion) +file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/vessel_motion/vm_wk7) add_test(NAME vessel_motion COMMAND test_vessel_motion WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/vessel_motion) set_tests_properties(vessel_motion PROPERTIES LABELS "fortran;unit;dynamic;validation") @@ -1352,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) @@ -1790,7 +1810,8 @@ if(Python3_Interpreter_FOUND) endif() # TMax = 0 checks the static start of a dynamic deck: vesselMotion and motionFile records - # that outlast TMax, RangeStart at TMax = 0, and the hint of a deck without dtM and TMax. + # that outlast TMax, RangeStart at TMax = 0, a deck of each route without dtM, and the hint of a + # deck without dtM and TMax. add_test(NAME static_check COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_static_check.py --exe $ @@ -1801,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 @@ -2287,6 +2321,12 @@ file(GLOB CD_WAVEKIN_MDC_FILES RELATIVE ${CMAKE_SOURCE_DIR}/tests/data/wavekin_m foreach(f IN LISTS CD_WAVEKIN_MDC_FILES) configure_file(tests/data/wavekin_mdc/${f} ${CMAKE_BINARY_DIR}/wavekin_mdc/${f} COPYONLY) endforeach() +# the same with a mean level (an omega = 0 row, a raised record), against MoorDyn-C v2.7.2 +file(GLOB CD_WAVEKIN_MDC_MEAN_FILES RELATIVE ${CMAKE_SOURCE_DIR}/tests/data/wavekin_mdc_mean + ${CMAKE_SOURCE_DIR}/tests/data/wavekin_mdc_mean/*) +foreach(f IN LISTS CD_WAVEKIN_MDC_MEAN_FILES) + configure_file(tests/data/wavekin_mdc_mean/${f} ${CMAKE_BINARY_DIR}/wavekin_mdc_mean/${f} COPYONLY) +endforeach() add_executable(test_wavekin_modes tests/test_wavekin_modes.f90) target_link_libraries(test_wavekin_modes PRIVATE cabledyn_core) add_test(NAME wavekin_modes COMMAND test_wavekin_modes WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) @@ -2747,7 +2787,8 @@ if(Python3_Interpreter_FOUND) LABELS "python;driver;integration;static" ENVIRONMENT "${_cabledyn_python_test_environment}") # A hang-off through a tapered bend stiffener follows the planar elastica (scipy solve_bvp) - # within 2 % of the root moment and 1 cm; skipped where scipy is not installed. + # within 2 % of the root moment and 1 cm, and node by node on a sweep of element sizes within + # 0.5 % of the local curvature on 0.1 m elements; skipped where scipy is not installed. add_test(NAME driver_bend_stiffener COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_bend_stiffener.py $) @@ -2755,6 +2796,26 @@ if(Python3_Interpreter_FOUND) LABELS "python;driver;integration;static;validation" SKIP_RETURN_CODE 77 ENVIRONMENT "${_cabledyn_python_test_environment}") + # The curvature of a stretched cable is the geometric one and the bend moment the section's: + # both follow the elastica of the element energy at strains up to 1.2 %. + add_test(NAME driver_curvature_stretch + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_curvature_stretch.py + $) + set_tests_properties(driver_curvature_stretch PROPERTIES + LABELS "python;driver;integration;static;validation" + SKIP_RETURN_CODE 77 + ENVIRONMENT "${_cabledyn_python_test_environment}") +endif() +# The scan of the round-off CI job: it must flag a fallback note, a failed run and a forced +# BLAS kernel that did not take effect, or a clean scan would prove nothing. +if(Python3_Interpreter_FOUND) + add_test(NAME example_fallback_scan + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_check_example_fallbacks.py + $ ${CMAKE_CURRENT_SOURCE_DIR}/examples) + set_tests_properties(example_fallback_scan PROPERTIES + LABELS "python;driver;integration" + TIMEOUT 120 + ENVIRONMENT "${_cabledyn_python_test_environment}") endif() # A clamped finite-EI cable with a buoyant section on a Rigid6 body: the body static solve # holds the objects at their deck poses, warns once, and reports no balanced clamped end loads. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 704a8b10..6cc9d3d4 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -86,6 +86,19 @@ stops at the first mismatch. The Fortran CI workflow builds Release and Debug on Ubuntu and Windows and runs the full suite on every pull request and push to `main`. +The `roundoff` job of the same workflow repeats the Linux Release build and the full +CTest on the SSE3 OpenBLAS kernel (`OPENBLAS_CORETYPE=Prescott`), then runs every shipped +example deck through `tests/check_example_fallbacks.py`, which fails on a non-zero exit and on a +fallback note where a deck should converge cleanly, and lists the offending decks. The job stops +first if OpenBLAS does not report that kernel. It is advisory, not a required check. To repeat it +locally, on Linux or Windows: + +```bash +OPENBLAS_CORETYPE=Prescott ctest --test-dir build --output-on-failure +python tests/check_example_fallbacks.py --driver build/cabledyn --examples examples \ + --out build/example_scan --coretype Prescott +``` + ## Python package The `cabledyn` package (`python/`) loads the C-ABI shared library built by diff --git a/README.md b/README.md index c8581bc1..c37d30e7 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ in the MoorDyn v2 format and solves them with two line formulations: - **Finite-bending lines** (finite-EI): cubic-Hermite elements for dynamic power cables, including lazy-wave configurations, with continuous centreline curvature. -The current stable release is `v0.4.0`. Interfaces are +The current stable release is `v0.4.1`. Interfaces are versioned and may change before `1.0.0`. ## Capabilities @@ -51,8 +51,8 @@ a named error rather than running approximately. |---|---| | `CableDyn_driver.exe` | Standalone solver for Windows x64 (AVX2 processor) | | `openfast.exe` | OpenFAST with CableDyn available as `CompMooring = 5` | - | `cabledyn-0.4.0-py3-none-any.whl` | Python package (pre- and post-processing, batch runs) | - | `cabledyn-0.4.0.tar.gz` | Python source distribution | + | `cabledyn-0.4.1-py3-none-any.whl` | Python package (pre- and post-processing, batch runs) | + | `cabledyn-0.4.1.tar.gz` | Python source distribution | | `SHA256SUMS.txt` | Checksums for the assets above | Both executables are statically linked, need no installer, compiler, or runtime @@ -90,7 +90,7 @@ a named error rather than running approximately. cabledyn-run examples\wd0050_chain.dat wd0050 ``` - The pure Python wheel on the release page (`cabledyn-0.4.0-py3-none-any.whl`) + The pure Python wheel on the release page (`cabledyn-0.4.1-py3-none-any.whl`) carries no driver: `cabledyn-run` then needs `--executable .\CableDyn_driver.exe`, the `CABLEDYN_DRIVER` environment variable, or the driver on `PATH`. A source build names the driver `cabledyn` (see [Build from source](#build-from-source)); @@ -157,7 +157,7 @@ If you use CableDyn in academic work, please cite: > offshore wind. *Ocean Engineering* 368 (Part 2), 128332, 2026. > -To identify the software version, also cite the release (CableDyn 0.4.0). +To identify the software version, also cite the release (CableDyn 0.4.1). The [citation file](https://cabledyn.readthedocs.io/en/latest/citing.html) contains both entries (GitHub **Cite this repository**); BibTeX is on the manual's [How to cite](https://cabledyn.readthedocs.io/en/latest/citing.html) page. diff --git a/RELEASE.md b/RELEASE.md index 11714b5a..60bd59c4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -104,7 +104,7 @@ The build fails if either executable imports a non-system DLL or lacks the UTF-8 active-code-page manifest (`release/check_static_windows_binary.ps1`), or if the standalone and `CompMooring = 5` smoke cases fail with a system-only `PATH` or from a folder named in mixed scripts. It also fails unless `openfast.exe -v` names the -executable `OpenFAST-v5.0.0-CableDyn-0.4.0`: the OpenFAST base and the CableDyn version, +executable `OpenFAST-v5.0.0-CableDyn-0.4.1`: the OpenFAST base and the CableDyn version, never a `-dirty` describe of the patched tree. The release workflow (`.github/workflows/release-windows-static.yml`) produces diff --git a/VALIDATION.md b/VALIDATION.md index 05a2b0a4..722cc8db 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -78,7 +78,7 @@ article's methods, are compared in | **L3-6d** | Moving-touchdown dynamic contact | OrcaFlex 11.6d installed | curv 0.48/0.58%, tension 0.74/0.90/0.80%, TDP 0.08 m, migration 2–3 m/cycle | curv <3–5%, tension <2–3%, TDP excursion bounds <1.5 m | Pass | | **L3-6e** | Out-of-plane / 3D whirl response | OrcaFlex 11.6d | 3D curv 0.52%, interior Y env ≤1.0 mm, sag ≤22 mm, tension 0.14/0.17/0.12% | curv <3%, env <5 cm, tension <2–3% | Pass | | **L3-7** | 1-hour VolturnUS-S mooring dynamic | OrcaFlex 11.6d | `FairTen` mean 0.27%, std 0.26%, min/max 0.50/0.43%, Fourier 0.27/1.14% | ≤5% | Pass | -| **L3-8** | Bend stiffener at a clamped hang-off (5 m tapered polyurethane) | OrcaFlex 11.6d (stiffener attachment) / planar elastica | curvature 0.74% of root; cable moment 0.74%, section moment 0.18%, deflection angle 0.003° | <1.5% / <2%, 0.05° | Pass | +| **L3-8** | Bend stiffener at a clamped hang-off (5 m tapered polyurethane) | OrcaFlex 11.6d (stiffener attachment) / planar elastica | curvature 0.45% of root; cable moment 0.01%, section moment 0.01%, deflection angle 0.0001°; local curvature 0.27% (0.1 m elements) | <1.5% / <2%, 0.05°, <0.5% | Pass | | **L3-H** | Holcombe static lazy-wave experiment | measured marker geometry | position RMSE 1.07–1.89% of suspended length; peak marker-curvature error 0.34–5.54% | end-restraint bracket | Pass | | **L3-B** | Bergdahl chain experiment, 30 orbit conditions | measured upper-end force | RMSE 1.58 N; MAPE 3.11%; Pearson 0.993; Lin concordance 0.989 | full published matrix | Pass | | **L-fig** | Along-arc curvature overlay (CableDyn / OrcaFlex / MoorDyn-F ±4×) | 3-code committed record | CableDyn peak 0.11% from OrcaFlex | <0.5% | Pass | @@ -275,13 +275,19 @@ initialisation. polyurethane stiffener (0.5 m cylinder of 0.60 m, cone to 0.22 m, bore 0.20 m, E 50 MPa; 16 times the cable's EI at the root), on 0.05 m elements. The static curvature over the stiffener and 3 m beyond follows OrcaFlex 11.6d with the stiffener given as a line attachment - within **0.74% of the root curvature** (root 0.4523 against 0.4522 1/m; + within **0.45% of the root curvature** (root 0.4528 against 0.4522 1/m; [`orcaflex_bend_stiffener.py`](https://github.com/SMI-Lab-Inha/CableDyn/blob/main/validation/scripts/orcaflex_bend_stiffener.py)). - Against an independent collocation solution of the planar extensible elastica with - `EI(s)` and the sleeve's weight, the cable's own moment follows within **0.74%** of its root - value, cable and sleeve moments add up to the section moment within **0.18%**, the node - positions within **0.6 mm**, and the end force's deflection from the clamp axis within - **0.003°** (32.37°). + Against an independent collocation solution of the planar extensible elastica of the element + energy with `EI(s)` and the sleeve's weight, the cable's own moment follows within **0.01%** of + its root value, cable and sleeve moments add up to the section moment within **0.01%**, the node + positions within **0.03 mm**, and the end force's deflection from the clamp axis within + **0.0001°** (32.37°). Node by node, relative to the local value floored at 0.1% of the root + curvature, the curvature follows the elastica within **0.99 / 0.52 / 0.27 / 0.06%** on + 0.2 / 0.15 / 0.1 / 0.05 m elements (gates 1 / 1 / 0.5 / 0.5%), under either output recovery, + and under the stiffener the element peaks of `.elements.out`, read through `bend_check`, + within **the same figures** on the same gates. On a cable strained up to 1.2% + (`driver_curvature_stretch`) the curvature and the section moment follow the same elastica + within **0.006%** and **0.001%** of the local value. - **L-fig curvature overlay**: `l3_lazywave_dynamic` writes each site's along-arc curvature profile. [`validation/scripts/curvature_profile_overlay.py`](https://github.com/SMI-Lab-Inha/CableDyn/blob/main/validation/scripts/curvature_profile_overlay.py) @@ -312,7 +318,7 @@ scripts, results, and source provenance are in over the full 30-condition period-radius matrix, cycle-maximum upper-end force has RMSE 1.58 N, mean absolute percentage error 3.11%, Pearson correlation 0.993, and Lin concordance 0.989. Two phase-averaged measured histories had - waveform RMSEs of 5.93% and 2.56% of their force ranges with 0.3.0 (not rerun for 0.4.0). + waveform RMSEs of 5.93% and 2.56% of their force ranges with 0.3.0 (not rerun for 0.4.0 or 0.4.1). Halving the mesh length and the time step changes the mean cycle maximum by at most 0.29% and 0.27%. The 0.3125 ms step resolves the segment axial modes that the snap loads excite (and the axial diff --git a/app/cabledyn.f90 b/app/cabledyn.f90 index 841b9cc3..750a3fcf 100644 --- a/app/cabledyn.f90 +++ b/app/cabledyn.f90 @@ -22,7 +22,7 @@ PROGRAM cabledyn USE CableDyn_Conventions, ONLY: CD_CHANNEL_FMT USE CableDyn_DeckDriver, ONLY: CD_Run_Deck_Driver, CD_Deck_Query_dtM, & CD_Classify_Dynamic_Completion, CD_DECKDRV_OK, & - CD_DECKDRV_BADINPUT, CD_DECKDRV_SOLVEFAIL, CD_DECK_PATHLEN + CD_DECKDRV_BADINPUT, CD_DECKDRV_SOLVEFAIL, CD_DECK_PATHLEN, CD_INIT_NOTE_LEN USE CableDyn_PathIO, ONLY: CD_Get_Argument, CD_Native_Path, CD_Lock_Path, CD_PATH_OK, & CD_LOCK_ACQUIRED, CD_LOCK_HELD, CD_Open_Read, CD_Open_Write USE CableDyn_OpenFAST_Aggregate, ONLY: CD_AGG_ModuleType, CD_AGG_Init_From_Deck, & @@ -777,7 +777,7 @@ SUBROUTINE print_mixed_initialization(aggregate, stat, msg) INTEGER :: n_lines, n_points, n_sections, n_ei0, n_finite, i, line_id, es REAL(wp) :: tension, force(3), inclination, declination, azimuth CHARACTER(4096) :: em - CHARACTER(2048) :: note + CHARACTER(CD_INIT_NOTE_LEN) :: note CALL CD_AGG_GetInitMetadata(aggregate, n_lines, n_points, n_sections, n_ei0, n_finite, es, em) IF (es /= CD_AGG_OK) THEN diff --git a/doc/api_python.rst b/doc/api_python.rst index 20262612..ec38d944 100644 --- a/doc/api_python.rst +++ b/doc/api_python.rst @@ -62,8 +62,10 @@ Reading results :func:`read_output` returns the most specific table class for the file it reads: :class:`TimeHistory` for the main output, :class:`LineNodeHistory` and :class:`LineSegmentHistory` for per-line dynamic outputs, :class:`StaticProfile` for the static -profile, and :class:`OutputTable` for any other numeric table. :class:`TimeHistory` and -:class:`StaticProfile` are subclasses of :class:`OutputTable`; :class:`LineNodeHistory` and +profile, :class:`NaturalModes` for the two tables of a ``.modes.out`` file, and +:class:`OutputTable` for any other numeric table. :class:`TimeHistory`, :class:`StaticProfile`, +:class:`NaturalModes` and :class:`ModeShapes` are subclasses of :class:`OutputTable`; +:class:`LineNodeHistory` and :class:`LineSegmentHistory` are subclasses of :class:`TimeHistory`, so every table method is available on them. @@ -84,6 +86,12 @@ available on them. .. autoclass:: cabledyn.StaticProfile :members: +.. autoclass:: cabledyn.NaturalModes + :members: + +.. autoclass:: cabledyn.ModeShapes + :members: + .. autoclass:: cabledyn.ChannelStatistics :members: @@ -494,6 +502,66 @@ library is located. Array ordering, frames, and units at the coupling boundary f Release the native handle. Calling it again is harmless. + .. py:method:: init_line(q0, v0, elem_conn, l0, ea, rho_a, fixed_dofs, tension_only, rho_inf) + + Initialise from one line meshed by the caller (``CableDyn_InitLine``). The model is + structural only: two-node ``EI = 0`` axial elements with a consistent mass matrix, without + gravity, buoyancy, seabed, hydrodynamics or structural damping, and without a static solve. + The handle has no deck and no points; the views of :attr:`lines` run from the last node of + ``q0`` to its first. + + :param q0: Initial node positions (m), flat ``(3 * n_nodes,)`` or column-per-point + ``(3, n_nodes)``; a ``(3, 3)`` array is rejected as ambiguous. + :type q0: array_like + :param v0: Initial node velocities (m/s), shaped as ``q0``. + :type v0: array_like + :param elem_conn: **1-based** node pair of each element, flat ``(2 * n_elem,)`` or + ``(n_elem, 2)``. An integer array. + :type elem_conn: array_like + :param l0: Unstretched length of each element (m), ``(n_elem,)``. + :type l0: array_like + :param ea: Axial stiffness of each element (N), ``(n_elem,)``. + :type ea: array_like + :param rho_a: Mass per unit unstretched length of each element (kg/m), ``(n_elem,)``. + :type rho_a: array_like + :param fixed_dofs: **1-based** DOFs the caller prescribes, in the order of the coupled + vector; node ``k`` owns DOFs ``3k-2``, ``3k-1`` and ``3k``. May be empty. + :type fixed_dofs: array_like + :param tension_only: If true an element goes slack instead of carrying compression. + :type tension_only: bool + :param rho_inf: Generalised-α spectral radius, in ``[0, 1]``. + :type rho_inf: float + :raises ValueError: on a wrong shape, a non-finite value, or ``rho_inf`` outside + ``[0, 1]``, before the library is called. + :raises TypeError: on a non-real array, a non-integer index array, or a non-numeric + scalar, before the library is called. + :raises CableDynError: if the library rejects the values (an index out of range, a repeated + fixed DOF, a non-positive ``l0``, ``ea`` or ``rho_a``) or the initial acceleration solve + fails. The handle must then be initialised again and a saved state is lost, unless it + held a model on the coupled aggregate, which stays live with its save. + + .. py:method:: init_lines(n_nodes, n_elem, n_fixed, q0, v0, elem_conn, l0, ea, rho_a, fixed_dofs, tension_only, rho_inf, coupled_map=None) + + Initialise from several lines meshed by the caller (``CableDyn_InitLines``), each as in + :meth:`init_line`, in one system. Every array is the concatenation, in line order, of that + line's :meth:`init_line` array; ``elem_conn`` and ``fixed_dofs`` stay local to each line. + + :param n_nodes: Node count of each line, at least 2. + :type n_nodes: array_like + :param n_elem: Element count of each line, at least 1. + :type n_elem: array_like + :param n_fixed: Prescribed DOF count of each line, at least 0. + :type n_fixed: array_like + :param coupled_map: Connects lines at shared points. Every line has exactly six fixed DOFs, + and entry ``6 * i + j`` (0-based ``i``, ``j``) is the **1-based** system DOF of line + ``i``'s ``j``-th fixed DOF. The entries use every index from 1 to their maximum, which is + the coupled DOF count; loads and Jacobian entries of a shared DOF are summed. ``None`` + keeps the lines independent, their ``fixed_dofs`` in turn forming the coupled vector. + :type coupled_map: array_like | None + :raises ValueError: as :meth:`init_line`, and if the counts do not match the arrays. + :raises TypeError: as :meth:`init_line`. + :raises CableDynError: as :meth:`init_line`, and on a ``coupled_map`` with a gap. + .. py:method:: last_error() Return the library's most recent diagnostic message for this instance. @@ -501,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 @@ -710,6 +789,76 @@ library is located. Array ordering, frames, and units at the coupling boundary f :rtype: numpy.ndarray :raises CableDynError: if the call fails. + .. py:method:: set_host_fluid(enable=True) + + Choose, before a deck initialization, whether the host supplies the ambient fluid. + With ``enable``, the following :meth:`init_deck`, :meth:`init_deck_at` and + :meth:`init_deck_rotating` take the field of :meth:`update_fluid_fields`: deck waves + are refused and a steady deck current sets the static start only. + + :param enable: Whether the host supplies the fluid. + :type enable: bool + :raises CableDynError: if the call fails. + + .. py:attribute:: n_fluid_nodes + + Nodes of the host fluid field; zero without one. + + .. py:method:: fluid_node_positions() + + Committed positions of the fluid nodes, where the host evaluates the field of the + coming step. + + :returns: Global positions (m), shaped ``(3, n_fluid_nodes)``. + :rtype: numpy.ndarray + :raises CableDynError: on a model built without a host fluid field. + + .. py:method:: update_fluid_fields(fluid_velocity, fluid_acceleration, waterline_z, dynamic_pressure=None) + + Prescribe the ambient fluid at every fluid node, held until the next call. Call it + once with the field at ``t = 0`` before the first step, then before each step from + ``t`` to ``t + dt`` with the field at ``t + dt`` at the positions of + :meth:`fluid_node_positions`. The density is the deck's. + + :param fluid_velocity: Whole ambient velocity (m/s), global axes, shaped + ``(3, n_fluid_nodes)`` or flat. + :type fluid_velocity: array_like + :param fluid_acceleration: Fluid acceleration (m/s\ :sup:`2`), with the same shape. + :type fluid_acceleration: array_like + :param waterline_z: Free-surface elevation above each node (m), ``(n_fluid_nodes,)``. + :type waterline_z: array_like + :param dynamic_pressure: Wave dynamic pressure (Pa) for the end caps of rods. + :type dynamic_pressure: array_like, optional + :raises CableDynError: on a model built without a host fluid field, or if the call + fails. + + .. py:method:: calc_output_derivatives(q, v, a) + + Return the coupled loads at an operating point and their analytic Jacobians + (``CableDyn_CalcOutputDerivatives``). Each line's interior DOFs are condensed out: interior + positions and velocities are held and interior accelerations eliminated through the mass + matrix, so ``dload_da`` is :math:`-(M_{cc} - M_{cf} M_{ff}^{-1} M_{fc})`, and the blocks are + those of :meth:`update_states` followed by :meth:`calc_output`. + + This call **sets the model state**, as :meth:`update_states` does, also when a later stage + of it fails; the previous state is not restored and :attr:`time` does not change. The + library's unused ``eps_fd`` argument is not exposed. + + :param q: Operating-point positions (m). + :type q: array_like + :param v: Operating-point velocities (m/s). + :type v: array_like + :param a: Operating-point accelerations (m/s\ :sup:`2`). + :type a: array_like + :returns: ``(loads, dload_dq, dload_dv, dload_da, added_mass)``. ``loads`` is flat, + ``(n_coupled_dof,)`` (N). The others are ``(n_coupled_dof, n_coupled_dof)`` with + ``J[i, j]`` the derivative of load ``i`` with respect to coupled DOF ``j``: N/m, N·s/m, + kg and kg; ``added_mass`` is ``-dload_da``. + :rtype: tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray, numpy.ndarray] + :raises ValueError: if an array has the wrong shape or a non-finite value. + :raises CableDynError: if the library rejects the call, as on a model on the coupled + aggregate (finite-EI lines, bodies or rods), which has no load derivatives. + .. py:method:: update_point_fluid_fields(fluid_velocity, fluid_acceleration, waterline_z, fluid_density) Prescribe external fluid kinematics at every system point. @@ -828,7 +977,8 @@ imports without the shared library. .. py:function:: abi_minor() - Return the extension level of ABI 1 of the loaded library: 3 when it also has the + Return the extension level of ABI 1 of the loaded library: 4 when it also has the host + fluid calls behind :meth:`CableDyn.update_fluid_fields`, 3 when it has the host-rotation calls behind :meth:`CableDyn.init_deck_rotating`, :meth:`CableDyn.update_rotations` and :meth:`CableDyn.coupled_moments`, 2 when it has the host-coupling calls behind :meth:`CableDyn.init_deck_at`, :meth:`CableDyn.save_state` and diff --git a/doc/capabilities.rst b/doc/capabilities.rst index cfba7e1d..eea4aa13 100644 --- a/doc/capabilities.rst +++ b/doc/capabilities.rst @@ -108,7 +108,7 @@ Line and constitutive capability the platform, ``PtfmInit``, checkpoints, linearisation with the twist angles as states; see :doc:`openfast`) and through the C API with the host's rotations (``CableDyn_InitDeckRotating``, :doc:`capi`), active line control (``CONTROL``) of the - fairlead segment on the coupled route; ``Torq``/``Twist`` channels and standalone range + fairlead segment on the coupled route; ``Torq``/``Twist`` channels and range graphs. Stopped by name: ``CableDyn_InitDeck``/``InitDeckAt`` (which move the line ends by translation only), ends of free and pinned rods (axisymmetric, no roll angle) and of ``Coupled``/``Vessel`` rods, bodies other than Rigid6 and ``False alpha_force_blend``. @@ -193,9 +193,10 @@ Loads and environment - a 6-DOF vessel record, or the RAO response to the deck waves, moves every ``Coupled``/``Vessel`` point of a line deck; see :doc:`options` * - Range graphs (``r`` line output flag, ``RangeStart``) - - Standalone only - - minimum, maximum, and mean along each line over the run; a coupled OpenFAST deck rejects - the flag + - Standalone and OpenFAST + - minimum, maximum, and mean along each line over the run, from the committed steps; a run + restarted from a checkpoint keeps them. A FAST.Farm shared mooring and the C API reject + the flag by name * - WaterKin CurrentMod-1 file table - Supported - bit-identical to the equivalent inline depth profile @@ -221,7 +222,8 @@ node that takes the platform position only and returns a force with zero moment, rods turn about a fixed End A. A coupled deck (OpenFAST, the C API and Python) carries bodies or rods, not both, a rod fixed to a body counting as a rod; the standalone driver's multibody march takes both, as does MoorDyn (``CompMooring = 3``). The C API and Python -run free, fixed and pinned rods and free bodies in still water. The coupled body/rod routes carry +run free, fixed and pinned rods and free bodies in still water or in a field the host supplies +at the nodes (:doc:`capi`). The coupled body/rod routes carry checkpoint state and SeaState drag, Froude–Krylov, and added mass. ``Rigid6`` also carries the documented hydrostatic restoring terms. Consult :doc:`driver_format` before using an imported object because unsupported fields fail closed. diff --git a/doc/capi.rst b/doc/capi.rst index 5fac1f83..a4d00fd6 100644 --- a/doc/capi.rst +++ b/doc/capi.rst @@ -29,11 +29,12 @@ A model lives behind an opaque handle. The lifecycle is: .. code-block:: text CableDyn_Create + -> [CableDyn_SetHostFluid] (host fluid field) -> CableDyn_InitDeck | CableDyn_InitDeckAt | CableDyn_InitDeckRotating | CableDyn_InitLine | CableDyn_InitLines (initialize; may repeat) -> CableDyn_GetCoupledMotion (read the initial state) -> loop: [CableDyn_SaveState | CableDyn_RestoreState] (host outer correctors) - [CableDyn_UpdatePointFluidFields] + [CableDyn_UpdatePointFluidFields | CableDyn_UpdateFluidFields] [CableDyn_UpdateRotations] (a host that turns) CableDyn_Step(dt, motion at t+dt) CableDyn_CalcOutput [, CableDyn_GetCoupledMoments] (loads at t+dt) @@ -61,6 +62,9 @@ MoorDyn's C API. - set the coupled-point motion without advancing time * - ``CableDyn_UpdatePointFluidFields`` - set fluid kinematics at the deck points (point hydrodynamics) + * - ``CableDyn_SetHostFluid``, ``CableDyn_NFluidNodes``, ``CableDyn_GetFluidNodePositions``, + ``CableDyn_UpdateFluidFields`` + - supply the ambient fluid at the nodes of every line (`Step 9: moving water`_) * - ``CableDyn_Step`` - advance one implicit step to :math:`t+\Delta t` * - ``CableDyn_CalcOutput`` @@ -71,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 @@ -116,12 +122,12 @@ files directly, as shown below. - Library files - Header * - Linux - - ``lib/libcabledyn.so.0.4.0``, the soname link ``libcabledyn.so.1``, and the development + - ``lib/libcabledyn.so.0.4.1``, the soname link ``libcabledyn.so.1``, and the development link ``libcabledyn.so``. ``lib`` is the platform ``CMAKE_INSTALL_LIBDIR``, which can be ``lib64`` or a multiarch directory. - ``include/CableDyn_CAPI.h`` * - macOS - - ``lib/libcabledyn.0.4.0.dylib``, ``lib/libcabledyn.1.dylib``, ``lib/libcabledyn.dylib`` + - ``lib/libcabledyn.0.4.1.dylib``, ``lib/libcabledyn.1.dylib``, ``lib/libcabledyn.dylib`` - ``include/CableDyn_CAPI.h`` * - Windows, GNU toolchain - ``bin/libcabledyn.dll`` and the import library ``lib/libcabledyn.dll.a``. The GNU @@ -251,7 +257,7 @@ ABI and versioning - the same four numbers, reported by the loaded library * - ``CableDyn_GetVersionString`` - runtime - - ``"CableDyn 0.4.0 C-ABI 1"`` + - ``"CableDyn 0.4.1 C-ABI 1"`` * - shared-library ``SOVERSION`` - 1 - soname ``libcabledyn.so.1`` or install name ``libcabledyn.1.dylib``. The Windows DLL @@ -344,8 +350,10 @@ These rules apply to every entry point. The Python package uses the same array o ``CableDyn_InitDeckAt`` on an initialised handle replaces the model only on success. If the new initialisation fails, the previous model is kept unchanged, and so is the state saved by ``CableDyn_SaveState``; a successful one discards that save. ``CableDyn_InitLine`` and - ``CableDyn_InitLines`` build the point model in place: after a failure the handle must be - initialised again, and the saved state is discarded. The one exception is a handle whose + ``CableDyn_InitLines`` build the point model in place: a failure during the build leaves the + handle to be initialised again, an input rejected before it leaves the previous model live + (``CableDyn_IsInitialized`` tells which), and either way the saved state is discarded. The one + exception is a handle whose deck model runs on the coupled aggregate (finite-EI lines, bodies or rods): a failed ``InitLine``/``InitLines`` leaves that model live, and its saved state with it. * **Time.** The library keeps no clock. Only ``dt`` enters ``CableDyn_Step``, and the host owns @@ -685,6 +693,58 @@ The rotations are inputs, like the motion: an end frame turned by more than a qu about its line since the last committed step is refused, and ``CableDyn_SaveState``/``CableDyn_RestoreState`` carry the last rotations with the model. +Step 9: moving water +~~~~~~~~~~~~~~~~~~~~ + +A deck's steady current is marched as in the standalone driver: a ``current`` option, a WaterKin +``CurrentMod 1`` depth table or a MoorDyn-C ``Currents 1`` profile acts in the static start and in +every step, on lines between ``Fixed``, ``Coupled`` and ``Vessel`` points. Nothing else is needed. + +A host that evaluates the water itself, such as a wave field not known in advance, supplies it at +the nodes: + +.. code-block:: c + + CableDyn_SetHostFluid(h, 1, &err); /* before the initialization */ + CableDyn_InitDeckRotating(h, deck, len, q0, n_q0, &err); + n = CableDyn_NFluidNodes(h, &err); + + CableDyn_GetFluidNodePositions(h, xyz, n, &err); /* the field at t = 0 */ + CableDyn_UpdateFluidFields(h, vel, acc, eta, NULL, n, &err); + for (;;) { /* a step from t to t + dt */ + CableDyn_GetFluidNodePositions(h, xyz, n, &err); /* the step-start positions */ + /* evaluate vel, acc, eta at xyz at time t + dt */ + CableDyn_UpdateFluidFields(h, vel, acc, eta, NULL, n, &err); + CableDyn_Step(h, dt, q, v, a, n_dof, &converged, &stalled, &n_iter, &err); + } + +The convention is that of the standalone march and of the OpenFAST module: + +* the field of a step from ``t`` to ``t + dt`` is sampled at ``t + dt`` at the step-start node + positions, and it is held unchanged over the step; +* ``vel`` is the whole ambient velocity, current and waves together, ``acc`` the fluid + acceleration, both as ``x, y, z`` triples in global axes, and ``eta`` the free-surface elevation + above each node, which decides the wetting of its drag, added mass and buoyancy; +* the first call, before the first step, is the field at ``t = 0`` and sets the start acceleration; + a step without any field is refused, because the march would otherwise run in still water; +* ``dynamic_pressure`` may be ``NULL``; rods use it for their end caps; +* the density is the deck's ``rhoW``; +* the nodes are every node of every line, then the deck's bodies, rods and hydrodynamic points. + The field depends on position alone, so the host needs no node numbering; +* ``CableDyn_SaveState`` and ``CableDyn_RestoreState`` carry the held field. + +With the host field, a deck runs on the coupled aggregate whatever its lines, needs ``dtM``, and +needs a seabed if it has ``EI = 0`` lines. Deck waves are refused. A steady deck current sets the +static start only, so the start is the equilibrium in that current, and the host's field is then +the whole water of the march; a deck current that varies in time is refused. + +Seabed friction, ``staticsFriction asLaid`` and ``STIFFENERS`` work on this route. + +With a host field the deck is read by the coupled rules, as in OpenFAST: it needs no ``TMax``, and a +``SEASTATE`` or ``WaveKinMod 2`` water policy takes the host's field. A deck current marched without +one follows the standalone rules and declares ``TMax``. Neither limits the host, which owns the clock +and may step past it. + Call sequence ~~~~~~~~~~~~~ @@ -770,7 +830,7 @@ Version queries - ``char *`` - ``version_len`` - yes: no-op - - receives ``"CableDyn 0.4.0 C-ABI 1"``, truncated to ``version_len - 1`` characters and + - receives ``"CableDyn 0.4.1 C-ABI 1"``, truncated to ``version_len - 1`` characters and ``NUL``-terminated * - ``version_len`` - in @@ -908,8 +968,10 @@ coupled aggregate as the OpenFAST module, with these differences from the point- the order of `Coupled DOF layout`_ (anchors are not part of the vector); the bodies and rods are integrated internally; * the deck must declare ``dtM``, and every ``CableDyn_Step`` must use that ``dt``; -* the run is in still water: deck waves or currents are rejected, and - ``CableDyn_UpdatePointFluidFields`` and ``CableDyn_CalcOutputDerivatives`` return +* a deck current is marched on lines between ``Fixed``, ``Coupled`` and ``Vessel`` points, + deck waves are rejected, and other moving water comes from the host (`Step 9: moving + water`_); +* ``CableDyn_UpdatePointFluidFields`` and ``CableDyn_CalcOutputDerivatives`` return ``CD_C_BAD_INPUT``; * ``CableDyn_NPoints`` and ``CableDyn_NLines`` report the deck's point and line counts. @@ -937,16 +999,20 @@ The deck is validated under the standalone-deck rules: - the standalone driver (prescribe motion through ``CableDyn_Step`` instead) * - a ``FAILURE`` section (line failures) - the standalone driver; the OpenFAST module + * - the ``LINES`` ``Outputs`` flag ``r`` (range graph) + - the standalone driver; the OpenFAST module in a single-turbine run * - a ``CONTROL`` section (active line control) - the OpenFAST module * - ``WaveKinMod 2`` / ``SEASTATE`` wave kinematics - - the OpenFAST module + - the OpenFAST module; a host that calls ``CableDyn_SetHostFluid`` * - a ``waves`` option (regular or JONSWAP deck waves) - the standalone driver; supply time-varying fluid kinematics through - ``CableDyn_UpdatePointFluidFields`` + ``CableDyn_UpdateFluidFields`` (`Step 9: moving water`_), or at the points of a point + deck through ``CableDyn_UpdatePointFluidFields`` * - a MoorDyn-C ``Currents 2``/``5`` table that varies in time - the standalone driver; supply time-varying fluid kinematics through - ``CableDyn_UpdatePointFluidFields`` + ``CableDyn_UpdateFluidFields`` (`Step 9: moving water`_), or at the points of a point + deck through ``CableDyn_UpdatePointFluidFields`` * - FAST.Farm ``Turbine`` points - the OpenFAST module in FAST.Farm * - ``frictionMu`` in a deck that has ``Free`` or ``Connect`` points @@ -1693,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`` ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1817,8 +1912,9 @@ handle numbers its lines 1..n). one value per node; ``_SEGMENT_TENSION`` (9), one value per segment; ``_DECK_NODE`` (10), one value per node: the deck node ``J`` of the node (the ```` of the node channels), 0 for a node a mesh refinement added, ``1 … n_nodes`` on a line - meshed as the deck. ``n_out`` must equal the value count. A library without code 10 - rejects it with ``CD_C_BAD_INPUT``. + meshed as the deck; ``_SECTION_CURVATURE`` (11), two values per node, ``CurvX`` and + ``CurvY`` interleaved, on a finite-EI line. ``n_out`` must equal the value count. A library + without code 10 or 11 rejects it with ``CD_C_BAD_INPUT``. * - ``CableDyn_GetPointState`` - position, velocity and the resultant force of the attached lines (``Point

F``), 3 doubles each @@ -1840,6 +1936,11 @@ On a finite-EI line whose mesh the static solve refined, ``n_nodes`` and the (:ref:`outputs-refined-nodes`); ``CD_C_LINE_DECK_NODE`` locates it: the array row whose value is 5. +``CableDyn_GetLineValues`` evaluates a whole line in one call, with no text token per node, and +each value is bit for bit the node channel's. It is the cheap way to read many nodes at every +step. Reading the tension, curvature and bend moment of a line after a step shares one evaluation +of its elements. + A ``NULL`` output pointer skips that output, except for the ``out`` array of ``CableDyn_GetLineValues`` and the ``value`` of ``CableDyn_EvalChannel``; the latter is not checked and must point to valid storage. An index out of range, a wrongly sized buffer, an diff --git a/doc/citing.rst b/doc/citing.rst index eb5be3a5..252c45c8 100644 --- a/doc/citing.rst +++ b/doc/citing.rst @@ -53,7 +53,7 @@ software]. https://github.com/SMI-Lab-Inha/CableDyn } When results depend on a particular release, quote the version reported in the solver banner -(``CableDyn v0.4.0``) together with the options recorded for the run. +(``CableDyn v0.4.1``) together with the options recorded for the run. Relation to the journal article ------------------------------- diff --git a/doc/coupling_boundary.md b/doc/coupling_boundary.md index 16950b31..7985a2d6 100644 --- a/doc/coupling_boundary.md +++ b/doc/coupling_boundary.md @@ -28,9 +28,12 @@ acceleration enters the dynamic residual through the consistent mass. **Fluid fields in.** -- Line nodes take their fluid kinematics from the deck environment (waves and current) in - standalone and C API use, and from the host field in OpenFAST, where SeaState is sampled at the - structural nodes and held over the step. +- Line nodes take their fluid kinematics from the deck environment in standalone use (waves and + current) and in C API use (current), and from a host field in OpenFAST, where SeaState is sampled + at the structural nodes, and in C API use after `CableDyn_SetHostFluid`, where the host passes + velocity, acceleration and surface elevation at every node through + `CableDyn_UpdateFluidFields`. All routes sample the field of a step at `t + dt` at the + step-start node positions and hold it over the step. - Dynamic `Free`/`Connect` points with hydrodynamic properties accept an external point-level field through `CableDyn_UpdatePointFluidFields`: velocity and acceleration (3 × `n_point`), local surface elevation (`n_point`), and one fluid density. A point above its surface @@ -78,8 +81,9 @@ on `t + dt` and the host time grid is unchanged. In OpenFAST the module may adva The public header declares the complete interface of C ABI version 1, including the object queries of its minor extension 1 (ABI 1.1; check `CableDyn_GetAbiMinor() >= 1` before calling them), the host-coupling calls of its minor extension 2 (ABI 1.2; check -`CableDyn_GetAbiMinor() >= 2`) and the host-rotation calls of its minor extension 3 (ABI 1.3; -check `CableDyn_GetAbiMinor() >= 3`). Signatures and examples are in the [C API guide](capi.rst), +`CableDyn_GetAbiMinor() >= 2`), the host-rotation calls of its minor extension 3 (ABI 1.3; +check `CableDyn_GetAbiMinor() >= 3`) and the host-fluid calls, initialisation notes and section +curvature of its minor extension 4 (ABI 1.4; check `CableDyn_GetAbiMinor() >= 4`). Signatures and examples are in the [C API guide](capi.rst), whose section "Coupling a host (CFD) solver" gives the call sequence of a CFD host. | Group | Functions | @@ -93,6 +97,7 @@ whose section "Coupling a host (CFD) solver" gives the call sequence of a CFD ho | 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) | +| Host fluid and notes (ABI 1.4) | `CableDyn_SetHostFluid` (before the initialisation: the host supplies the ambient fluid), `CableDyn_NFluidNodes`, `CableDyn_GetFluidNodePositions`, `CableDyn_UpdateFluidFields` (the fluid at every line node), `CableDyn_GetInitNotes` (the notes of the last deck initialisation), and the line quantity `CD_C_LINE_SECTION_CURVATURE` of `CableDyn_GetLineValues` | Rules shared by every entry point: @@ -112,7 +117,7 @@ Rules shared by every entry point: The interface ships as the versioned shared library `libcabledyn` (`cabledyn.dll` in an Intel Fortran and MSVC build; CMake target `cabledyn_shared`) with the installed header; it reports -CableDyn 0.4.0 and C ABI version 1, minor extension 3. The Python package uses the same library. +CableDyn 0.4.1 and C ABI version 1, minor extension 4. The Python package uses the same library. ### Concurrency diff --git a/doc/driver_format.md b/doc/driver_format.md index 8da2e336..84d808ed 100644 --- a/doc/driver_format.md +++ b/doc/driver_format.md @@ -740,7 +740,7 @@ never both: a `SECTIONS` row for a 7-column line is an error naming the line. |--------|---------| | `ID` | unique line id ≥ 1 | | `NodeA`, `NodeB` | End A / End B POINT ids (distinct). **End A is the fairlead (top) end and End B the anchor (lower) end**, as in OrcaFlex. | -| `Outputs` | per-line file flags. `-` = none. `p` (node positions) and `t` (segment tensions) write `.Line.p.out` / `.t.out`, End A first. Static EI = 0 decks write node/segment tables; independent EI = 0, point-system EI = 0, independent finite-EI, rod, and Rigid6 dynamic decks write time series (one row per output time). `r` writes the range graph `.Line.range.out`: the minimum, maximum and mean over the run (from `RangeStart`) of the node tension, curvature, bend moment, declination and seabed clearance (and torque and twist on a line with torsion), on every standalone route; a coupled OpenFAST deck rejects it. Line-node channels in the main `.out` file are requested in `OUTPUTS`. | +| `Outputs` | per-line file flags. `-` = none. `p` (node positions) and `t` (segment tensions) write `.Line.p.out` / `.t.out`, End A first. Static EI = 0 decks write node/segment tables; independent EI = 0, point-system EI = 0, independent finite-EI, rod, and Rigid6 dynamic decks write time series (one row per output time). `r` writes the range graph `.Line.range.out`: the minimum, maximum and mean over the run (from `RangeStart`) of the node tension, curvature, bend moment, declination and seabed clearance (and torque and twist on a line with torsion), on every standalone route and in a single-turbine OpenFAST run (`.CD.Line.range.out`); a FAST.Farm shared mooring and the C API reject it. Line-node channels in the main `.out` file are requested in `OUTPUTS`. | **Automatic anchor-first swap.** Stock MoorDyn decks list lines anchor first. When a line's `NodeA` is a `Fixed` point and its `NodeB` is a `Coupled`, `Vessel`, `Body`, `Free`, or @@ -1099,7 +1099,10 @@ Behaviour: - The element properties are piecewise constant, one value per deck element, so the deck mesh must resolve the taper: an initialisation note names a stiffener that spans fewer than 10 elements and the element length that resolves it. Split the section at the stiffener tip and - mesh the part under it finely (about 0.1–0.2 m elements for a typical 3–8 m stiffener). + mesh the part under it finely (about 0.1–0.2 m elements for a typical 3–8 m stiffener). The + node outputs and the element peaks of `.elements.out` do not take these element values: they + read `EI(s)` at their own arc, and at a real step of the section (the tip, rows that do not + match, two line types) the softer side's. - The stiffener root takes the moment only where the end is held in rotation: declare the end `Rigid`, or a rotational spring, in [END CONNECTIONS](#end-connections-optional-finite-ei-lines). A stiffener at a pinned end prints a note. @@ -1264,7 +1267,7 @@ syntax form. The most frequently edited keywords are: | `TMax` | dynamic end time [s]; an integer multiple of `dtM` | — | | `rhoInf` | generalised-α spectral radius | 0.4 | | `maxStrain` | plausibility bound on `EI = 0` element strain, checked after every committed dynamic step; a non-finite state or a larger strain stops the run with exit code 2. `0` disables it | 0.5 | -| `RangeStart` / `range_start` | first output time [s] in the range graphs of the `LINES` `Outputs` flag `r`; earlier rows (a start-up transient) are left out. ≥ 0 and ≤ `TMax`; needs `TMax` and a line with the `r` flag | 0 | +| `RangeStart` / `range_start` | first output time [s] in the range graphs of the `LINES` `Outputs` flag `r`; earlier rows (a start-up transient) are left out. ≥ 0 and ≤ `TMax` (the host's in OpenFAST); needs a line with the `r` flag and, standalone, `TMax` | 0 | | `modified_newton` | `True`/`False`: reuse the tangent within a step on every dynamic solver the deck builds (the EI = 0 system and each coupled finite-EI cable): one effective-tangent factorization per step, contraction-checked with a refresh when the direction goes stale. Committed states meet the same Newton tolerance either way | `False` | | `cable_load_feedback` | coupled finite-EI cable reaction switch. `True` returns cable force and moment to the host. `False` marches the cable with the same moving boundary and host fluid field and keeps its output channels, but returns zero force and moment; use it for a one-way comparison, not as a physical model | `True` | | `adaptive_mesh` | `True`/`False`; allow finite-EI mesh refinement when the static resolution check is fragile | `False` | @@ -1329,7 +1332,7 @@ every node's position and time as MoorDyn-C interpolates them. Standalone decks | Option | File | Meaning | |---|---|---| | `WaveKin 3` | `wave_elevation.txt` | `time elevation` rows from `t = 0` (no header rows), resampled linearly at `dtWave` to `floor(t_last/dtWave) + 1` samples (the last dropped when the count is odd) and reduced to its Fourier components; components above `WvHiCOff` (default 0.5 Hz, MoorDyn-C's fixed cut) are dropped and the mean is a mean-level offset, as MoorDyn-C does. Waves travel along +x | -| `WaveKin 7` | `wave_frequencies.txt` | `omega Re Im [beta]` rows [rad/s, m, m, rad], the first at `omega = 0`: each row is the component `\|c\| sin(omega t - k d + arg c)`, `d = x cos(beta) + y sin(beta)`; the `omega = 0` row is a mean-level offset. All rows must share one `beta` | +| `WaveKin 7` | `wave_frequencies.txt` | `omega Re Im [beta]` rows [rad/s, m, m, rad], the first at `omega = 0`: each row is the component `\|c\| sin(omega t - k d + arg c)`, `d = x cos(beta) + y sin(beta)` with the row's own `beta` (0 when the column is left out); the `omega = 0` row is a mean-level offset and its `beta` has no effect. Rows of different directions describe a short-crested sea, summed in ascending frequency | | `Currents 1` | `current_profile.txt` | `z ux uy uz` rows after its header rows, strictly increasing `z`; linear in `z` and held beyond the end rows | | `Currents 2` | `current_profile_dynamic.txt` | a depth profile in time: lines 1-4 and 6 are free text, line 5 lists the depths `z1 ... zn` (strictly increasing, read in single precision as MoorDyn-C does), and each later row is `t ux1 ... uxn [uy1 ... uyn [uz1 ... uzn]]` (missing components zero), at least two rows with strictly increasing `t` | | `Currents 5` | `current_profile_4d.txt` | a grid in x, y, z and time: line 1 is `nx ny nz nt`, lines 2-5 the x, y, z and t coordinates (each strictly increasing, at least two times), then one `x y z t ux uy uz` row per grid point, in any order, matched to the grid by value; a text line 6 is a header | @@ -1368,16 +1371,28 @@ where it is sampled on `water_grid.txt`; CableDyn adds the current at the nodes, Rejected by name: `WaveKin 1` (kinematics set node by node through the MoorDyn-C API; a deck has no source for them), `WaveKin 2` (the FFT grid: `wave_frequencies.txt` resampled to an even spectrum; `WaveKin 7` evaluates the same file's components exactly), `WaveKin 4`-`6` and -`Currents 3`-`4` (no kinematics source in MoorDyn-C v2.7.1), and a `wave_frequencies.txt` whose -rows have different directions. A deck that also declares a `waves` option, a WaterKin file, or a -second current source is rejected as double counting. On shared inputs the `wavekin_modes` test -holds `WaveKin 7` and `Currents 1` identical to MoorDyn-C v2.7.1 (`5e-12` relative) and `WaveKin 3` +`Currents 3`-`4` (no kinematics source in MoorDyn-C v2.7.1). A deck that also declares a `waves` +option, a WaterKin file, or a second current source is rejected as double counting. On shared +inputs the `wavekin_modes` test holds `WaveKin 7` and `Currents 1` identical to MoorDyn-C v2.7.1 (`5e-12` relative) and `WaveKin 3` within `3e-7` m in elevation and `1e-4` in velocity and acceleration at MoorDyn-C's grid points, the remainder being MoorDyn-C's grid interpolation and its approximate wave number. The `mdc_currents` test holds `Currents 2` and `5` identical to MoorDyn-C v2.7.1 at 739 points and times (including past the last sample), and `mdc_currents_runs` matches the tension change of a time-varying and of a sheared current to MoorDyn-C's within 0.9 % on a 70 m rope. +`wave_frequencies.txt` is the file MoorDyn-C v2.7.1 reads, a `beta` per row included: its +`SUM_COMPONENTS` mode gives each row its own direction too. `WaveRefX` and `WaveRefY` +([options](options.rst)) are an extension of CableDyn and no part of MoorDyn-C or MoorDyn-F: a +deck with them is not a plain MoorDyn deck, and a MoorDyn solver would not place the phases there. + +A mean level, a non-zero `omega = 0` row or the mean of `wave_elevation.txt`, is added to the +elevation alone, as MoorDyn-C does: every wave number stays that of `WtrDpth`, and the Wheeler +stretching maps the water column under the total elevation, the level included. The +`wavekin_modes` test holds both files with a 0.8 m level, under six `WaveKin 7` components in +six directions, to MoorDyn-C v2.7.2: `WaveKin 7` to `1e-8` of its scale and `WaveKin 3` to the +tolerances above. The mean of a WaterKin `WaveKinMod 1` record raises the still-water level +instead ([below](#moordyn-f-waterkin-file-modes)). + (waterkin-file-modes)= ### MoorDyn-F `WaterKin` file modes @@ -1394,6 +1409,11 @@ A MoorDyn-F deck may select a WaterKin file (grammar in [Auxiliary file formats] | `CurrentMod = 1` | N-level WaterKin depth table, interpolated at structural nodes | | `CurrentMod = 2` | current from the coupled host SeaState field | +The mean of a `WaveKinMod 1` record raises the still-water level: its wave numbers are those of +`WtrDpth` plus the mean, and the stretching maps the water column under the raised level. +MoorDyn-F instead drops the record's mean, and its Nyquist bin, so a record whose mean is not zero +puts the surface of the two codes apart by that mean. + **`WaveKinMod 1`** is standalone-only. It needs a `WaveKinFile` of at least four finite `time elevation` rows starting at `t = 0` with strictly increasing times, a positive `dtWave`, a positive `TMax`, and the ordinary `waves` requirements (`dtM`/`TMax` and `WtrDpth`). It cannot be @@ -1550,8 +1570,8 @@ bending moment. None of these channels is mandatory. | `Body…`, `Rod…` | Rigid6 body and rod channels in the MoorDyn-F names: position, attitude, velocity and acceleration (`P`, `R`, `V`, `RV`, `A`, `RA`), net force and moment (`F`, `M`), rod end tensions (`TenA`, `TenB`), submerged fraction (`Sub`) and rod node positions (`RodNP`). Available on every route that carries bodies or rods, OpenFAST included; the full list and units are in [Outputs](outputs.rst) | see Outputs | | `Point

F{x,y,z}`, `Point

FH` | resultant of the line-end and cable end forces on point P (each the `FairTen`/`AnchTen` end force): the anchor load of a Fixed point shared by several lines, the line load a free point balances; `FH` is its horizontal magnitude. Available on every route (static, `EI = 0`, finite-EI, multibody and OpenFAST) | N | | `TenN` | line L effective tension at node J. EI = 0 lines: the mean of the two adjacent segment tensions. Finite-EI lines: the axial component of the section force recovered from nodal equilibrium (`OutputRecovery element`: the length-weighted mean of the adjacent element-mean axial forces). End nodes report the line-end force of `FairTen`/`AnchTen` | N | -| `CurvN` | line L curvature at node J. `EI = 0` lines: geometric curvature of the circle through the node and its two neighbours (end nodes take the adjacent interior value). Cubic-Hermite lines: `BendMomN` / EI, the larger of the two sides at a section junction (`OutputRecovery element`: exact curvature of the continuous centreline, the larger one-sided element value at an interior node) | 1/m | -| `BendMomN` | line L bend moment = EI × curvature **relative to the stress-free reference shape** (≈ 0 at the reference; the finite-EI reference is straight). Finite-EI lines: recovered from nodal equilibrium, the moment the neighbouring elements carry (`OutputRecovery element`: EI × the larger one-sided exact curvature); 0 on EI = 0 lines. Under a bend stiffener: the cable's own share, the line type's EI × curvature | N·m | +| `CurvN` | line L curvature at node J. `EI = 0` lines: geometric curvature of the circle through the node and its two neighbours (end nodes take the adjacent interior value). Cubic-Hermite lines: the geometric curvature of the stretched line, (1 + ε) `BendMomN` / EI with ε the axial strain at the node, the larger of the two sides at a section junction (`OutputRecovery element`: exact curvature of the continuous centreline, the larger one-sided element value at an interior node) | 1/m | +| `BendMomN` | line L bend moment = EI × curvature / (1 + ε), the moment that balances the forces on the section, **relative to the stress-free reference shape** (≈ 0 at the reference; the finite-EI reference is straight). Finite-EI lines: recovered from nodal equilibrium, the moment the neighbouring elements carry (`OutputRecovery element`: EI × the larger one-sided exact curvature); 0 on EI = 0 lines. Under a bend stiffener: the cable's own share, the section moment × the line type's EI / EI(s) | N·m | | `StfMomN` | line L share of the section moment a bend stiffener carries at node J (the section moment less `BendMomN`; 0 outside the stiffener); only on a line with STIFFENERS | N·m | | `LNp{x,y,z}` | line L node J position component | m | | `LNv{x,y,z}` | line L node J velocity component (0 in static-only runs) | m/s | diff --git a/doc/file_formats.rst b/doc/file_formats.rst index 32cff5ab..65232a19 100644 --- a/doc/file_formats.rst +++ b/doc/file_formats.rst @@ -215,7 +215,8 @@ whole multiple of ``dtM``. The file is checked against this grid: (``time is outside the dtM/TMax grid``). Write times with enough digits to hit the grid exactly. A file may run past ``TMax``: its rows after ``TMax`` are checked (on the ``dtM`` grid, to within ``100·ε·max(1, time)``) and not used, so a long - record serves a shorter run and ``TMax = 0`` checks the static start. + record serves a shorter run and ``TMax = 0`` checks the static start. At ``TMax = 0`` + without ``dtM`` the grid is the ``t = 0`` row and later rows are not checked. * Every eligible point needs exactly one row at every grid time. A repeated point/time pair is rejected (``duplicate row for point at this time``). A missing pair is rejected once the whole file has been read (``motionFile must @@ -548,7 +549,8 @@ two columns: * - 2 - ``elevation`` - m - - Free-surface elevation η at the reference location. + - Free-surface elevation η at the reference location: the origin, or the point of the + ``WaveRefX`` and ``WaveRefY`` options, an extension of CableDyn. * Blank lines are skipped. Lines before the first data row whose first token is not a number, such as a ``time elevation`` header, are skipped. Once data has @@ -572,7 +574,10 @@ two columns: ``TMax / (2 dtWave)`` with ``none`` (600 at ``dtWave`` 0.25 s), against the 200 of a ``jonswap`` sea. Every node sums all of them for its surface elevation. Below the surface, the components too short to reach the node's depth are dropped once they cannot change its - velocity and acceleration beyond round-off (:ref:`theory-waves`). + velocity and acceleration beyond round-off (:ref:`theory-waves`). The sum costs about 4 ns + per node, component and step on one core of a current desktop: 3 ms per step for 300 nodes + and 2,400 components, the 4,200 s record at ``WvHiCOff`` 3.6, against 2.5 ms for the solve + of that line. A longer record costs proportionally more, and a larger ``dtM`` less. Example ~~~~~~~ diff --git a/doc/index.rst b/doc/index.rst index 121a98d7..33e0d2fa 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -14,7 +14,7 @@ with MoorDyn-C, MoorDyn-F, and OrcaFlex on common reference cases. .. admonition:: Release status :class: note - CableDyn ``v0.4.0`` is the current stable release. It is on PyPI with wheels that carry the + CableDyn ``v0.4.1`` is the current stable release. It is on PyPI with wheels that carry the solver (``pip install cabledyn``), runs 13-33 % faster in the release build, extrapolates the coupled loads between OpenFAST solves at a ``dtM`` above ``DT``, restarts bit for bit at any ``dtM``, and recovers node tension through touchdown on coarse meshes. @@ -53,8 +53,8 @@ Download to first result in five minutes ---------------------------------------- #. Download ``CableDyn_driver.exe``, ``SHA256SUMS.txt``, and **Source code (zip)** (for the - ``examples`` folder) from the `v0.4.0 release - `_ and verify the checksum — + ``examples`` folder) from the `v0.4.1 release + `_ and verify the checksum — :doc:`installation`. No installer and no runtime DLLs. #. Put ``CableDyn_driver.exe`` in a working folder, unzip the source archive, and copy its ``examples`` folder next to the executable. @@ -113,7 +113,7 @@ What CableDyn does - yes * - Modal analysis (``nModes``), range graphs, discrete ``ATTACHMENTS`` - yes - - ``ATTACHMENTS`` only + - range graphs and ``ATTACHMENTS`` * - Checkpoint/restart, linearisation, FAST.Farm, active tensioning - — - yes (:doc:`openfast`) diff --git a/doc/installation.rst b/doc/installation.rst index 1bc8b1e6..dde97b84 100644 --- a/doc/installation.rst +++ b/doc/installation.rst @@ -6,8 +6,8 @@ Installation There are two ways to get CableDyn: * **Windows release (recommended for users).** Two single-file executables and a Python wheel, - attached to the `v0.4.0 GitHub release - `_. No installer, no compiler, + attached to the `v0.4.1 GitHub release + `_. No installer, no compiler, and no runtime DLLs. Start here if you want a first result in five minutes. * **Build from source** (Windows, Linux, macOS). Needed for the C/Python in-process API, for development, and on every platform other than Windows x64. @@ -35,11 +35,11 @@ What is on the release page - OpenFAST v5.0.0 (maintained by NLR, the National Laboratory of the Rockies, formerly NREL) with CableDyn built in as ``CompMooring = 5``; stock MoorDyn (``CompMooring = 3``) is still available in the same binary (:doc:`openfast`). It names - itself ``OpenFAST-v5.0.0-CableDyn-0.4.0`` (``openfast.exe -v`` and the start of every run). - * - ``cabledyn-0.4.0-py3-none-any.whl`` + itself ``OpenFAST-v5.0.0-CableDyn-0.4.1`` (``openfast.exe -v`` and the start of every run). + * - ``cabledyn-0.4.1-py3-none-any.whl`` - the pure-Python package: deck editing, case generation, driver automation, result readers, fatigue and spectral post-processing (:doc:`python`) - * - ``cabledyn-0.4.0.tar.gz`` + * - ``cabledyn-0.4.1.tar.gz`` - the Python source distribution (same package as the wheel) * - ``SHA256SUMS.txt`` - SHA-256 checksums of the four files above @@ -65,7 +65,7 @@ Download and verify ~~~~~~~~~~~~~~~~~~~ 1. Create a folder, for example ``C:\CableDyn``, and download into it ``CableDyn_driver.exe``, - ``openfast.exe`` (only if you will couple with OpenFAST), ``cabledyn-0.4.0-py3-none-any.whl`` + ``openfast.exe`` (only if you will couple with OpenFAST), ``cabledyn-0.4.1-py3-none-any.whl`` (only if you will use Python), ``SHA256SUMS.txt``, and ``Source code (zip)``. 2. Verify every downloaded asset against ``SHA256SUMS.txt`` in PowerShell: @@ -85,8 +85,8 @@ Download and verify OK CableDyn_driver.exe OK openfast.exe - OK cabledyn-0.4.0-py3-none-any.whl - not downloaded cabledyn-0.4.0.tar.gz + OK cabledyn-0.4.1-py3-none-any.whl + not downloaded cabledyn-0.4.1.tar.gz ``not downloaded`` only reports an asset you chose to skip. @@ -95,7 +95,7 @@ Download and verify ``(Get-FileHash .\CableDyn_driver.exe -Algorithm SHA256).Hash`` with its line in ``SHA256SUMS.txt`` (the comparison is case-insensitive). -3. Unzip ``Source code (zip)``. It expands to a folder such as ``CableDyn-0.4.0``; copy (or move) +3. Unzip ``Source code (zip)``. It expands to a folder such as ``CableDyn-0.4.1``; copy (or move) its ``examples`` folder into ``C:\CableDyn`` so the layout is:: C:\CableDyn\ @@ -121,7 +121,7 @@ Check that it runs .. code-block:: text =================================================================== - CableDyn v0.4.0 + CableDyn v0.4.1 Geometrically exact finite-element statics and dynamics of mooring lines and dynamic power cables for floating offshore wind ------------------------------------------------------------------- @@ -177,8 +177,8 @@ the CableDyn shared library and the standalone driver, so the package runs decks release executables. On another platform pip builds from the source distribution, which needs a Fortran compiler and LAPACK. -The pure Python wheel on the release page (``cabledyn-0.4.0-py3-none-any.whl``) carries no -solver; install it with ``py -m pip install .\cabledyn-0.4.0-py3-none-any.whl``. The package +The pure Python wheel on the release page (``cabledyn-0.4.1-py3-none-any.whl``) carries no +solver; install it with ``py -m pip install .\cabledyn-0.4.1-py3-none-any.whl``. The package finds the driver in this order: #. an explicit path, ``CableDynDriver(r"C:\CableDyn\CableDyn_driver.exe")`` or @@ -273,7 +273,7 @@ The script downloads the LAPACK source from its release tag and verifies its SHA integration to OpenFAST itself (do not pre-apply it), builds both executables, rejects any non-system DLL import, runs a standalone and a coupled ``CompMooring = 5`` / ``CompMooring = 3`` smoke case with a system-only ``PATH``, and writes -``SHA256SUMS.txt`` into ``build-static-release\dist``. The v0.4.0 assets were built from +``SHA256SUMS.txt`` into ``build-static-release\dist``. The v0.4.1 assets were built from OpenFAST revision ``2895884d2be01862173c88d70f86b358d2f1a50a`` and smoke-tested against ``r-test`` revision ``dd5feaaaa500ba7283140107806300d551cff0a7``. diff --git a/doc/modeling.rst b/doc/modeling.rst index 0bf65947..32cbf87e 100644 --- a/doc/modeling.rst +++ b/doc/modeling.rst @@ -91,7 +91,10 @@ sleeve's bending stiffness, mass, buoyancy and drag to the elements it covers the hang-off direction. A pinned end carries no moment, so the stiffener then takes none; - **the mesh under the stiffener.** One stiffness per element: split the section at the stiffener tip and mesh the part under it with elements of about 0.1–0.2 m, at least ten over - the stiffener (the driver notes fewer); + the stiffener (the driver notes fewer). The node outputs and the element peaks of + ``.elements.out`` take the stiffness at their own arc, so on that mesh the curvature follows a + much finer one within about 0.2 % (``lazy_wave_bend_stiffener.dat``), and shortening the + elements further gains little; - **the material.** The sleeve is linear elastic. Polyurethane stiffness depends on temperature and strain rate; use the modulus for the design temperature and check the result at the ends of its range. diff --git a/doc/openfast.rst b/doc/openfast.rst index 9fd39544..fc7f65c7 100644 --- a/doc/openfast.rst +++ b/doc/openfast.rst @@ -207,6 +207,12 @@ the seabed (``Z_Depth = WtrDpth + MSL2SWL``): they are in the seabed, where the grid bottom are the right ones. FAST.Farm runs, which use still water, and SeaState fields without waves or current are not checked. +SeaState's cosine depth grid is coarsest at the bottom: its deepest interval is +``Z_Depth*sin(pi/(2*(NZ - 1)))``, about 22 m at 200 m depth with ``NZ = 15``, and the field falls +linearly across it to the seabed, so near the seabed it differs from a deck depth table; when that +interval exceeds 5 % of the depth (``NZ`` below 33) and a line lies in it, an initialisation note +states the interval and the ``NZ`` that would bring it under 5 %. + Run and confirm --------------- @@ -217,7 +223,7 @@ Run and confirm Before trusting a run, confirm in the console: -#. ``Running CableDyn (v0.4.0, ...)`` and ``Parsing CableDyn input file: ``; +#. ``Running CableDyn (v0.4.1, ...)`` and ``Parsing CableDyn input file: ``; #. the ``dtM`` line and the model inventory (``Created CableDyn model: ... line object(s)``); #. for every line, the converged fairlead effective tension, force vector, inclination, declination, and azimuth at the static equilibrium — printed unconditionally, whether or not @@ -268,7 +274,11 @@ channels ``AnchFz``, ``AnchFH`` and ``TDPGrd``) and units are in :doc:` * - ``.CD.static.out`` - CableDyn-owned, always written: the static-equilibrium profile of every line, one row per node (arc length, coordinates, effective tension, curvature, bend moment, declination, - inclination, azimuth) — the coupled range graph, at the state of the printed line summary + inclination, azimuth), at the state of the printed line summary + * - ``.CD.Line.range.out`` + - CableDyn-owned, for a line with the ``LINES`` ``Outputs`` flag ``r``: the range graph, the + minimum, maximum and mean at every node over the committed steps from ``RangeStart``, + written at the end of a run that completes (:doc:`outputs`) * - ``.CD.rst.dat`` - temporary CableDyn-owned copy of the deck, written in the ``MooringFile`` folder only when a run restarts from a checkpoint (see *Lifecycle features*), and removed after use @@ -380,9 +390,10 @@ without an auxiliary process: * **Checkpoint/restart** serialises the committed line, constitutive, connection, body, and rod state needed to continue the trajectory, with the SeaState fields the committed step holds and, with ``dtM`` larger than ``DT``, the loads of the solve before it (the load predictor), so a - restart continues the run bit for bit at any ``dtM``. A checkpoint is accepted only on a committed CableDyn - step, so choose ``ChkptTime`` as a whole multiple of ``dtM``; a checkpoint written between two - commits stops the restart with a message that says so. On restart the module writes a + restart continues the run bit for bit at any ``dtM``. The accumulated range graphs (``LINES`` + flag ``r``) are part of it, so a restarted run writes the range files of an uninterrupted one. A + checkpoint can fall on any glue step: ``ChkptTime`` need not be a multiple of ``dtM``, and a run + restarted between two commits goes on as the uninterrupted run. On restart the module writes a temporary deck copy, ``.CD.rst.dat``, beside the ``MooringFile``, rebuilds the model from it and removes it; files the deck names (``bathymetryFile``, a WaterKin file, Syrope tables) resolve against the ``MooringFile`` folder as in the original run. Keep restart files with the exact executable and complete input set. diff --git a/doc/options.rst b/doc/options.rst index cc59b65b..ca74bbde 100644 --- a/doc/options.rst +++ b/doc/options.rst @@ -262,7 +262,7 @@ Clock and initial condition - real, s - absent (static-only) - finite, > 0. In the standalone driver ``dtM`` and ``TMax`` are given together or not at - all + all; only ``TMax = 0``, which marches no step, needs no ``dtM`` - standalone step; OpenFAST targets ``0.1`` s when absent, rounds it to at least one whole glue ``DT``, and reports the value used - ``0.05 dtM`` @@ -272,7 +272,7 @@ Clock and initial condition - finite, ≥ 0 and an integer multiple of ``dtM``. ``TMax = 0`` runs the dynamic initialisation and writes the ``t = 0`` row: the static start of a dynamic deck, checked with every other option kept (motion records longer than ``TMax`` and a later - ``RangeStart`` included) + ``RangeStart`` included), with or without ``dtM`` - standalone only; OpenFAST uses the host ``TMax``. Do not copy the host duration into a coupled deck - ``600.0 TMax`` @@ -282,8 +282,10 @@ Clock and initial condition - finite, ≥ 0 and ≤ ``TMax`` (any value at ``TMax = 0``, where the graphs hold the ``t = 0`` row). First output time accumulated into the range graphs (``.Line.range.out``, ``LINES`` ``Outputs`` flag ``r``), so a start-up - transient is left out. Needs ``TMax`` and at least one line with the ``r`` flag - - standalone only + transient is left out. Needs at least one line with the ``r`` flag and, standalone, + ``TMax`` + - both; OpenFAST holds it against the host ``TMax``. A FAST.Farm shared mooring rejects the + ``r`` flag - ``100.0 RangeStart`` Nonlinear solver @@ -490,9 +492,11 @@ Finite-EI cable controls static-profile and range-graph columns, ``.t.out`` segment tensions). ``equilibrium`` recovers them from nodal equilibrium: the effective tension :math:`\mathbf F\cdot\mathbf t`, the moment the neighbouring elements carry, and - curvature = moment / ``EI`` (see :ref:`theory-output-recovery`). ``element`` reports the + the geometric curvature (1 + strain) × moment / ``EI`` (see + :ref:`theory-output-recovery`). ``element`` reports the length-weighted element-mean axial force :math:`EA(\lvert\mathbf r'\rvert - 1)`, the - larger one-sided exact curvature, and the axial resultant at the element midpoint as the + larger one-sided exact curvature (on an element with a bend stiffener sleeve, the recovered + moment over ``EI(s)``), and the axial resultant at the element midpoint as the segment tension, the definitions of the releases before 0.2.0. End forces, positions, ``TDP`` channels and the material-strain checks (``tensile_safety``, compression notes) are the same under both @@ -741,7 +745,7 @@ Ambient fluid and prescribed motion filename; ``SEASTATE`` selects the host field. The other MoorDyn-C modes fail closed by name. Mode rules are in :ref:`moordyn-c-wavekin` and :ref:`waterkin-file-modes` - ``3``/``7`` and file ``WaveKinMod 1``: standalone decks only; ``SEASTATE``, - ``WaveKinMod 2``, and ``CurrentMod 2``: OpenFAST only + ``WaveKinMod 2``, and ``CurrentMod 2``: OpenFAST and C API hosts with a host fluid field - ``waterkin.dat WaterKin`` * - ``dtWave`` - positive number [s] @@ -759,6 +763,16 @@ Ambient fluid and prescribed motion component at or below the cut-off is refused - standalone - ``6.2832 WvHiCOff`` + * - ``WaveRefX``, ``WaveRefY`` + - number [m], at most 10\ :sup:`6` in magnitude + - ``0`` + - the horizontal point that the phases of an elevation record or a component file + (WaterKin ``WaveKinMod 1``, ``WaveKin 3`` and ``7``) refer to: the record was measured + there, and each phase uses the position relative to it (:ref:`theory-waves`). An + extension of CableDyn, which neither MoorDyn-C nor MoorDyn-F reads. A deck with any other + sea, or none, is refused by name + - standalone + - ``250.0 WaveRefX`` * - ``Currents`` - ``0``, ``1``, ``2`` or ``5`` - ``0`` @@ -841,11 +855,11 @@ cables, Rigid6 bodies or rods. Standalone versus OpenFAST checklist ------------------------------------ -For a standalone static calculation, omit both ``dtM`` and ``TMax`` or set a deliberate pair with -``TMax=0`` when exercising the dynamic initialisation route. For standalone dynamics, provide +For a standalone static calculation, omit both ``dtM`` and ``TMax`` or set ``TMax=0`` (``dtM`` is +then optional) when exercising the dynamic initialisation route. For standalone dynamics, provide both and ensure ``TMax/dtM`` is integral. ``current``, ``waves``, ``motionFile``, a positive ``frictionMu``, rods, bodies, ``Connect``/``Free`` points, and finite-EI sections (outside a mixed -deck) all need the ``dtM``/``TMax`` pair. File paths are resolved relative to the deck and may use +deck) all need the ``dtM``/``TMax`` pair or ``TMax=0``. File paths are resolved relative to the deck and may use ordinary nested paths longer than the 64-character deck-identifier limit. For ``CompMooring = 5``, OpenFAST owns gravity, density, flat depth, platform motion, run duration, diff --git a/doc/outputs.rst b/doc/outputs.rst index 3229e994..0cb33a4d 100644 --- a/doc/outputs.rst +++ b/doc/outputs.rst @@ -289,7 +289,9 @@ B)``, header row, units row, data (12 columns): - unstretched arc length of the curvature maximum * - ``PeakCurvature`` - ``(1/m)`` - - maximum curvature over the element + - maximum curvature over the element; under a bend stiffener the curvature of the section, + the moment over the stiffness ``EI(s)`` along the element, not of the interpolation + (:ref:`theory-bend-stiffeners`) * - ``BendMomentAtPeak`` - ``(N.m)`` - ``EI × PeakCurvature``, with the line type's ``EI`` (the cable's share under a bend @@ -384,6 +386,20 @@ every node: the minimum and maximum equal those of the channel time histories exactly, and the mean is their arithmetic mean. The file is written when the run completes; a run that stops early leaves none. +A single-turbine OpenFAST run writes the same file as ``.CD.Line.range.out``. Its +samples are the rows of ``.CD.out``: the state at ``t = 0`` and every committed ``dtM`` +step, once each whatever ``DT``, ``DT_Out`` and the correction iterations are; a step OpenFAST +corrects counts once, with its corrected state. The accumulators are double precision on every +OpenFAST build and travel in the checkpoint, so a restart continues them. The file is written at +the end of a run that completes, whenever the host ends it: at ``TMax``, at a steady state found +by ``CalcSteady``, or at FAST.Farm's last whole ``DT_Low``, and holds the samples up to there. +The run removes a file left by an earlier run as soon as its deck is parsed, and a run that stops on +an error after that, in any module, leaves none. A deck that does not parse, or a module that stops +before CableDyn starts, leaves every output of the earlier run as it was. A turbine's own CableDyn +instance in FAST.Farm (``CompMooring = 5`` in its ``.fst``) writes +``.T.CD.Line.range.out``. A FAST.Farm shared mooring and the C API write no range +file and reject the flag by name. + Layout: title line, header row, **units row**, then one row per node, End A first. The title reads ``CableDyn range graph (line ; samples from t = s to t = s; public node order End A -> End B)``. @@ -463,6 +479,8 @@ column, for every line of the file (``0`` on a line without torsion): mode (``PolarShare`` above 0.5) is scaled to a unit largest ``dTwist`` instead, and its translations are then in metres per radian of that twist. +:func:`cabledyn.read_output` reads both tables into a :class:`cabledyn.NaturalModes`. + Per-rod files (``RODS`` ``Outputs`` flag) ----------------------------------------- @@ -516,7 +534,8 @@ chord from End A to End B, at the deck positions, heads within 45° of +X, and i every other line. The cut of a line's range then lies at least 45° from its heading: a line swaying about its heading reads continuously, where a line along +X would otherwise jump between 0 and 360 as it moves a fraction of a degree to either side. The range is fixed for -the run, so the motion of the line cannot switch it. A round-off component across the cut +the run, so the motion of the line cannot switch it, and the same on every route: the standalone +driver, the C API and Python, and OpenFAST. A round-off component across the cut reads on its included side (0, or 180 in the signed range), and one across +X reads exactly 0. A line a host builds through the C interface without a deck reports [0, 360). @@ -683,11 +702,15 @@ Line-node channels * - ``CurvN`` - curvature at node ``J``. ``EI = 0`` lines: discrete curvature of the circle through the node and its two neighbours (end nodes take the adjacent interior value). - Cubic-Hermite lines: ``BendMomN`` / ``EI``, the larger of the two sides at a - section junction, and the exact geometric curvature where ``EI`` is below - ``max(|w| L0^2)/12`` times 1 m (:ref:`theory-output-recovery`) (``OutputRecovery + Cubic-Hermite lines: the geometric curvature of the stretched line, the reciprocal of the + bend radius, ``(1 + ε) BendMomN`` / ``EI`` with ``ε`` the axial strain at the node, + the larger of the two sides at a section junction (under a bend stiffener ``(1 + ε)`` + times the section moment over the stiffness ``EI(s)`` at the node, + :ref:`theory-bend-stiffeners`), and the exact geometric curvature where + ``EI`` is below ``max(|w| L0^2)/12`` times 1 m (:ref:`theory-output-recovery`) (``OutputRecovery element``: exact curvature of the continuous - centreline, the larger of the two one-sided element values at an interior node). The + centreline, the larger of the two one-sided element values at an interior node; the + recovered moment over ``EI(s)`` on an element with a bend stiffener sleeve). The library routines ``CD_HermiteCable_Dyn_Curvature`` and ``CD_HFMF_Curvature`` return the same value; ``CD_HermiteCable_Dyn_Curvature_Exact`` and ``CD_HFMF_Curvature_Exact`` the exact geometric traces @@ -703,14 +726,15 @@ Line-node channels returns them for every node - 1/m * - ``BendMomN`` - - bend moment ``EI × κ`` measured relative to the stress-free reference shape (the - cubic-Hermite reference is straight). Finite-EI lines: the component normal to the tangent - of the moment recovered from nodal equilibrium, the mean of the two neighbouring elements + - bend moment ``EI × κ / (1 + ε)``, the moment that balances the forces on the section (it + is ``EI × κ`` to within the axial strain ``ε``), measured relative to the stress-free + reference shape (the cubic-Hermite reference is straight). Finite-EI lines: the moment + recovered from nodal equilibrium, the mean of the two neighbouring elements (one-sided at a line end, where it is the end connection moment); ``OutputRecovery element``: ``EI`` times the larger one-sided exact curvature. 0 on ``EI = 0`` lines. Under a bend stiffener (``STIFFENERS``) it is the cable's own share, - the line type's ``EI`` times the curvature; the stiffener's share is ``StfMomN`` - and the two add up to the section moment + the section moment times the line type's ``EI`` over the stiffness ``EI(s)`` at the node; + the stiffener's share is ``StfMomN`` and the two add up to the section moment - N·m * - ``StfMomN`` - the share of the section moment a bend stiffener carries at node ``J``, the section @@ -1012,14 +1036,15 @@ output root: The first data row is the static initialisation at ``t = 0``; every further row is written after each committed CableDyn step, at every ``dtM`` boundary. It is independent of OpenFAST's ``DT_Out`` and never repeats held values between -CableDyn steps. If OpenFAST corrects a step (predictor–corrector iterations), the provisional row -is replaced rather than duplicated; the file is flushed after every row, so it is readable during -a run and intact after a crash. +CableDyn steps. If OpenFAST retries a step, at any ``dtM``, the provisional row is replaced rather +than duplicated, and a restore of an operating point (``CalcSteady``) keeps the rows of the run; +the file is flushed after every row, so it is readable during a run and intact after a crash. Select CableDyn channels in the ``OUTPUTS`` section of the CableDyn ``MooringFile``; they are not members of the top-level ``.fst`` ``OutList``. The ``TDP`` channels are evaluated from the -coupled state against the touchdown reference of the converged initialisation; the range files of -the ``LINES`` flag ``r`` are a standalone output, and a coupled deck with that flag is rejected. For +coupled state against the touchdown reference of the converged initialisation. The ``LINES`` flag +``r`` adds ``.CD.Line.range.out`` (see above) to a single-turbine run; a FAST.Farm +shared-mooring deck with that flag is rejected. For a single-turbine run the module also prints every selected channel's static value to the screen after initialisation. A deck with no ``OUTPUTS`` section is valid but publishes no channels. diff --git a/doc/python.rst b/doc/python.rst index a5ec813c..378db20e 100644 --- a/doc/python.rst +++ b/doc/python.rst @@ -74,8 +74,9 @@ Pass the executable explicitly for the most reproducible setup: fairlead_tension = history.column("FairTen1") # N ``result`` retains the executable, absolute deck and output paths, captured ``stdout`` and -``stderr``, return code, the optional static profile and finite-EI element table -(``elements_output``), and all requested per-line and per-rod outputs. A result is returned only +``stderr``, return code, the optional static profile, finite-EI element table +(``elements_output``) and modal table (``modes_output``, read with ``result.read_modes()``), and +all requested per-line and per-rod outputs. A result is returned only for a fully converged analysis: the driver exits non-zero whenever a nonlinear step fails. Arrays returned by the parser are read-only so post-processing cannot accidentally mutate the recorded result. @@ -135,7 +136,8 @@ archive the native diagnostic. Typed result objects -------------------- -:func:`cabledyn.read_output` accepts main histories, static profiles, and per-line tables. It +:func:`cabledyn.read_output` accepts main histories, static profiles, per-line tables, and modal +tables. It recognises both current main output (title + channel row) and tables with a units row in the style of OpenFAST (maintained by NLR, the National Laboratory of the Rockies, formerly NREL): @@ -161,6 +163,41 @@ and a generic :class:`cabledyn.OutputTable` for static per-line node or segment strictly increasing; line and node identifiers must be positive integers; and arc length must be monotonic within each line. +Natural modes +~~~~~~~~~~~~~ + +A deck that sets ``nModes`` writes ``.modes.out``, which holds two tables (see +:doc:`outputs`). :func:`cabledyn.read_output` reads both into one +:class:`cabledyn.NaturalModes`: the object is the frequency table, and its ``shapes`` attribute is +the mode-shape table, a :class:`cabledyn.ModeShapes`. Each table is an +:class:`cabledyn.OutputTable`, so ``column``, ``unit`` and ``to_dataframe`` work on either; an +export of the object writes the frequency table only. + +.. code-block:: python + + from cabledyn import read_output + + modes = read_output("results/baseline.modes.out") # or result.read_modes() + + print(modes.line_ids) + print(modes.periods(1)) # s, by mode + print(modes.frequencies(1)) # Hz, by mode + + xyz = modes.shapes.positions(1) # static node positions, (n_nodes, 3), m + shape = modes.shapes.displacements(1, 2) # mode 2, (n_nodes, 3), largest node 1 + line_2 = modes.line(2) # both tables of one line + +A file with a line restrained in torsion has the columns ``PolarShare`` and ``dTwist`` for every +line, and ``has_twist`` is true. ``modes.polar_shares(1)`` and ``modes.shapes.twist(1, mode)`` +return them, and ``modes.torsional_modes(1)`` the modes above a polar share of 0.5, which are +scaled to a unit largest twist instead of a unit largest displacement. On a file without the +columns these return zeros and no modes, as on a line without torsion. + +A negative eigenvalue has a negative ``Frequency`` and ``Omega`` and a ``Period`` of 0. A line +without free nodes has no modes and no rows, so it is absent from ``line_ids``. The reader refuses +a file without a mode-shape table, and one whose two tables do not hold the same modes of the same +lines. + Statistics, range graphs, and geometry ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -762,16 +799,39 @@ environment variable ``CABLEDYN_BLAS_THREADS`` changes that (:doc:`capi`). Array ordering is defined in :ref:`capi:Calling conventions`; frames, fluid fields, and lifecycle rules are defined in :doc:`coupling_boundary`. Use this API only when the Python process owns time integration; use ``CompMooring = 5`` for OpenFAST (:doc:`openfast`). The Python -class covers deck initialisation and the coupled step/output cycle; raw-array initialisation -(``CableDyn_InitLine`` and -``CableDyn_InitLines``) and output Jacobians (``CableDyn_CalcOutputDerivatives``) are available -through the C API (:doc:`capi`). +class covers deck initialisation, raw-array lines, the coupled step/output cycle and the load +Jacobians (see `Raw-array lines and load Jacobians`_). A deck with finite-EI lines, ``BODIES`` or ``RODS`` runs on the coupled aggregate and needs ``dtM``; other decks run on the point route. Decks with deck waves, a ``motionFile`` or vessel -motion are rejected in-process (they run in the standalone driver): time-varying fluid -kinematics enter through :meth:`~cabledyn.CableDyn.update_point_fluid_fields`, and motion through -the coupled kinematics of every step. +motion are rejected in-process (they run in the standalone driver): motion enters through the +coupled kinematics of every step. A steady deck current is marched in-process, in the static +start and in every step. Other moving water comes from the host, at the nodes of every line: + +.. code-block:: python + + model = cabledyn.CableDyn() + model.set_host_fluid() # before the initialization + model.init_deck_rotating(deck) + + def field(t): + xyz = model.fluid_node_positions() # (3, n), the committed positions + velocity, acceleration, elevation = my_sea(xyz, t) + model.update_fluid_fields(velocity, acceleration, elevation) + + field(0.0) # the start acceleration + for i in range(1, steps + 1): + t = i * dt + field(t) # held over the step + model.step(dt, q(t), v(t), a(t)) + +``examples/python/step_in_moving_water.py`` is a complete script of this loop. + +``velocity`` is the whole ambient velocity, current and waves together. The field of a step to +``t + dt`` is evaluated at ``t + dt`` at the step-start positions and held over the step, as in +the standalone march and in OpenFAST; :meth:`~cabledyn.CableDyn.save_state` carries it. A steady +deck current then sets the static start only; the rules are in :doc:`capi`. On a deck of the +point route, :meth:`~cabledyn.CableDyn.update_point_fluid_fields` sets the field at the points. A host that moves the ``Coupled`` points from its own body solver starts the model at its positions and rewinds it between the passes of an iterated time step. The save is keyed on the @@ -806,6 +866,63 @@ start time of the pass, so a pass retried with a smaller ``dt`` restores as well self.model.step(dt, self.q, self.v, self.a) return self.model.calc_output()[self.host].reshape(3, -1, order="F") +Raw-array lines and load Jacobians +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:meth:`~cabledyn.CableDyn.init_line` and :meth:`~cabledyn.CableDyn.init_lines` build a model from +lines the caller has meshed, without a deck. These lines are **structural only**: two-node +``EI = 0`` axial elements with a consistent mass matrix, without gravity, buoyancy, seabed, +hydrodynamics or structural damping, and without a static solve, so the state starts at ``q0``, +``v0`` exactly. Use a deck for anything else. As in the C API, ``elem_conn``, ``fixed_dofs`` and +``coupled_map`` are **1-based**, and the order of ``fixed_dofs`` is the order of the coupled +vector: + +.. code-block:: python + + import numpy as np + from cabledyn import CableDyn + + n_elem = 10 + nodes = np.linspace(0.0, 1.0, n_elem + 1)[:, None] * [100.5, 0.0, 0.0] + first = np.arange(1, n_elem + 1) + + with CableDyn() as model: + model.init_line( + q0=nodes.reshape(-1), + v0=np.zeros(nodes.size), + elem_conn=np.column_stack([first, first + 1]), # 1-based node pairs + l0=np.full(n_elem, 10.0), # m + ea=np.full(n_elem, 1.0e7), # N + rho_a=np.full(n_elem, 50.0), # kg/m + fixed_dofs=[1, 2, 3, 31, 32, 33], # both end nodes, 1-based + tension_only=True, + rho_inf=0.8, + ) + q, v, a = model.get_coupled_motion() + loads, dload_dq, dload_dv, dload_da, added_mass = model.calc_output_derivatives(q, v, a) + +:meth:`~cabledyn.CableDyn.init_lines` takes the counts of each line, then the same arrays +concatenated in line order, with connectivity and ``fixed_dofs`` local to each line. Its optional +``coupled_map`` connects lines at shared points; see ``CableDyn_InitLines`` in :doc:`capi`. +Shapes, non-finite values and non-integer indices raise ``ValueError`` or ``TypeError`` before the +library is called, which changes nothing. The library judges the values (an index out of range, a +repeated fixed DOF, a non-positive length) and raises :class:`cabledyn.CableDynError`; the handle +must then be initialised again, unless it held a model on the coupled aggregate, which stays live. + +:meth:`~cabledyn.CableDyn.calc_output_derivatives` returns the loads at an operating point and +their analytic Jacobians, ``J[i, j]`` being the derivative of load ``i`` with respect to coupled +DOF ``j``, and works on raw lines and on decks of the point route. A model on the coupled aggregate +has no load derivatives and raises :class:`cabledyn.CableDynError`. Each line's interior DOFs are +condensed out: interior positions and velocities are held and interior accelerations eliminated +through the mass matrix, so the blocks are those of ``update_states`` followed by ``calc_output``, +the instantaneous response of the end loads to the end motion. ``added_mass`` is ``-dload_da``. + +.. warning:: + + ``calc_output_derivatives`` **sets the model state**: on return the model is at the operating + point, as after ``update_states``, and the previous state is not restored. Call + ``update_states`` with the previous kinematics to return to them. + Objects and output channels ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -822,6 +939,7 @@ state at the time of the call through the same evaluator as the deck ``OUTPUTS`` line = model.line(1) xyz = line.node_positions() # (n_nodes, 3), End A first seg = line.segment_tensions() # (n_segments,) + axes = line.section_curvature() # (n_nodes, 2): CurvX, CurvY of a finite-EI line kappa = line.curvature() # (n_nodes,); bend_moment() on finite-EI lines buf = np.empty_like(xyz) for _ in range(100): diff --git a/doc/quickstart.rst b/doc/quickstart.rst index d9d43faa..c12bf0cb 100644 --- a/doc/quickstart.rst +++ b/doc/quickstart.rst @@ -18,7 +18,7 @@ and the ``examples`` folder (from the release's **Source code (zip)**) side by s .. code-block:: powershell Set-Location C:\CableDyn - .\CableDyn_driver.exe --version # prints the v0.4.0 banner, exit code 0 + .\CableDyn_driver.exe --version # prints the v0.4.1 banner, exit code 0 2. Solve a mooring line ----------------------- @@ -38,7 +38,7 @@ The run takes well under a second: .. code-block:: text =================================================================== - CableDyn v0.4.0 + CableDyn v0.4.1 Geometrically exact finite-element statics and dynamics of mooring lines and dynamic power cables for floating offshore wind ------------------------------------------------------------------- diff --git a/doc/theory.rst b/doc/theory.rst index b1be3d2e..7b45a636 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -178,11 +178,38 @@ part of the element a segment covers. With these one-per-element values the tape by the mesh, as in a segmented line model; the driver notes a stiffener that spans fewer than ten elements. -The recovered section moment :math:`M` (:ref:`theory-output-recovery`) is shared in proportion -to the stiffnesses, since cable and sleeve take the same curvature :math:`\kappa = M/EI_e`: the -cable carries :math:`EI_c\,\kappa`, reported as ``BendMom``, and the sleeve -:math:`(EI_e - EI_c)\,\kappa`, reported as ``StfMom``. At a node between a covered and a bare -element the side of the larger curvature, the bare one, gives the values. The deflection angle +The node outputs do not use :math:`EI_e`. It stands for the whole element, so under a taper it +steps at every node although the section does not, and the softer side would overstate the +curvature by about :math:`h\lvert EI'\rvert/(2 EI)`, first order in the element length +:math:`h`. A node takes :math:`EI(s)` at its own arc instead, from the stiffener rows, one limit +from either side. The section moment :math:`M` (:ref:`theory-output-recovery`) is continuous and +second-order accurate, so the curvature :math:`\kappa = (1+\varepsilon) M/EI(s)` is too. Cable +and sleeve take that curvature and share the moment in proportion to the stiffnesses: the cable +carries :math:`EI_c\,M/EI(s)`, reported as ``BendMom``, and the sleeve the rest, +:math:`(EI(s) - EI_c)\,M/EI(s)`, reported as ``StfMom``. + +The two limits differ only at a real step of the section: at the bare tip, between stiffener +rows whose end diameter, bore or modulus do not match, and between line types of different +:math:`EI`. There the side of the larger curvature gives the values, the bare one at the tip; +no threshold on the stiffness ratio is involved. A node past a tip inside an element is bare. + +An element's interpolation bends under :math:`EI_e`, so on an element with a sleeve its curvature +is the curvature of no section: :math:`EI_e` times it is the linear fit of the moment over the +element, so at an element's end it is off by the same first-order step against :math:`EI(s)`, +high where the curvature falls toward the tip and low where it rises, and :math:`EI_e` times it +is low by :math:`h^2 M''/12` against :math:`M`. Two outputs that read the interpolation elsewhere read +the section here. The peak of such an element in ``.elements.out`` is the largest +:math:`(1+\varepsilon)\lvert M(u)\rvert/EI(s(u))` along it, with :math:`M(u)` the quadratic +through the recovered moments of the end nodes that keeps the mean of the fit; a row end inside the element, +or the tip, is a step of :math:`EI(s)` that the search keeps. Below the stiffness that resolves +the recovered moment (:ref:`theory-output-recovery`) both the nodes and the peak fall back on +the interpolation's curvature times :math:`EI_e/EI(s)`. ``OutputRecovery element`` reports +the recovered moment over :math:`EI(s)` on such an element, the same value as the equilibrium +recovery, and the exact trace on every other element. The geometric traces themselves stay +available as ``CD_HermiteCable_Dyn_Curvature_Exact``, and the mesh diagnostics +(:math:`h\kappa`) read them. + +The deflection angle ``FairDefl``/``AnchDefl`` is :math:`\arccos(\hat{\mathbf F}\cdot\hat{\mathbf d})` between the end force :math:`\mathbf F` and the direction :math:`\mathbf d` the end connection holds (into the line), the angle through which the stiffener turns the cable. @@ -190,12 +217,17 @@ the line), the angle through which the stiffener turns the cable. The planar check ``driver_bend_stiffener`` compares a hang-off clamped through a 5 m stiffener (a cylindrical root and a cone, 16 times the cable's stiffness at the root) with an independent collocation solution of the extensible elastica -:math:`\theta' = M/EI(s)`, :math:`M' = -(1 + \varepsilon)(\cos\theta\,n_z - \sin\theta\,n_x)`, +:math:`\theta' = (1+\varepsilon)^2 M/EI(s)`, +:math:`M' = -(1 + \varepsilon)(\cos\theta\,n_z - \sin\theta\,n_x)`, :math:`\mathbf n' = (0, w(s))`: on 0.05 m elements the cable's moment over the stiffener and -3 m beyond follows the elastica within 0.8 % of its root value, cable and sleeve moments add up -to the section moment within 0.2 % at the root, the deflection angle agrees within 0.003°, and -the node positions within 1 mm. On the same hang-off, the curvature along the stiffener follows -OrcaFlex 11.6d with its stiffener given as a line attachment within 0.8 % of the root curvature. +3 m beyond follows the elastica within 0.01 % of its root value, cable and sleeve moments add up +to the section moment within 0.01 % at the root, the deflection angle agrees within 0.0001°, and +the node positions within 0.03 mm. Node by node, the curvature and the cable's moment follow the +elastica within 0.27 % of their local value on 0.1 m elements and 0.06 % on 0.05 m, 0.99 % on +0.2 m, under either output recovery, and the peaks of ``.elements.out`` under the stiffener +as closely. The local value is floored at 0.1 % of the root curvature, as the curvature changes +sign near the tip. On the same hang-off, the curvature along the stiffener follows OrcaFlex +11.6d with its stiffener given as a line attachment within 0.5 % of the root curvature. .. _theory-output-recovery: @@ -213,11 +245,25 @@ and the conjugate of the material tangent are \mathbf{m} = \frac{\partial W}{\partial \mathbf{b}} = EI\,\frac{(\mathbf{a}\times\mathbf{b})\times\mathbf{a}}{\lvert\mathbf{a}\rvert^{6}} . +The energy is unchanged by a rotation of the whole line, so +:math:`\mathbf{r}\times\mathbf{F} + \mathbf{a}\times\mathbf{m}` changes along the line only by +the moment of the loads: the section moment is :math:`\mathbf{a}\times\mathbf{m}`, not +:math:`\mathbf{m}`, which is the conjugate of the tangent. With :math:`\kappa` the curvature of +the stretched line, the energy :math:`\tfrac12 EI\kappa^2` per unstretched metre gives + +.. math:: + + \lvert\mathbf{m}_\perp\rvert = \frac{EI\,\kappa}{(1+\varepsilon)^2}, + \qquad + M = \lvert\mathbf{a}\times\mathbf{m}\rvert = \frac{EI\,\kappa}{1+\varepsilon}, + \qquad + \kappa = (1+\varepsilon)\,\frac{M}{EI} . + On a stiff-``EA`` cable :math:`\lvert\mathbf{a}\rvert = 1 + \varepsilon` with :math:`\varepsilon \ll 1`, :math:`\mathbf{a}\cdot\mathbf{b} = O(\varepsilon)` and :math:`\mathbf{b} = \kappa\,\mathbf{n}`, so that :math:`\partial W/\partial\mathbf{a} = (EA\,\varepsilon - 2EI\kappa^2)\,\mathbf{t}` plus normal -terms, :math:`\mathbf{m} = EI\,\kappa\,\mathbf{n}`, and +terms, :math:`\mathbf{m} \approx EI\,\kappa\,\mathbf{n}`, and :math:`(\partial W/\partial\mathbf{b})'\cdot\mathbf{t} = EI\,\mathbf{b}'\cdot\mathbf{t} = -EI\kappa^2`. The axial internal force of the rod, the effective tension that balances the loads, and the bend moment are therefore @@ -226,7 +272,18 @@ bend moment are therefore N = \mathbf{F}\cdot\mathbf{t} = EA\,\varepsilon - EI\,\kappa^2, \qquad - M = \lvert \mathbf{m} - (\mathbf{m}\cdot\mathbf{t})\,\mathbf{t} \rvert = EI\,\kappa . + M = EI\,\kappa \quad\text{to } O(\varepsilon) . + +The curvature output is the geometric one, the reciprocal of the bend radius of the stretched +line: what the bend radius limit and the bending strain :math:`y\kappa` of a section point read. +``BendMom`` is :math:`M`, the moment that balances the forces, and at a line end the moment of +the end connection, whose potential depends on the direction of the tangent only. The check +``driver_curvature_stretch`` holds both against the elastica of this energy on a cable strained +up to 1.2 %: within 0.006 % and 0.001 % of the local value, against the 2.3 % and 1.2 % that +reading :math:`\lvert\mathbf{m}_\perp\rvert` as the moment gives. The stretch at a node is +:math:`\lvert\mathbf{r}'\rvert`, the length of its tangent, the one the discrete moment balance +holds with exactly; on a coarse stiff-``EA`` line it carries the pointwise oscillation above, +of order :math:`10^{-4}` of the curvature. The bending energy measures the curvature per deformed length, and its dependence on the stretch puts :math:`-EI\kappa^2` into the axial force: :math:`EA(\lvert\mathbf{r}'\rvert - 1)` @@ -272,12 +329,14 @@ it, and its tangent rows are :math:`\mathbf{m}` there. An interior node reports N_j = \mathbf{t}_j\cdot\tfrac{1}{2}\left(\mathbf{F}_j^- + \mathbf{F}_j^+\right), \qquad - M_j = \lvert \mathbf{m}_j - (\mathbf{m}_j\cdot\mathbf{t}_j)\,\mathbf{t}_j \rvert, + M_j = \lvert\mathbf{r}'_j\rvert\, + \lvert \mathbf{m}_j - (\mathbf{m}_j\cdot\mathbf{t}_j)\,\mathbf{t}_j \rvert, \qquad - \kappa_j = M_j / EI , + \kappa_j = \lvert\mathbf{r}'_j\rvert\, M_j / EI , with :math:`\mathbf{t}_j` the unit nodal tangent, :math:`\mathbf{m}_j` the mean of the two -one-sided values, and at a section junction the larger of :math:`M_j/EI` on the two sides. The +one-sided values, and at a section junction the larger of :math:`\kappa_j` on the two sides (under +a bend stiffener :math:`EI` is the stiffness at the node, :ref:`theory-bend-stiffeners`). The recovered moment carries an error that does not depend on :math:`EI`, of the order of the largest consistent-load moment :math:`w L_0^2/12` of an element (a section junction leaves a jump of that order between the two one-sided moments), so over a soft section dividing by :math:`EI` amplifies @@ -325,7 +384,8 @@ at most 0.45 N of 9.4 kN and moves the bend moment away from :math:`EI\kappa`. ``OPTIONS`` ``OutputRecovery element`` restores the element definitions: the length-weighted mean of the element-mean axial forces :math:`EA(\lvert\mathbf{r}'\rvert - 1)` at an interior -node, the larger one-sided exact curvature, and the midpoint axial resultant as the segment +node, the larger one-sided exact curvature (the section's on an element with a bend stiffener +sleeve, :ref:`theory-bend-stiffeners`), and the midpoint axial resultant as the segment tension. The material-strain checks (``tensile_safety``, the static compression notes) judge :math:`EA\varepsilon` under either setting. @@ -1112,8 +1172,31 @@ commensurate, the record does not repeat (an equally spaced comb would repeat af its component table (amplitude, frequency, phase), for example a WaterKin ``WaveKinMod`` 1 elevation history, which is reduced once to its Fourier components on the run time base. -Every point sums every component of the elevation. The velocity, acceleration and dynamic -pressure sums run in order of increasing :math:`k_i`. For :math:`-h \le z' \le 0` both depth +The phase of a component of an elevation history or a ``WaveKin 7`` table is + +.. math:: + + \theta_i = k_i\big[(x - x_r)\cos\beta_i + (y - y_r)\sin\beta_i\big] - \omega_i t + \phi_i, + +with :math:`(x_r, y_r)` the ``WaveRefX``, ``WaveRefY`` point where the record was measured, the +origin by default. The sea of a record referred to a point is therefore the same sea with the +deck translated, and a vessel RAO takes the vessel's position relative to that point. A history +has one direction :math:`\beta_i = \beta`. The rows of a ``WaveKin 7`` table may each have +their own: a short-crested sea, whose elevation and kinematics are the sums of those of its +directions, stretched by the one total elevation. + +The mean :math:`\bar\eta` of an elevation history, or the ``omega = 0`` row of a ``WaveKin 7`` +table, is a steady level. A WaterKin history raises the still-water level by it: the wave numbers +are those of the depth :math:`h + \bar\eta`, and the stretching maps the water column under the +raised level. A MoorDyn-C file (``WaveKin 3`` or ``7``) adds it to the elevation alone, as +MoorDyn-C does: the wave numbers stay those of :math:`h`, and the stretching maps +:math:`[-h,\ \bar\eta + \eta]` onto :math:`[-h,\ 0]`. + +Every point sums every component of the elevation. The sums run in interleaved partial sums +added up in a fixed order, so a result does not depend on the vector width or the thread +count, and each point is a function of its position and time alone: nothing carries from one +step to the next. The velocity, acceleration and dynamic pressure sums run in order of +increasing :math:`k_i`. For :math:`-h \le z' \le 0` both depth ratios :math:`\cosh k(z' + h)/\sinh kh` and :math:`\sinh k(z' + h)/\sinh kh` fall as :math:`k` grows, so the cosh ratio of the last summed component times the remaining :math:`\sum_{j \ge i} a_j\omega_j`, :math:`\sum a_j\omega_j^2` and :math:`\sum a_j\omega_j^2/k_j` diff --git a/doc/troubleshooting.rst b/doc/troubleshooting.rst index 542ad635..78388c12 100644 --- a/doc/troubleshooting.rst +++ b/doc/troubleshooting.rst @@ -179,14 +179,15 @@ Clock, motion, and environment * - Message - Cause and fix * - ``dynamic run requires both dtM and TMax options`` - - the standalone driver needs both or neither. Add the missing one. + - the standalone driver needs both or neither. Add the missing one; ``TMax = 0``, a check of + the static start, needs no ``dtM``. * - ``OPTIONS TMax must be an integer multiple of dtM`` - choose ``TMax = n·dtM`` exactly (for example ``dtM = 0.05``, ``TMax = 600``). * - ``standalone OPTION current requires dtM and TMax`` (likewise ``waves``, ``motionFile``, ``frictionMu``, ``RODS``, ``Rigid6 BODY``, ``Point3 BODY``, ``Connect``/``Free`` points, ``finite-EI section (EI>0)``) - - these features act only in a dynamic march. Add ``dtM``/``TMax``, or remove the feature for - a static calculation. A mixed ``EI = 0`` + finite-EI deck may run static without them. A + - these features act only in a dynamic march. Add ``dtM``/``TMax``, ``TMax = 0`` to check the + static start, or remove the feature for a static calculation. A mixed ``EI = 0`` + finite-EI deck may run static without them. A coupled (caller-driven) deck takes its clock from the host, so ``current`` and ``frictionMu`` need no ``dtM`` there. * - ``OPTION waves requires WtrDpth`` @@ -756,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/doc/tutorial_openfast.rst b/doc/tutorial_openfast.rst index b0a0ea9f..684bd368 100644 --- a/doc/tutorial_openfast.rst +++ b/doc/tutorial_openfast.rst @@ -168,7 +168,7 @@ The CableDyn part of the console (other modules' lines trimmed): .. code-block:: text - Running CableDyn (v0.4.0, 2026-11-04). + Running CableDyn (v0.4.1, 2026-11-08). CableDyn: geometrically exact finite-element statics and dynamics of mooring lines and dynamic power cables. ... CableDyn time step dtM = 2.50000E-02 s (1 x glue DT; deck dtM) @@ -232,7 +232,7 @@ Check, in order: CableDyn step (every ``dtM``), with a units row * - ``IEA-15-UMaine_CompMooring5_CableDyn.CD.static.out`` - the initial nodal equilibrium of every line (arc length, coordinates, tension, curvature, - bend moment, declination, inclination, azimuth) — the coupled range graph + bend moment, declination, inclination, azimuth) * - ``*.ED.sum``, ``*.HD.sum`` - the usual OpenFAST module summaries diff --git a/examples/cabledyn_options_reference.dat b/examples/cabledyn_options_reference.dat index 85a57e0a..b9fc3128 100644 --- a/examples/cabledyn_options_reference.dat +++ b/examples/cabledyn_options_reference.dat @@ -99,6 +99,8 @@ none waves - Wave model and parameters (m, s, deg) {none; airy; -- 0 Currents - MoorDyn-C current mode {0=none; 1=current_profile.txt} [default: 0] -- 0.25 dtWave - MoorDyn-C WaveKin 3 resampling step (s) [default: 0.25] -- 3.14159 WvHiCOff - High cut-off of an elevation record, WaveKinMod 1 or WaveKin 3 (rad/s) {value; none} [default: 3.14159] +-- 0.0 WaveRefX - x of the point an elevation record or component file refers to (m) [default: 0] +-- 0.0 WaveRefY - y of the point an elevation record or component file refers to (m) [default: 0] -- Keyword aliases (equivalent spellings, case-insensitive): -- g = gravity; rhoW = WtrDnsty = water_density; WtrDpth = water_depth; kBot = kb; cBot = cb -- bathymetryFile = bathymetry_file = seafloorFile = seafloor_file; frictionMu = mu = frictionCoefficient diff --git a/examples/openfast/README.md b/examples/openfast/README.md index 5054654d..7aeb4033 100644 --- a/examples/openfast/README.md +++ b/examples/openfast/README.md @@ -70,7 +70,7 @@ Grid sizing is described in the manual's OpenFAST page (SeaState grid). .\openfast.exe IEA-15-UMaine_CompMooring3_MoorDyn.fst # MoorDyn ``` -The CableDyn run log identifies the module with `Running CableDyn (v0.4.0, 2026-11-04)` and +The CableDyn run log identifies the module with `Running CableDyn (v0.4.1, 2026-11-08)` and prints the converged fairlead state of each line, as the standalone driver does: ```text diff --git a/examples/python/step_in_moving_water.py b/examples/python/step_in_moving_water.py new file mode 100644 index 00000000..11423a1f --- /dev/null +++ b/examples/python/step_in_moving_water.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""Step a cable in-process while the host supplies the water and moves the top end. + +The host owns the time loop. Before each step it reads the node positions, evaluates its own +current and wave at them, hands the field over, and passes the motion of the top end. Replace +``sea`` and ``top_end`` with your own models. Run it with:: + + python step_in_moving_water.py +""" + +from __future__ import annotations + +import math +import tempfile +from pathlib import Path + +import numpy as np + +import cabledyn + +DT = 0.05 +DEPTH, GRAVITY = 60.0, 9.80665 +AMPLITUDE, PERIOD, CURRENT = 1.0, 8.0, 0.4 +TOP = np.array([0.0, 0.0, -10.0]) + +DECK = """--------------------- CableDyn Input File ------------------------------------ +A cable from a moving top end to an anchor. The steady current of the deck sets the static start. +--------------------- LINE TYPES --------------------------------------------- +TypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-) +cable 0.16 36.7 4.69e8 -1.0 1.99e4 1.2 0.1 1.0 0.0 +--------------------- POINTS ------------------------------------------------ +ID Type X Y Z +(-) (-) (m) (m) (m) +1 Coupled 0.0 0.0 -10.0 +2 Fixed 70.0 0.0 -60.0 +--------------------- LINES ------------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +1 1 2 - +--------------------- SECTIONS ---------------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +1 cable 100.0 50 +--------------------- OPTIONS ----------------------------------------------- +9.80665 g +1025.0 rhoW +60.0 WtrDpth +0.05 dtM +uniform 0.4 0 0 current +--------------------- OUTPUTS ----------------------------------------------- +"FairTen1" +--------------------- need this line ---------------------------------------- +""" + + +def wave_number(omega: float) -> float: + k = omega * omega / GRAVITY + for _ in range(50): + k = omega * omega / (GRAVITY * math.tanh(k * DEPTH)) + return k + + +OMEGA = 2.0 * math.pi / PERIOD +K = wave_number(OMEGA) + + +def sea(xyz: np.ndarray, t: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Whole ambient velocity, fluid acceleration and surface elevation at the nodes ``(3, n)``.""" + theta = K * xyz[0] - OMEGA * t + decay = np.exp(K * np.minimum(xyz[2], 0.0)) # deep-water profile, for brevity + velocity = np.zeros_like(xyz) + acceleration = np.zeros_like(xyz) + velocity[0] = CURRENT + AMPLITUDE * OMEGA * decay * np.cos(theta) + velocity[2] = AMPLITUDE * OMEGA * decay * np.sin(theta) + acceleration[0] = AMPLITUDE * OMEGA**2 * decay * np.sin(theta) + acceleration[2] = -AMPLITUDE * OMEGA**2 * decay * np.cos(theta) + return velocity, acceleration, AMPLITUDE * np.cos(theta) + + +def top_end(t: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Position, velocity and acceleration of the coupled point: a slow surge, from rest.""" + rate = 2.0 * math.pi / 10.0 + s, c = math.sin(rate * t), math.cos(rate * t) + q = TOP + np.array([0.5 * s**3, 0.0, 0.0]) + v = np.array([1.5 * rate * s * s * c, 0.0, 0.0]) + a = np.array([1.5 * rate**2 * (2.0 * s * c * c - s**3), 0.0, 0.0]) + return q, v, a + + +def main(duration: float = 20.0) -> list[float]: + """March for ``duration`` seconds and return the top tension of every step.""" + tensions = [] + with tempfile.TemporaryDirectory() as folder: + deck = Path(folder) / "cable.dat" + deck.write_text(DECK, encoding="utf-8") + with cabledyn.CableDyn() as model: + model.set_host_fluid() # before the initialization + model.init_deck(deck) + + def give_field(t: float) -> None: + # the field of a step: at its end time, at the step-start positions + model.update_fluid_fields(*sea(model.fluid_node_positions(), t)) + + give_field(0.0) # the start acceleration + for i in range(1, round(duration / DT) + 1): + t = i * DT + give_field(t) + model.step(DT, *top_end(t)) + model.calc_output() + tensions.append(float(model.channel("FairTen1"))) + if i % round(1.0 / DT) == 0: + print(f"t = {t:5.1f} s top tension {tensions[-1]:10.1f} N") + return tensions + + +if __name__ == "__main__": + main() diff --git a/integration/openfast/README.md b/integration/openfast/README.md index 037a63d9..26523f91 100644 --- a/integration/openfast/README.md +++ b/integration/openfast/README.md @@ -9,7 +9,7 @@ who only run coupled cases should use the released `openfast.exe` and the The patches in [`patches/`](patches/) apply to the public OpenFAST upstream commit `2895884d2be01862173c88d70f86b358d2f1a50a` (the OpenFAST v5.0.0 release, used by -CableDyn v0.4.0). They add only the OpenFAST host integration; the CableDyn sources come from +CableDyn v0.4.1). They add only the OpenFAST host integration; the CableDyn sources come from this repository. | File | Purpose | @@ -67,7 +67,7 @@ ifx default, `/fp:fast`, may reassociate sums and flush subnormals to zero. OpenFAST names its version with `git describe --dirty`, which would read `v5.0.0-dirty` on the patched tree. A CableDyn-enabled executable names itself `OpenFAST--CableDyn-` instead, here -`OpenFAST-v5.0.0-CableDyn-0.4.0` (printed by `openfast.exe -v` and at the start of every run). +`OpenFAST-v5.0.0-CableDyn-0.4.1` (printed by `openfast.exe -v` and at the start of every run). The OpenFAST part is `git describe --tags --abbrev=8` of the checked-out commit, the unpatched base; the CableDyn part is the project version in the CableDyn `CMakeLists.txt`. Patch `0021` sets it in the CMake build, and `integrate_openfast_vs_solution.ps1` in the diff --git a/integration/openfast/patches/0014-fast-subs.patch b/integration/openfast/patches/0014-fast-subs.patch index 21027d36..972f52aa 100644 --- a/integration/openfast/patches/0014-fast-subs.patch +++ b/integration/openfast/patches/0014-fast-subs.patch @@ -1,5 +1,5 @@ diff --git a/modules/openfast-library/src/FAST_Subs.f90 b/modules/openfast-library/src/FAST_Subs.f90 -index 48d3be5ed..3284a8c5d 100644 +index 48d3be5ed..2ad5a40fc 100644 --- a/modules/openfast-library/src/FAST_Subs.f90 +++ b/modules/openfast-library/src/FAST_Subs.f90 @@ -40,6 +40,7 @@ MODULE FAST_Subs @@ -540,7 +540,18 @@ index 48d3be5ed..3284a8c5d 100644 end if ! If we are doing AeroMaps, there is leftover data in AD15 parameters -@@ -7621,7 +7752,7 @@ contains +@@ -6900,6 +7031,10 @@ SUBROUTINE ExitThisProgram_T( Turbine, ErrLevel_in, StopTheProgram, ErrLocMsg, S + if (associated(Turbine%AD%p%FlowField)) deallocate(Turbine%AD%p%FlowField) + endif + ++ ! CableDyn writes its whole-run outputs only after a run that ended without an error. FAST.Farm ++ ! ends its turbines with no error level either way and sets this itself after its last step. ++ if (ErrorLevel < AbortErrLev .and. .not. Turbine%p_FAST%FarmIntegration) Turbine%CD%m%HostComplete = .true. ++ + ! End all modules + if (allocated(Turbine%m_Glue%ModData)) then + CALL FAST_ModEnd(Turbine%m_Glue%ModData, Turbine, ErrStat, ErrMsg) +@@ -7621,7 +7756,7 @@ contains ! Write VTK output call WriteVTK(TimeVTK, T%p_FAST, T%y_FAST, & T%ED, T%SED, T%BD, T%AD, T%IfW, T%ExtInfw, T%SeaSt, T%HD, T%SD, & @@ -549,4 +560,3 @@ index 48d3be5ed..3284a8c5d 100644 end subroutine - diff --git a/integration/openfast/patches/0023-fast-farm-end.patch b/integration/openfast/patches/0023-fast-farm-end.patch new file mode 100644 index 00000000..5348ac1f --- /dev/null +++ b/integration/openfast/patches/0023-fast-farm-end.patch @@ -0,0 +1,17 @@ +diff --git a/glue-codes/fast-farm/src/FAST_Farm.f90 b/glue-codes/fast-farm/src/FAST_Farm.f90 +index c3d63ed75..c7ecdcedc 100644 +--- a/glue-codes/fast-farm/src/FAST_Farm.f90 ++++ b/glue-codes/fast-farm/src/FAST_Farm.f90 +@@ -160,6 +160,12 @@ type(All_FastFarm_Data) :: farm + ! End: + !............................................................................................................................... + ++ ! the run is complete: a turbine's CableDyn instance writes its whole-run outputs (an abort, in ++ ! CheckError, ends the turbines without this) ++ do i_turb = 1, farm%p%NumTurbines ++ farm%FWrap(i_turb)%m%Turbine%CD%m%HostComplete = .true. ++ end do ++ + call FARM_End(farm, ErrStat, ErrMsg) + + ! Finalize AMReX library diff --git a/pyproject.toml b/pyproject.toml index b11093ea..264f7e6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ build-backend = "scikit_build_core.build" [project] name = "cabledyn" -version = "0.4.0" +version = "0.4.1" description = "Python tools for CableDyn, a geometrically exact finite-element solver for mooring lines and dynamic power cables" readme = "python/README.md" requires-python = ">=3.10" diff --git a/python/README.md b/python/README.md index 1e094cc4..e1fe8757 100644 --- a/python/README.md +++ b/python/README.md @@ -25,7 +25,7 @@ Fortran compiler and LAPACK. The pure Python wheel attached to each [GitHub release](https://github.com/SMI-Lab-Inha/CableDyn/releases/latest) -(`cabledyn-0.4.0-py3-none-any.whl`) carries no solver. `CableDynDriver` finds the +(`cabledyn-0.4.1-py3-none-any.whl`) carries no solver. `CableDynDriver` finds the driver from an explicit path, then the `CABLEDYN_DRIVER` environment variable, then the driver inside the package, then `PATH`; the in-process `CableDyn` class uses the library inside the package, or one named by `CABLEDYN_LIBRARY` or found in diff --git a/python/cabledyn/__init__.py b/python/cabledyn/__init__.py index 26720afd..032a6c06 100644 --- a/python/cabledyn/__init__.py +++ b/python/cabledyn/__init__.py @@ -124,6 +124,8 @@ ChannelStatistics, LineNodeHistory, LineSegmentHistory, + ModeShapes, + NaturalModes, OutputTable, SpatialStatistics, StaticLineSummary, @@ -196,7 +198,9 @@ "LinePositions", "LineSegmentHistory", "LineSummary", + "ModeShapes", "MoorDynLineHistory", + "NaturalModes", "OutputFormatError", "OutputTable", "PowerSpectrum", @@ -282,7 +286,7 @@ "upcrossing_maxima", ] -__version__ = "0.4.0" +__version__ = "0.4.1" def __getattr__(name: str) -> Any: diff --git a/python/cabledyn/_lib.py b/python/cabledyn/_lib.py index 44c49741..a34bc7de 100644 --- a/python/cabledyn/_lib.py +++ b/python/cabledyn/_lib.py @@ -87,6 +87,46 @@ def library_path() -> str: _lib.CableDyn_GetLastError.restype = None _DBL = POINTER(c_double) +_INT = POINTER(c_int) + +_lib.CableDyn_InitLine.argtypes = [ + c_void_p, + c_int, + c_int, + _DBL, + _DBL, + _INT, + _DBL, + _DBL, + _DBL, + _INT, + c_int, + c_int, + c_double, + _INT, +] +_lib.CableDyn_InitLine.restype = None + +_lib.CableDyn_InitLines.argtypes = [ + c_void_p, + c_int, + _INT, + _INT, + _INT, + _DBL, + _DBL, + _INT, + _DBL, + _DBL, + _DBL, + _INT, + _INT, + c_int, + c_int, + c_double, + _INT, +] +_lib.CableDyn_InitLines.restype = None _lib.CableDyn_UpdateStates.argtypes = [c_void_p, _DBL, _DBL, _DBL, c_int, POINTER(c_int)] _lib.CableDyn_UpdateStates.restype = None @@ -108,6 +148,22 @@ def library_path() -> str: _lib.CableDyn_CalcOutput.argtypes = [c_void_p, _DBL, c_int, POINTER(c_int)] _lib.CableDyn_CalcOutput.restype = None +_lib.CableDyn_CalcOutputDerivatives.argtypes = [ + c_void_p, + _DBL, + _DBL, + _DBL, + c_int, + c_double, + _DBL, + _DBL, + _DBL, + _DBL, + _DBL, + _INT, +] +_lib.CableDyn_CalcOutputDerivatives.restype = None + _lib.CableDyn_GetCoupledMotion.argtypes = [c_void_p, _DBL, _DBL, _DBL, c_int, POINTER(c_int)] _lib.CableDyn_GetCoupledMotion.restype = None @@ -214,8 +270,7 @@ def _bind_host_coupling() -> bool: # the coupled moments. A library built before the extension lacks these symbols # and reports ``abi_minor() < 3``. -#: Minor extension level of ABI 1 this binding uses for the host-rotation calls -#: (the newest extension the binding knows). +#: Minor extension level of ABI 1 this binding uses for the host-rotation calls. HOST_ROTATION_ABI_MINOR = 3 @@ -235,6 +290,55 @@ def _bind_host_rotation() -> bool: _has_host_rotation = _bind_host_rotation() +# Host fluid field (ABI minor 4). A library built before the extension lacks these +# symbols and reports ``abi_minor() < 4``. + +#: Minor extension level of ABI 1 this binding uses for the host fluid field +#: (the newest extension the binding knows). +HOST_FLUID_ABI_MINOR = 4 + + +def _bind_host_fluid() -> bool: + try: + set_fluid = _lib.CableDyn_SetHostFluid + except AttributeError: + return False + set_fluid.argtypes = [c_void_p, c_int, POINTER(c_int)] + set_fluid.restype = None + _lib.CableDyn_NFluidNodes.argtypes = [c_void_p, POINTER(c_int)] + _lib.CableDyn_NFluidNodes.restype = c_int + _lib.CableDyn_GetFluidNodePositions.argtypes = [c_void_p, _DBL, c_int, POINTER(c_int)] + _lib.CableDyn_GetFluidNodePositions.restype = None + _lib.CableDyn_UpdateFluidFields.argtypes = [ + c_void_p, + _DBL, + _DBL, + _DBL, + _DBL, + c_int, + POINTER(c_int), + ] + _lib.CableDyn_UpdateFluidFields.restype = None + return True + + +_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).""" diff --git a/python/cabledyn/deck_file.py b/python/cabledyn/deck_file.py index 7ea1388a..5ba451b2 100644 --- a/python/cabledyn/deck_file.py +++ b/python/cabledyn/deck_file.py @@ -12,6 +12,7 @@ from __future__ import annotations +import bisect import contextlib import copy import hashlib @@ -22,10 +23,11 @@ import re import sys import tempfile -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path, PureWindowsPath from types import MappingProxyType +from typing import Any from cabledyn._paths import windows_device_component from cabledyn.errors import DeckFormatError @@ -277,6 +279,8 @@ "bodysubstep", "dtwave", "wvhicoff", + "waverefx", + "waverefy", "bodywetting", "bodyhydro", "rodhydro", @@ -326,7 +330,7 @@ _MOTION_SOURCE_KEYWORDS = ("motionfile", "vesselmotion", "vesselrao") # Native STATIC_CHECK_HINT: appended to the errors that ask a standalone deck without TMax for # the march (those naming one of the cues). -_STATIC_CHECK_HINT = "; to check a dynamic deck's static start only, give dtM with TMax = 0" +_STATIC_CHECK_HINT = "; to check a dynamic deck's static start only, give TMax = 0" _STATIC_CHECK_CUES = ("dtM and TMax", "requires dtM", "require dtM", "dynamic deck (TMax)") _END_CONNECTION_ENDS = { "a": "a", @@ -1229,6 +1233,8 @@ def _scalar_option_value_error(keyword: str, value: str) -> str | None: "dtout", "dtwave", "wvhicoff", + "waverefx", + "waverefy", "inertialf", "inertialf_rampt", }: @@ -1441,6 +1447,911 @@ def _scalar_option_value_error(keyword: str, value: str) -> str | None: return None +# --------------------------------------------------------------------------- ancillary files +# The native readers of the files a deck names, as far as they refuse a deck. Each function +# follows the subroutine it names in src/CableDyn_DeckDriver.f90 (or the module given) and raises +# _AncillaryError with the native message; DeckFile adds the deck's label. + +_NATIVE_RECORD_LENGTH = 512 # DECK_RECLEN: the longest non-comment record of next_record +_NATIVE_TABLE_ROW_LENGTH = 65536 # LONG_ROW of read_moordyn_c_kinematics +_NATIVE_EPSILON = sys.float_info.epsilon +_NUMERIC_SHAPE = re.compile( + r"[+-]?(?:(?i:inf|infinity|nan)|(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eEdD][+-]?[0-9]+)?)" +) +_MAX_GRID_STEPS = float(2**31 - 2) +_MAX_MOTION_SPEED = 1.0e4 +_MAX_MOTION_ACCEL = 1.0e6 +_MAX_FLUID_SPEED = 1.0e3 +_MAX_WAVE_SAMPLES = 1.0e6 +_MAX_CURRENT_SAMPLES = 2000000 +_SYROPE_CURVE_SAMPLES = 30 # CD_SYROPE_NWC +_SYROPE_SCAN_SAMPLES = 200 # CD_SYROPE_NSCAN +_BATHY_CREASE_FRACTION = 0.05 +_BATHY_CREASE_MIN_HEIGHT = 1.0e-6 +_SEA_DEFAULT_DIRECTIONS = 9 +_SPREADING_QUADRATURE = 128 +_STATIC_CHECK_DT = 1.0 # the nominal dtM of a standalone TMax = 0 without one (parse_deck) + + +class _AncillaryError(ValueError): + """A refusal of a native ancillary-file reader.""" + + +def _token_real(token: str) -> float | None: + """Mirror the native ``parse_real_token``: the finite, normal real of a whole token.""" + if _NUMERIC_SHAPE.fullmatch(token) is None: + return None + try: + value = float(re.sub("[dD]", "e", token)) + except ValueError: + return None + if not math.isfinite(value) or _is_subnormal(value): + return None + return value + + +def _real_row(tokens: list[str], count: int) -> tuple[int, list[float]]: + """Mirror the native ``parse_real_row``: status 0, 1 (no number) or 2 (malformed), values.""" + if not tokens or _NUMERIC_SHAPE.fullmatch(tokens[0]) is None: + return 1, [] + values: list[float] = [] + for token in tokens[:count]: + value = _token_real(token) + if value is None: + return 2, [] + values.append(value) + return (0, values) if len(values) == count else (2, []) + + +def _data_rows(records: list[str], what: str) -> Iterator[tuple[int, list[str]]]: + """Mirror the native ``next_record``: non-blank rows without comments, by file line.""" + for number, raw in enumerate(records, start=1): + visible = _strip_inline_comment(raw) + if not visible.strip(_WHITESPACE): + continue + defect = _character_error(visible) + if defect is not None: + raise _AncillaryError(f"{what} line {number} {defect}") + if len(visible) > _NATIVE_RECORD_LENGTH: + raise _AncillaryError(f"{what} line {number} is longer than the record limit") + yield number, visible.split() + + +def _increasing(values: Sequence[float]) -> bool: + return all(later > earlier for earlier, later in itertools.pairwise(values)) + + +def _padded_fft_size(wanted: int) -> int: + """Mirror ``padded_fft_size``: twice the next 7-smooth half length.""" + half = max((wanted + 1) // 2, 1) + while True: + rest = half + for prime in (2, 3, 5, 7): + while rest % prime == 0: + rest //= prime + if rest == 1: + return 2 * half + half += 1 + + +def _check_elevation_record( + records: list[str], dt_wave: float, window: float, cutoff: float, depth: float | None +) -> None: + """Mirror ``read_wave_elevation_components`` and the component bounds of validation.""" + import numpy as np + + times: list[float] = [] + heights: list[float] = [] + for raw in records: + tokens = raw.split() + if not tokens: + continue + status, values = _real_row(tokens, 2) + if status == 2: + raise _AncillaryError(f'WaterKin WaveKinFile row "{raw}" is not "time elevation"') + if status != 0: + if times: + raise _AncillaryError("WaterKin WaveKinFile contains a non-data row in its history") + continue + times.append(values[0]) + heights.append(values[1]) + if len(times) < 4: + raise _AncillaryError("WaterKin WaveKinFile needs at least four `time elevation` rows") + tolerance = 1000.0 * _NATIVE_EPSILON * max(1.0, abs(times[-1])) + if abs(times[0]) > tolerance: + raise _AncillaryError("WaterKin WaveKinFile time history must start at t = 0") + if not _increasing(times): + raise _AncillaryError("WaterKin WaveKinFile times must be strictly increasing") + span = window if window > 0.0 else times[-1] + if not span / dt_wave <= _MAX_WAVE_SAMPLES: + raise _AncillaryError("WaterKin TMax/dtWave needs more than 1000000 wave samples") + wanted = math.ceil(span / dt_wave) + samples = _padded_fft_size(wanted) + series = np.zeros(samples) + series[:wanted] = np.interp(np.arange(wanted) * dt_wave, times, heights, right=0.0) + kept = samples // 2 + if cutoff >= 0.0: + step = 2.0 * math.pi / (samples * dt_wave) + kept = min(kept, math.floor(cutoff / step * (1.0 + 1.0e-15))) + if kept < 1: + raise _AncillaryError("WvHiCOff keeps no component of the WaterKin WaveKinFile record") + mean = float(series.sum() / samples) + amplitudes = np.abs(np.fft.rfft(series - mean))[1 : kept + 1] * (2.0 / samples) + if samples % 2 == 0 and kept == samples // 2: + amplitudes[-1] *= 0.5 + top = 2.0 * math.pi * kept / (samples * dt_wave) + if float(amplitudes.max()) > 1.0e3 or top > 1.0e4: + raise _AncillaryError( + "the wave components (WaterKin WaveKinMod 1 or WaveKin 3/7) need amplitudes of at " + "most 1e3 m and frequencies of at most 1e4 rad/s" + ) + if depth is not None and depth + mean <= 0.0: + raise _AncillaryError("WaterKin mean elevation leaves a non-positive effective depth") + + +def _check_waterkin_current(records: list[str]) -> None: + """Mirror the CurrentMod 1 table of ``read_waterkin_records`` (records after CurrentMod).""" + if len(records) < 2: + raise _AncillaryError("WaterKin file ended inside the current-table header") + malformed = "malformed WaterKin current row (expected z ux uy as plain numbers)" + rows: list[list[float]] = [] + start = 2 + for offset in range(4): + if start + offset >= len(records): + break + status, values = _real_row(records[start + offset].split(), 3) + if status == 2: + raise _AncillaryError(malformed) + if status == 0: + rows.append(values) + start += offset + 1 + break + if not rows: + raise _AncillaryError("could not find the WaterKin current profile table") + for record in records[start:]: + status, values = _real_row(record.split(), 3) + if status == 2: + raise _AncillaryError(malformed) + if status != 0: + break + rows.append(values) + if len(rows) < 2: + raise _AncillaryError("WaterKin CurrentMod 1 needs at least two `z ux uy` depth rows") + depths = [row[0] for row in rows] + if any(value > 0.0 for value in depths): + if not all(value >= 0.0 for value in depths): + raise _AncillaryError("WaterKin current depth column mixes signs") + depths = [-value for value in depths] + if depths[0] > depths[-1]: + depths.reverse() + if any(b - a <= 100.0 * _NATIVE_EPSILON for a, b in itertools.pairwise(depths)): + raise _AncillaryError("WaterKin current depths must be strictly monotonic") + _check_current_bounds(depths, [value for row in rows for value in row[1:]]) + + +def _check_current_bounds(depths: Sequence[float], velocities: Iterable[float]) -> None: + if any(abs(value) > _NATIVE_MAX_COORDINATE for value in depths) or any( + abs(value) > _MAX_FLUID_SPEED for value in velocities + ): + raise _AncillaryError( + "current profile depths must be at most 1e6 m and velocities at most 1e3 m/s in " + "magnitude" + ) + + +def _wave_frequency_rows(records: list[str], label: str) -> list[list[float]]: + """Mirror ``read_table``: rows `omega Re Im beta`, the direction zero when left out.""" + rows: list[list[float]] = [] + for number, record in enumerate(records, start=1): + tokens = record.split() + if not tokens: + continue + status, values = _real_row(tokens, 4) + if status != 0: + status, values = _real_row(tokens, 3) + values.append(0.0) + if status != 0: + raise DeckFormatError(f"{label} line {number}: row is not 3 to 4 plain numbers") + rows.append(values) + return rows + + +def _table_tokens(record: str) -> list[str]: + """Mirror ``row_tokens``: the tokens up to the first one holding a ``#``.""" + tokens: list[str] = [] + for token in record.split(): + if "#" in token: + break + tokens.append(token) + return tokens + + +def _table_number(token: str, name: str, number: int) -> float: + value = _token_real(token) + if value is None: + raise _AncillaryError(f'{name} line {number}: value "{token}" is not a plain finite number') + return value + + +def _check_current_profile(records: list[str], name: str) -> None: + """Mirror ``Currents 1``: `z ux uy uz` rows after their header rows.""" + rows: list[list[float]] = [] + for number, raw in enumerate(records, start=1): + tokens = raw.split() + if not tokens: + continue + status, values = _real_row(tokens, 4) + if status != 0: + status, values = _real_row(tokens, 3) + if status == 1 and not rows: + continue + if status != 0: + raise _AncillaryError(f"{name} line {number}: row is not 3 to 4 plain numbers") + rows.append(values) + if not rows: + raise _AncillaryError(f"Currents 1: {name} has no `z ux uy uz` rows") + depths = [row[0] for row in rows] + if not _increasing(depths): + raise _AncillaryError(f"Currents 1: {name} depths must be strictly increasing") + if any(b - a <= 100.0 * _NATIVE_EPSILON for a, b in itertools.pairwise(depths)): + raise _AncillaryError("current profile depths must be strictly increasing") + _check_current_bounds(depths, [value for row in rows for value in row[1:]]) + + +def _check_current_resample( + times: list[float], spatial: int, velocities: Any, coordinates: Iterable[float] +) -> None: + """Mirror ``CD_Current_Grid_Resample`` and the grid bounds; velocities is (nt, points).""" + import numpy as np + + step = min(b - a for a, b in itertools.pairwise(times)) + if times[-1] < 0.0: + raise _AncillaryError("current grid: the last time must not be negative") + if not times[-1] / _MAX_CURRENT_SAMPLES < step: + raise _AncillaryError( + "current grid: the last time over the smallest time interval exceeds the sample limit" + ) + samples = math.floor(times[-1] / step) + 1 + if samples * spatial > _MAX_CURRENT_SAMPLES: + raise _AncillaryError("current grid: more than 2e6 resampled space-time samples") + at = np.arange(samples) * step + upper = np.clip(np.searchsorted(times, at, side="right"), 1, len(times) - 1) + lower = upper - 1 + knots = np.asarray(times) + weight = np.clip((at - knots[lower]) / (knots[upper] - knots[lower]), 0.0, 1.0)[:, None] + resampled = (1.0 - weight) * velocities[lower] + weight * velocities[upper] + if any(abs(value) > _NATIVE_MAX_COORDINATE for value in coordinates) or ( + float(np.abs(resampled).max()) > _MAX_FLUID_SPEED + ): + raise _AncillaryError( + "current grid coordinates must be at most 1e6 m and velocities at most 1e3 m/s in " + "magnitude" + ) + + +def _check_dynamic_current(records: list[str], name: str) -> None: + """Mirror ``read_dynamic_profile`` (Currents 2).""" + import numpy as np + + depths: list[float] = [] + times: list[float] = [] + rows: list[list[float]] = [] + for number, raw in enumerate(records, start=1): + if len(raw) > _NATIVE_TABLE_ROW_LENGTH: + raise _AncillaryError(f"{name} line {number}: row is too long") + if number <= 4 or number == 6: + continue + tokens = _table_tokens(raw) + if number == 5: + if not tokens: + raise _AncillaryError(f"Currents 2: line 5 of {name} must list the depths") + for token in tokens: + depth = float(np.float32(_table_number(token, name, number))) + if not math.isfinite(depth): + raise _AncillaryError(f"{name} depth is not a single-precision number") + depths.append(depth) + continue + if not tokens: + continue + count = len(depths) + if len(tokens) not in (1 + count, 1 + 2 * count, 1 + 3 * count): + raise _AncillaryError(f"Currents 2: {name} line {number}: wrong number of values") + values = [_table_number(token, name, number) for token in tokens] + times.append(values[0]) + rows.append(values[1:] + [0.0] * (3 * count - len(values) + 1)) + if not depths or len(records) < 7: + raise _AncillaryError(f"Currents 2: {name} needs at least 7 lines") + if len(times) < 2: + raise _AncillaryError(f"Currents 2: {name} needs at least two time rows") + if not _increasing(depths): + raise _AncillaryError(f"Currents 2: {name} depths must be strictly increasing") + if not _increasing(times): + raise _AncillaryError(f"Currents 2: {name} times must be strictly increasing") + _check_current_resample(times, len(depths), np.asarray(rows), [*depths, 0.0]) + + +def _check_grid_current(records: list[str], name: str) -> None: + """Mirror ``read_4d_grid`` (Currents 5).""" + import numpy as np + + sizes: list[int] = [] + axes: list[list[float]] = [] + points: dict[tuple[int, int, int, int], list[float]] = {} + for number, raw in enumerate(records, start=1): + if len(raw) > _NATIVE_TABLE_ROW_LENGTH: + raise _AncillaryError(f"{name} line {number}: row is too long") + tokens = _table_tokens(raw) + if number == 1: + good = len(tokens) == 4 and all(re.fullmatch(r"[+-]?[0-9]+", t) for t in tokens) + sizes = [int(token) for token in tokens] if good else [] + if not good or any(size < 1 for size in sizes): + raise _AncillaryError(f"Currents 5: line 1 of {name} must be `nx ny nz nt`") + if math.prod(sizes) > _MAX_CURRENT_SAMPLES: + raise _AncillaryError(f"Currents 5: {name} declares more than 2e6 grid points") + elif number <= 5: + if len(tokens) != sizes[number - 2]: + raise _AncillaryError(f"Currents 5: line {number} of {name} has the wrong length") + axes.append([_table_number(token, name, number) for token in tokens]) + else: + if not tokens: + continue + if number == 6 and _NUMERIC_SHAPE.fullmatch(tokens[0]) is None: + continue + if len(tokens) != 7: + raise _AncillaryError(f"Currents 5: {name} line {number}: a row is 7 numbers") + row = [_table_number(token, name, number) for token in tokens] + where: list[int] = [] + for axis, value in zip(axes, row, strict=False): + if value not in axis: + raise _AncillaryError(f"Currents 5: {name} line {number}: off the grid") + where.append(axis.index(value)) + key = (where[0], where[1], where[2], where[3]) + if key in points: + raise _AncillaryError(f"Currents 5: {name} line {number}: repeats a grid point") + points[key] = row[4:] + if len(axes) < 4: + raise _AncillaryError(f"Currents 5: {name} needs the grid sizes and coordinates") + if not all(_increasing(axis) for axis in axes): + raise _AncillaryError(f"Currents 5: {name} coordinates must be strictly increasing") + if len(axes[3]) < 2: + raise _AncillaryError(f"Currents 5: {name} needs at least two times") + if len(points) < math.prod(sizes): + raise _AncillaryError(f"Currents 5: {name} has no row for a grid point") + spatial = math.prod(sizes[:3]) + field = np.zeros((sizes[3], spatial * 3)) + for (ix, iy, iz, it), velocity in points.items(): + column = 3 * ((ix * sizes[1] + iy) * sizes[2] + iz) + field[it, column : column + 3] = velocity + _check_current_resample(axes[3], spatial, field, [*axes[0], *axes[1], *axes[2]]) + + +def _motion_time_index( + time: float, dt: float, n_time: int, tolerance: float, start_row_only: bool +) -> int | None: + """Mirror ``motion_grid_index``: the 0-based step, -1 past TMax, ``None`` off the grid. + + start_row_only: a static check without dtM, whose grid is the t = 0 row: a later row is not + used, whatever its time step. + """ + if start_row_only and time > tolerance: + return -1 + if not abs(time / dt) < _MAX_GRID_STEPS: + return None + step = round(time / dt) + if step < 0: + return None + if step < n_time: + return step if abs(step * dt - time) <= tolerance else None + if abs(step * dt - time) <= 100.0 * _NATIVE_EPSILON * max(1.0, abs(time)): + return -1 + return None + + +def _check_motion_rows( + records: list[str], + dt: float, + tmax: float, + eligible: set[int] | None, + roll_ends: set[int] | None, + turbines: dict[int, list[int]] | None, + start_row_only: bool, +) -> None: + """Mirror ``read_motion_file``. + + eligible: the ids the file must move at every time (``None``: on a route that takes + other points as well, their rows are only read). roll_ends: with a line solved in torsion, + the points whose moving end is restrained in torsion. turbines: the point ids of each + TURBINES row, on a deck that has them. + """ + n_time = round(tmax / dt) + 1 + tolerance = 100.0 * _NATIVE_EPSILON * max(1.0, tmax) + what = "motionFile" + seen: set[tuple[int, int]] = set() + roll_rows: dict[int, dict[int, float]] = {} + for number, tokens in _data_rows(records, what): + where = f"{what} line {number}" + if turbines is not None: + status, row = _real_row(tokens, 21) + if status != 0: + raise _AncillaryError(f"{where}: TURBINES motion rows must be 21 numbers") + if ( + any(abs(value) > _NATIVE_MAX_COORDINATE for value in row[2:9]) + or any(abs(value) > _MAX_MOTION_SPEED for value in row[9:15]) + or any(abs(value) > _MAX_MOTION_ACCEL for value in row[15:21]) + ): + raise _AncillaryError(f"{where}: value outside the admissible range") + turbine = int(row[1]) if row[1] == int(row[1]) else 0 + if turbine not in turbines: + raise _AncillaryError(f"{where}: J must be the integer id of a TURBINES row") + if abs(math.sqrt(sum(value * value for value in row[5:9])) - 1.0) > 1.0e-6: + raise _AncillaryError(f"{where}: the quaternion must have unit norm (within 1e-6)") + step = _motion_time_index(row[0], dt, n_time, tolerance, start_row_only) + if step is None: + raise _AncillaryError(f"{where}: time is outside the dtM/TMax grid") + if step == -1: + continue + if (-turbine, step) in seen: + raise _AncillaryError(f"{where}: duplicate row for turbine {turbine}") + seen.add((-turbine, step)) + seen.update((point, step) for point in turbines[turbine]) + continue + status, row = _real_row(tokens, 11) + if status != 0: + raise _AncillaryError(f"{where}: rows must be: time point_id x y z vx vy vz ax ay az") + if ( + any(abs(value) > _NATIVE_MAX_COORDINATE for value in row[2:5]) + or any(abs(value) > _MAX_MOTION_SPEED for value in row[5:8]) + or any(abs(value) > _MAX_MOTION_ACCEL for value in row[8:11]) + ): + raise _AncillaryError(f"{where}: value outside the admissible range") + point = int(row[1]) if abs(row[1]) <= 2**31 - 1 and row[1] == int(row[1]) else 0 + if point < 1: + raise _AncillaryError(f"{where}: point_id must be a positive integer") + step = _motion_time_index(row[0], dt, n_time, tolerance, start_row_only) + if step is None: + raise _AncillaryError(f"{where}: time is outside the dtM/TMax grid") + if eligible is not None and point not in eligible: + raise _AncillaryError(f"{where}: references point id {point}, which is not eligible") + if step == -1: + continue + if (point, step) in seen: + raise _AncillaryError(f"{where}: duplicate row for point {point} at this time") + seen.add((point, step)) + if roll_ends is not None and len(tokens) >= 12 and _NUMERIC_SHAPE.fullmatch(tokens[11]): + roll = _token_real(tokens[11]) + if roll is None: + raise _AncillaryError(f"{where}: the roll column must be a finite number") + roll_rows.setdefault(point, {})[step] = roll + if eligible is not None and any( + (point, step) not in seen for point in eligible for step in range(n_time) + ): + raise _AncillaryError( + "motionFile must provide every Coupled/Vessel point at every dtM time" + ) + if roll_ends is None: + return + for point, rolls in roll_rows.items(): + if len(rolls) != n_time: + raise _AncillaryError( + f"motionFile: point {point} gives the roll column on some rows only" + ) + if any(rolls.values()): + if point not in roll_ends: + raise _AncillaryError( + f"motionFile: point {point} has a non-zero roll column, but no line restrained " + "in torsion has its moving end there" + ) + if rolls[0] != 0.0: + raise _AncillaryError( + f"motionFile: the roll column of point {point} must be 0 at t = 0" + ) + + +def _check_vessel_rows(records: list[str], dt: float, tmax: float, start_row_only: bool) -> None: + """Mirror ``read_vessel_record``.""" + n_time = round(tmax / dt) + 1 + tolerance = 100.0 * _NATIVE_EPSILON * max(1.0, tmax) + what = "vesselMotion file" + form = 0 + seen: set[int] = set() + for number, tokens in _data_rows(records, what): + where = f"{what} line {number}" + if form == 0 and len(tokens) in (19, 20): + form = len(tokens) + status, row = (1, []) if len(tokens) != form else _real_row(tokens, form) + if status != 0: + raise _AncillaryError(f"{where}: rows must hold 19 or 20 values, one form for the file") + if any(abs(value) > _NATIVE_MAX_COORDINATE for value in row[1:]): + raise _AncillaryError(f"{where}: value outside the admissible range") + step = _motion_time_index(row[0], dt, n_time, tolerance, start_row_only) + if step is None: + raise _AncillaryError(f"{where}: time is outside the dtM/TMax grid") + if step == -1: + continue + if step in seen: + raise _AncillaryError(f"{where}: duplicate row at this time") + if form == 20 and abs(math.sqrt(sum(v * v for v in row[4:8])) - 1.0) > 1.0e-6: + raise _AncillaryError(f"{where}: the quaternion must have unit norm (within 1e-6)") + seen.add(step) + if len(seen) < n_time: + raise _AncillaryError("vesselMotion file must provide one row at every dtM time") + + +def _check_rao_table(records: list[str], headings_in_use: Iterable[float]) -> None: + """Mirror ``read_rao_table``, ``CD_Vessel_RAO_Set`` and the heading test of ``_RAO_Eval``.""" + what = "vesselRAO file" + heads: list[float] = [] + blocks: list[list[float]] = [] + amplitudes: list[float] = [] + for number, tokens in _data_rows(records, what): + where = f"{what} line {number}" + if tokens[0].lower() == "heading": + value = _token_real(tokens[1]) if len(tokens) == 2 else None + if value is None or len(heads) >= 361: + raise _AncillaryError(f'{where}: a heading row must be "HEADING "') + heads.append(value) + blocks.append([]) + continue + status, row = (1, []) if len(tokens) != 13 else _real_row(tokens, 13) + if status != 0 or not heads or len(blocks[-1]) >= 2000: + raise _AncillaryError(f'{where}: data rows follow a "HEADING " row with 13 values') + blocks[-1].append(row[0]) + amplitudes.extend(row[1::2]) + if not heads: + raise _AncillaryError('vesselRAO file holds no "HEADING " block') + if any(not block for block in blocks): + raise _AncillaryError("a vesselRAO heading block holds no period rows") + if any(block != blocks[0] for block in blocks): + raise _AncillaryError("every vesselRAO heading block must list the same periods") + periods = sorted(blocks[0]) + heads = sorted(heads) + if periods[0] <= 0.0: + raise _AncillaryError("vesselRAO file: RAO periods must be positive") + if any(value < 0.0 for value in amplitudes): + raise _AncillaryError("vesselRAO file: RAO amplitudes must be non-negative") + if not _increasing(periods): + raise _AncillaryError("vesselRAO file: RAO periods must be strictly ascending") + if not _increasing(heads): + raise _AncillaryError("vesselRAO file: RAO headings must be strictly ascending") + if heads[-1] - heads[0] >= 360.0: + raise _AncillaryError("vesselRAO file: RAO headings must lie within one 360 deg turn") + for heading in headings_in_use: + turned = heads[0] + (heading - heads[0]) % 360.0 + if turned > heads[-1] + 1.0e-9 and abs(turned - 360.0 - heads[0]) <= 1.0e-9: + turned = heads[0] + if len(heads) == 1: + if abs(turned - heads[0]) > 1.0e-9: + raise _AncillaryError("relative wave heading is not in the one-heading RAO table") + elif turned > heads[-1] + 1.0e-9: + raise _AncillaryError("relative wave heading lies outside the RAO table heading range") + + +def _floor_axis(axis: list[float], q: float) -> tuple[int, float, bool]: + """Mirror ``locate_axis`` (0-based): the cell, the fraction and whether q is off the grid.""" + if q <= axis[0]: + return 0, 0.0, q < axis[0] + if q >= axis[-1]: + return len(axis) - 2, 1.0, q > axis[-1] + cell = bisect.bisect_left(axis, q) - 1 + return cell, (q - axis[cell]) / (axis[cell + 1] - axis[cell]), False + + +def _crease_band(axis: list[float], cell: int, q: float) -> tuple[int, float, float]: + """Mirror ``crease_band`` (0-based; -1: none).""" + if cell > 0: + band = _BATHY_CREASE_FRACTION * min( + axis[cell] - axis[cell - 1], axis[cell + 1] - axis[cell] + ) + if q - axis[cell] < band: + return cell, q - axis[cell], band + if cell + 2 < len(axis): + band = _BATHY_CREASE_FRACTION * min( + axis[cell + 1] - axis[cell], axis[cell + 2] - axis[cell + 1] + ) + if axis[cell + 1] - q < band: + return cell + 1, q - axis[cell + 1], band + return -1, 0.0, 0.0 + + +def _bathymetry_floor( + xs: list[float], ys: list[float], depth: dict[tuple[int, int], float], x: float, y: float +) -> tuple[float, float]: + """Mirror ``floor_eval``: the rounded and the bilinear seabed elevation at (x, y).""" + ix, tx, clamp_x = _floor_axis(xs, x) + iy, ty, clamp_y = _floor_axis(ys, y) + grid = ( + -(1.0 - tx) * (1.0 - ty) * depth[ix, iy] + - tx * (1.0 - ty) * depth[ix + 1, iy] + - (1.0 - tx) * ty * depth[ix, iy + 1] + - tx * ty * depth[ix + 1, iy + 1] + ) + if len(xs) <= 2 and len(ys) <= 2: + return grid, grid + + def x_jump(k: int, j: int, band: float) -> tuple[float, bool]: + jump = (depth[k, j] - depth[k + 1, j]) / (xs[k + 1] - xs[k]) - ( + depth[k - 1, j] - depth[k, j] + ) / (xs[k] - xs[k - 1]) + active = abs(jump) * band / 6.0 > _BATHY_CREASE_MIN_HEIGHT + return (jump if active else 0.0), active + + def y_jump(i: int, k: int, band: float) -> tuple[float, bool]: + jump = (depth[i, k] - depth[i, k + 1]) / (ys[k + 1] - ys[k]) - ( + depth[i, k - 1] - depth[i, k] + ) / (ys[k] - ys[k - 1]) + active = abs(jump) * band / 6.0 > _BATHY_CREASE_MIN_HEIGHT + return (jump if active else 0.0), active + + kx, u, band_x = (-1, 0.0, 0.0) if clamp_x else _crease_band(xs, ix, x) + ky, v, band_y = (-1, 0.0, 0.0) if clamp_y else _crease_band(ys, iy, y) + if kx < 0 and ky < 0: + return grid, grid + floor = grid + hx = 0.0 + if kx >= 0: + hx = (band_x - abs(u)) ** 3 / (6.0 * band_x * band_x) + jx0, on0 = x_jump(kx, iy, band_x) + jx1, on1 = x_jump(kx, iy + 1, band_x) + if on0 or on1: + floor += hx * ((1.0 - ty) * jx0 + ty * jx1) + if ky >= 0: + hy = (band_y - abs(v)) ** 3 / (6.0 * band_y * band_y) + jy0, on0 = y_jump(ix, ky, band_y) + jy1, on1 = y_jump(ix + 1, ky, band_y) + mixed = 0.0 + on_mixed = False + if kx >= 0: + below, _ = x_jump(kx, ky - 1, band_x) + centre, _ = x_jump(kx, ky, band_x) + above, _ = x_jump(kx, ky + 1, band_x) + mixed = (above - centre) / (ys[ky + 1] - ys[ky]) - (centre - below) / ( + ys[ky] - ys[ky - 1] + ) + on_mixed = abs(mixed) * band_x * band_y / 36.0 > _BATHY_CREASE_MIN_HEIGHT + if not on_mixed: + mixed = 0.0 + if on0 or on1 or on_mixed: + floor += hy * ((1.0 - tx) * jy0 + tx * jy1 + hx * mixed) + return floor, grid + + +def _check_bathymetry( + records: list[str], fixed_points: list[tuple[int, float, float, float]] +) -> None: + """Mirror ``load_bathymetry_file`` and ``check_fixed_points_on_bathymetry``.""" + what = "bathymetry file" + rows: list[list[float]] = [] + for number, tokens in _data_rows(records, what): + if len(tokens) != 3: + raise _AncillaryError(f'{what} line {number}: rows must be "x y depth"') + status, row = _real_row(tokens, 3) + if status != 0: + raise _AncillaryError(f"{what} line {number}: malformed row") + rows.append(row) + if len(rows) < 4: + raise _AncillaryError("bathymetry file needs at least a 2x2 grid") + if any(row[2] <= 0.0 for row in rows): + raise _AncillaryError("bathymetry file depths must be positive") + + def unique(values: list[float]) -> list[float]: + kept: list[float] = [] + for value in sorted(values): + if not kept or abs(value - kept[-1]) > 16.0 * _NATIVE_EPSILON * max( + 1.0, abs(value), abs(kept[-1]) + ): + kept.append(value) + return kept + + def nearest(axis: list[float], value: float) -> int: + at = bisect.bisect_left(axis, value) + return min( + (i for i in (at - 1, at) if 0 <= i < len(axis)), key=lambda i: abs(axis[i] - value) + ) + + xs = unique([row[0] for row in rows]) + ys = unique([row[1] for row in rows]) + if len(xs) < 2 or len(ys) < 2 or len(rows) != len(xs) * len(ys): + raise _AncillaryError("bathymetry file must define one complete rectangular grid") + depth: dict[tuple[int, int], float] = {} + for x, y, value in rows: + key = (nearest(xs, x), nearest(ys, y)) + if key in depth: + raise _AncillaryError("bathymetry file has duplicate x/y entries") + depth[key] = value + for point, x, y, z in fixed_points: + floor, grid = _bathymetry_floor(xs, ys, depth, x, y) + tolerance = max(1.0e-3, 1.0e-5 * abs(floor)) * (1.0 + 1.0e-9) + if abs(z - floor) <= tolerance or abs(z - grid) <= tolerance or z > floor: + continue + raise _AncillaryError(f"POINT {point} (Fixed) lies below the seabed: z = {z:g}") + + +def _read_syrope_settings(records: list[str]) -> tuple[str, str, float, float]: + """Mirror the settings rows of ``read_syrope_settings``: OWC path, WCType, k1, k2.""" + what = "Syrope settings file" + found: dict[str, str] = {} + for number, tokens in _data_rows(records, what): + if len(tokens) < 2: + continue + name = tokens[1].lower() + value = _strip_quotes(tokens[0]) + if name not in {"owc", "wctype", "k1", "k2"}: + continue + if name in found: + raise _AncillaryError(f"{what} line {number}: duplicate Syrope {tokens[1]} row") + if name == "wctype" and value.lower() not in {"linear", "quadratic", "exp"}: + raise _AncillaryError( + f"{what} line {number}: Syrope WCType must be LINEAR, QUADRATIC, or EXP" + ) + if name in {"k1", "k2"} and _token_real(value) is None: + raise _AncillaryError(f"{what} line {number}: Syrope {tokens[1]} is not a plain number") + found[name] = value + if len(found) < 4: + raise _AncillaryError("the Syrope settings file needs OWC, WCType, k1, and k2 rows") + k1 = _token_real(found["k1"]) + k2 = _token_real(found["k2"]) + assert k1 is not None and k2 is not None + return found["owc"], found["wctype"].lower(), k1, k2 + + +def _read_syrope_table(records: list[str]) -> list[list[float]]: + """Mirror the OWC table rows of ``read_syrope_settings``.""" + what = "Syrope OWC table" + rows: list[list[float]] = [] + for number, tokens in _data_rows(records, what): + if not any(_NUMERIC_SHAPE.fullmatch(token) for token in tokens): + continue + status, row = _real_row(tokens, 2) + if status != 0: + raise _AncillaryError(f"{what} line {number}: malformed row") + rows.append(row) + if len(rows) < 2: + raise _AncillaryError("the Syrope OWC table needs at least two rows") + return rows + + +def _syrope_curve_defect( + strain: list[float], + tension: list[float], + shape: str, + k1: float, + k2: float, + alpha: float, + beta: float, + t_max: float, +) -> bool: + """True when ``CD_Syrope_Working_Curve`` fails at the running maximum ``t_max``.""" + + def fast(value: float) -> float: + return math.log(1.0 + beta / alpha * value) / beta + + if not t_max > 0.0 or t_max < tension[0] or t_max > tension[-1]: + return True + at = bisect.bisect_left(tension, t_max) + if at == 0: + eps_max = strain[0] + elif at == len(tension): + eps_max = strain[-1] + else: + eps_max = strain[at - 1] + (strain[at] - strain[at - 1]) * (t_max - tension[at - 1]) / ( + tension[at] - tension[at - 1] + ) + eps_min = strain[0] + k1 * (eps_max - strain[0]) + if shape == "linear" and k1 >= 1.0: + eps_min = eps_max - t_max / k1 + if not (eps_min >= 0.0 and eps_max - eps_min > 0.0): + return True + previous: tuple[float, float] | None = None + for index in range(_SYROPE_CURVE_SAMPLES): + at_strain = eps_min + (eps_max - eps_min) * index / (_SYROPE_CURVE_SAMPLES - 1) + xi = (at_strain - eps_min) / (eps_max - eps_min) + if shape == "linear": + value = t_max * xi + elif shape == "quadratic": + value = t_max * xi * (k2 * xi + (1.0 - k2)) + else: + value = t_max * (1.0 - math.exp(k2 * xi)) / (1.0 - math.exp(k2)) + argument = 1.0 + beta / alpha * value + if argument <= 0.0: + return True + slow = at_strain - fast(value) + if previous is not None and (value <= previous[0] or slow <= previous[1]): + return True + previous = (value, slow) + return False + + +def _check_syrope_curve( + rows: list[list[float]], + shape: str, + k1: float, + k2: float, + alpha: float, + beta: float, +) -> None: + """Mirror ``CD_Syrope_Init`` (the constants BA_s and BA_d are checked with the row).""" + strain = [row[0] for row in rows] + tension = [row[1] for row in rows] + if not (_increasing(strain) and _increasing(tension)): + raise _AncillaryError("the OWC strain and tension columns must be strictly increasing") + if shape == "exp" and not k2 > 0.0: + raise _AncillaryError("the EXP working curve needs a positive shape parameter p2 (k2 > 0)") + if shape == "linear" and k1 < 0.0: + raise _AncillaryError("the LINEAR working curve requires k1 >= 0") + if shape == "quadratic" and (k1 < 0.0 or k1 >= 1.0 or k2 <= 0.0 or k2 > 1.0): + raise _AncillaryError("the QUADRATIC working curve requires 0 <= k1 < 1 and 0 < k2 <= 1") + if shape == "exp" and (k1 < 0.0 or k1 >= 1.0 or k2 < 1.0e-6 or k2 > 700.0): + raise _AncillaryError("the EXP working curve requires 0 <= k1 < 1 and 1e-6 <= k2 <= 700") + if any(1.0 + beta / alpha * value <= 0.0 for value in tension): + raise _AncillaryError("the OWC slow-spring static strain is non-finite") + slow = [ + e - math.log(1.0 + beta / alpha * t) / beta for e, t in zip(strain, tension, strict=True) + ] + if not _increasing(slow): + raise _AncillaryError("the OWC slow-spring static strain must be strictly increasing") + low = max(tension[0], 0.0) + high = tension[-1] + if all( + _syrope_curve_defect( + strain, + tension, + shape, + k1, + k2, + alpha, + beta, + low + (high - low) * k / _SYROPE_SCAN_SAMPLES, + ) + for k in range(1, _SYROPE_SCAN_SAMPLES + 1) + ): + raise _AncillaryError("no running maximum in the OWC table range admits a working curve") + + +def _train_direction(values: tuple[str, ...]) -> tuple[float, float]: + """Mirror ``CD_Sea_Parse_Train``: the direction and spreading of a wave row's values.""" + kind = values[0].lower() + if kind == "airy": + return _native_float(values[3]), 0.0 + index = 4 if kind == "jonswap" else 7 if kind.startswith("ochi") else 3 + spreading = _native_float(values[index + 1]) if len(values) > index + 1 else 0.0 + return _native_float(values[index]), spreading + + +def _spreading_weights(spreading: float, bins: int) -> list[float]: + """Mirror ``CD_Spreading_Weights``: the cos-2s weight of each equal-angle bin.""" + constant = math.exp(math.lgamma(spreading + 1.0) - math.lgamma(spreading + 0.5)) + constant /= math.sqrt(math.pi) + weights = [] + for j in range(bins): + start = -0.5 * math.pi + math.pi * j / bins + step = (-0.5 * math.pi + math.pi * (j + 1) / bins - start) / _SPREADING_QUADRATURE + weight = 0.0 + for q in range(_SPREADING_QUADRATURE + 1): + density = constant * max(0.0, math.cos(start + step * q)) ** (2.0 * spreading) + end = q in (0, _SPREADING_QUADRATURE) + weight += density * (1.0 if end else 4.0 if q % 2 == 1 else 2.0) + weights.append(weight * step / 3.0) + total = sum(weights) + if total > 0.0: + weights = [weight / total for weight in weights] + return [weight if weight >= sys.float_info.min else 0.0 for weight in weights] + + +def _spread_headings(direction: float, spreading: float, bins: int) -> list[float]: + """The headings of a train (``CD_Sea_Synthesise``) that carry energy. + + A spread train has one heading per bin centre. A narrow spreading leaves the outer bins with + no weight, and ``synthesize_vessel_rao`` skips their zero-amplitude components. + """ + if spreading <= 0.0: + return [direction] + return [ + direction - 90.0 + 180.0 * (j + 0.5) / bins + for j, weight in enumerate(_spreading_weights(spreading, bins)) + if weight > 0.0 + ] + + @dataclass(frozen=True) class DeckRecord: """One parsed table row and its zero-based source-line location. @@ -1623,8 +2534,11 @@ class DeckFile: current is kept as a steady field when SeaState carries no waves or current and is rejected otherwise. The native C API (:class:`cabledyn.CableDyn`) reads decks with the standalone rules, so it rejects the ``CoupledPinned``/``VesselPinned`` bodies and rods - that ``caller_driven=True`` accepts; FAST.Farm, in turn, refuses bodies and rods when it - initialises the module. A coupled deck carries bodies or rods, not both (a rod fixed to a + that ``caller_driven=True`` accepts; a FAST.Farm shared mooring, in turn, refuses bodies and + rods and the ``LINES`` range-graph flag ``r`` when it initialises the module, and the C API + refuses the flag. ``RangeStart`` is held + against the host's ``TMax`` when OpenFAST initialises the module, not against a deck + ``TMax``. A coupled deck carries bodies or rods, not both (a rod fixed to a body counts as a rod): ``caller_driven=True`` rejects a deck with both, as the coupled route and the C API do, while the standalone driver's multibody march takes them. """ @@ -2421,12 +3335,13 @@ def validate(self) -> None: between rows. A deck that passes can still be rejected by the solver on physical grounds. - The ancillary files a deck names (motion, bathymetry, WaterKin, Syrope, - and the MoorDyn-C kinematics files) are not read here, so the checks - that depend on their contents stay with the solver: for example the - anchor height on a bathymetry surface, whether a WaterKin file carries - waves (``WaveKinMod``) for ``vesselRAO``, and whether its current - doubles a ``current`` row or ``Currents 1``, ``2`` or ``5``. + The ancillary files a deck names (WaterKin and its wave record, the + MoorDyn-C kinematics files, motion and vessel records, the RAO table, + bathymetry, and the Syrope settings and OWC table) are read from the + deck's folder, with the rules of their native readers. A file that is + not there, as for a deck held in memory under its nominal path, is not + an error at this stage: the deck passes these checks and the solver, + which must open the file, refuses it by name if it is still missing. Raises ------ @@ -2505,7 +3420,14 @@ def effective(key: str, default: float) -> float: ) has_dtm = "dtm" in option_by_key has_tmax = "tmax" in option_by_key - if not self.caller_driven and has_dtm != has_tmax: + # native parse_deck: a static check marches no step, so standalone TMax = 0 needs no dtM + static_check = ( + not self.caller_driven + and has_tmax + and not has_dtm + and _native_float(option_by_key["tmax"].values[0]) == 0.0 + ) + if not self.caller_driven and has_dtm != has_tmax and not static_check: raise DeckFormatError( f"{self._label}: standalone dynamic run requires both dtM and TMax options" ) @@ -2525,6 +3447,7 @@ def effective(key: str, default: float) -> float: raise DeckFormatError( f"{self._label}: OPTION TMax must be an integer multiple of dtM" ) + has_dtm = has_dtm or static_check # MoorDyn-C WaveKin 3/7 and Currents 1/2/5 (native read_moordyn_c_kinematics): component # waves and a current profile or grid read from fixed-name files in the deck folder. # The coupled aggregate takes its water kinematics from the host and rejects both. @@ -2572,6 +3495,8 @@ def effective(key: str, default: float) -> float: has_waterkin_file = waterkin_value.lower() not in {"", "none", "seastate"} and any( character in _WATERKIN_FILENAME_LETTERS for character in waterkin_value ) + if has_waterkin_file and motion_source == "vesselrao": + has_waterkin_file = self._waterkin_wave_mode(waterkin_value) in {None, 1} has_trains = any(o.keyword.lower() in _WAVETRAIN_KEYWORDS for o in options) # WaveKin 3/7 installs linear component waves (native has_wave) if motion_source == "vesselrao" and not ( @@ -2664,6 +3589,35 @@ def effective(key: str, default: float) -> float: raise DeckFormatError( f"{self._label}: WaveKin {mdc_wave_kin} waves require dtM, TMax and WtrDpth" ) + if mdc_wave_kin == 7: + self._check_wave_frequencies(_native_float(option_by_key["water_depth"].values[0])) + # native validation: WaveRefX and WaveRefY place the phases of component waves alone, + # which a WaterKin file carries under WaveKinMod 1. + # The bound applies to the value in force: a later row of an option replaces an earlier one. + wave_ref_rows = { + o.keyword.lower(): o for o in options if o.keyword.lower() in {"waverefx", "waverefy"} + } + if wave_ref_rows: + if any( + abs(_native_float(o.values[0])) > _NATIVE_MAX_COORDINATE + for o in wave_ref_rows.values() + ): + raise DeckFormatError( + f"{self._label}: WaveRefX and WaveRefY must be at most 1e6 m in magnitude" + ) + waterkin_ref = option_by_key.get("waterkin") + waterkin_text = "" if waterkin_ref is None else waterkin_ref.values[0] + has_record = bool(mdc_wave_kin) or ( + waterkin_text.lower() not in {"", "none", "seastate"} + and any(character in _WATERKIN_FILENAME_LETTERS for character in waterkin_text) + and self._waterkin_wave_mode(waterkin_text) in {None, 1} + ) + if self.caller_driven or has_waves or trains or not has_record: + raise DeckFormatError( + f"{self._label}: WaveRefX and WaveRefY place the reference point of an " + "elevation record or a component file (WaterKin WaveKinMod 1, WaveKin 3 or 7); " + "this deck has none" + ) if mdc_currents: if current is not None and current.values[0].lower() != "none": raise DeckFormatError( @@ -4125,17 +5079,19 @@ def is_body_kind(kind: str, pinned: bool) -> bool: f"{self._label}: OPTION RangeStart needs a line with the LINES Outputs " "flag r (range graph)" ) - if not has_tmax: - raise DeckFormatError( - f"{self._label}: OPTION RangeStart needs a dynamic deck (TMax)" - ) - tmax_value = _native_float(option_by_key["tmax"].values[0]) - # at TMax = 0 (a dynamic deck's static start) the range graphs hold the t = 0 row - if _native_float(range_start.values[0]) > tmax_value and tmax_value > 0.0: - raise DeckFormatError( - f"{self._label}: OPTION RangeStart exceeds TMax: the range window would hold " - "no output time" - ) + # a coupled host owns the run time: the native init holds the window against it + if not self.caller_driven: + if not has_tmax: + raise DeckFormatError( + f"{self._label}: OPTION RangeStart needs a dynamic deck (TMax)" + ) + tmax_value = _native_float(option_by_key["tmax"].values[0]) + # at TMax = 0 (a dynamic deck's static start) the range graphs hold the t = 0 row + if _native_float(range_start.values[0]) > tmax_value and tmax_value > 0.0: + raise DeckFormatError( + f"{self._label}: OPTION RangeStart exceeds TMax: the range window would " + "hold no output time" + ) # Native check_channel: ids are digit strings compared by value, so leading zeros # are accepted (FairTen01 is FairTen1). @@ -4298,6 +5254,431 @@ def is_body_kind(kind: str, pinned: bool) -> bool: ) continue raise DeckFormatError(f"{self._label}: unsupported OUTPUT channel {channel!r}") + try: + self._check_ancillary_files( + option_by_key, + options, + motion_source, + mdc_wave_kin, + mdc_currents, + has_waves or bool(trains), + current is not None and current.values[0].lower() != "none", + ( + { + node + for line_id in torsion_lines + for node, end in zip( + normalized_line_endpoints[line_id], ("a", "b"), strict=True + ) + if torsion_ends.get(line_id, {}).get(end, False) + and point_types[node] != "fixed" + } + if torsion_lines + else None + ), + bool(finite_length_rods), + has_rigid6, + static_check, + ) + except _AncillaryError as exc: + raise DeckFormatError(f"{self._label}: {exc}") from None + + def _check_ancillary_files( + self, + option_by_key: dict[str, OptionRecord], + options: tuple[OptionRecord, ...], + motion_source: str | None, + mdc_wave_kin: int, + mdc_currents: int, + has_sea: bool, + has_current: bool, + roll_ends: set[str] | None, + has_rods: bool, + has_rigid6: bool, + static_check: bool, + ) -> None: + """Run the native rules of the ancillary files the deck names, those that are there. + + A static check runs on the nominal clock the native parser installs before it validates. + """ + + def number(key: str) -> float | None: + row = option_by_key.get(key) + return None if row is None else _native_float(row.values[0]) + + dt = _STATIC_CHECK_DT if static_check else number("dtm") + tmax = number("tmax") or 0.0 + depth = number("water_depth") + record_dir: float | None = None + component_headings: list[float] | None = None + waterkin_row = option_by_key.get("waterkin") + if waterkin_row is not None and not mdc_wave_kin: + value = waterkin_row.values[0] + if value.lower() not in {"", "none", "seastate"} and any( + character in _WATERKIN_FILENAME_LETTERS for character in value + ): + record_dir = self._check_waterkin_file( + value, tmax, dt, depth, has_sea, has_current, mdc_currents, option_by_key + ) + if mdc_wave_kin == 3: + component_headings = [0.0] + elif mdc_wave_kin == 7: + component_headings = self._wave_frequency_headings() + if mdc_currents: + name = _MOORDYN_C_CURRENT_FILES[mdc_currents] + records = self._ancillary_records(name) + if records is not None: + check = { + 1: _check_current_profile, + 2: _check_dynamic_current, + 5: _check_grid_current, + }[mdc_currents] + check(records, name) + rows = self._rows("POINTS") + for key in ("bathymetry",): + row = option_by_key.get(key) + if row is None: + continue + records = self._ancillary_records(row.values[0]) + if records is not None: + _check_bathymetry( + records, + [ + ( + _native_int(point.tokens[0]), + _native_float(point.tokens[2]), + _native_float(point.tokens[3]), + _native_float(point.tokens[4]), + ) + for point in rows + if point.tokens[1].lower() == "fixed" + ], + ) + self._check_syrope_files() + if motion_source is None or dt is None or self.caller_driven: + return + source = next(o for o in reversed(options) if o.keyword.lower() == motion_source) + records = self._ancillary_records(source.values[0]) + if records is None: + return + if motion_source == "vesselmotion": + if has_rods or has_rigid6: + raise _AncillaryError( + "vesselMotion/vesselRAO move Coupled/Vessel line points; decks with prescribed " + "rods or Rigid6 bodies take motionFile rows" + ) + _check_vessel_rows(records, dt, tmax, static_check) + elif motion_source == "vesselrao": + if has_rods or has_rigid6: + raise _AncillaryError( + "vesselMotion/vesselRAO move Coupled/Vessel line points; decks with prescribed " + "rods or Rigid6 bodies take motionFile rows" + ) + headings = component_headings + if record_dir is not None: + headings = [record_dir] + if headings is None: + headings = self._sea_headings(option_by_key, options) + _check_rao_table(records, headings) + else: + self._check_motion_file( + records, dt, tmax, roll_ends, has_rods, has_rigid6, static_check + ) + + def _check_waterkin_file( + self, + value: str, + tmax: float, + dt: float | None, + depth: float | None, + has_sea: bool, + has_current: bool, + mdc_currents: int, + option_by_key: dict[str, OptionRecord], + ) -> float | None: + """Mirror ``read_waterkin_records``; returns the WaveDir of a WaveKinMod 1 record.""" + records = self._ancillary_records(value) + mode = self._waterkin_wave_mode(value) + if records is None or mode is None: + return None + + def line(index: int, name: str) -> str: + if index >= len(records): + raise _AncillaryError(f"WaterKin file has no {name} line") + return records[index] + + tokens = line(4, "WaveKinFile").split() + wave_file = _strip_quotes(tokens[0]) if tokens else "" + tokens = line(5, "dtWave").split() + dt_wave = _token_real(tokens[0]) if tokens else None + if dt_wave is None or dt_wave < 0.0: + raise _AncillaryError("WaterKin dtWave must be finite and non-negative") + tokens = line(6, "WaveDir").split() + wave_dir = _token_real(tokens[0]) if tokens else None + if wave_dir is None: + raise _AncillaryError("WaterKin WaveDir must be finite") + if len(records) < 13: + raise _AncillaryError("WaterKin file ended inside the waves block") + if mode == 1: + if self.caller_driven: + raise _AncillaryError( + "WaterKin WaveKinMod 1 is supported only by the standalone driver; coupled " + "aggregate runs must use WaveKinMod 2/SEASTATE through external_fluid" + ) + if not wave_file: + raise _AncillaryError("WaterKin WaveKinMod 1 requires WaveKinFile") + if dt_wave <= 0.0: + raise _AncillaryError("WaterKin WaveKinMod 1 requires positive dtWave") + if has_sea: + raise _AncillaryError( + "the deck declares BOTH a waves OPTION and WaterKin WaveKinMod 1 " + "(double-counting); keep one" + ) + if dt is None or depth is None: + raise _AncillaryError("deck waves require dtM, TMax and WtrDpth") + history = self._ancillary_records(wave_file) + if history is None: + history = self._ancillary_records( + str(self._ancillary_path(value).parent / wave_file) + ) + if history is not None: + cutoff_row = option_by_key.get("wvhicoff") + cutoff = math.pi + if cutoff_row is not None: + cutoff = ( + -1.0 + if cutoff_row.values[0].lower() == "none" + else _native_float(cutoff_row.values[0]) + ) + _check_elevation_record(history, dt_wave, tmax, cutoff, depth) + elif mode == 2 and not self.caller_driven: + raise _AncillaryError( + "WaterKin WaveKinMod 2/SEASTATE is coupled-only; the standalone driver has no host " + "SeaState field" + ) + line(13, "current header") + tokens = line(14, "CurrentMod").split() + if not tokens or re.fullmatch(r"[+-]?[0-9]{1,18}", tokens[0]) is None: + raise _AncillaryError("WaterKin CurrentMod must be an integer (0, 1, or 2)") + current_mode = int(tokens[0]) + if not 0 <= current_mode <= 2: + raise _AncillaryError( + "WaterKin CurrentMod supports 0 (none), 1 (depth profile), or 2 (SeaState hybrid)" + ) + if current_mode == 2: + if mode == 1: + raise _AncillaryError( + "WaterKin WaveKinMod 1 plus CurrentMod 2 mixes self-driven waves with a host " + "SeaState current, which the nodewise host field cannot separate" + ) + if not self.caller_driven: + raise _AncillaryError( + "WaterKin WaveKinMod 2/SEASTATE is coupled-only; the standalone driver has no " + "host SeaState field" + ) + if current_mode == 1: + _check_waterkin_current(records[15:]) + if has_current: + raise _AncillaryError( + "the deck declares BOTH a current OPTION and a WaterKin current " + "(double-counting); keep one" + ) + if mdc_currents: + raise _AncillaryError( + "the deck declares BOTH a current OPTION (or a WaterKin current) and " + f"Currents {mdc_currents} (double-counting); keep one" + ) + return wave_dir if mode == 1 else None + + def _wave_frequency_headings(self) -> list[float] | None: + """The directions [deg] of the components of ``wave_frequencies.txt``, if it is there.""" + records = self._ancillary_records(_MOORDYN_C_WAVE_FILES[7]) + if records is None: + return None + headings = [ + math.degrees(math.atan2(math.sin(beta), math.cos(beta))) + for omega, real_part, imaginary, beta in _wave_frequency_rows(records, "") + if omega > 0.0 and math.hypot(real_part, imaginary) > 0.0 + ] + return headings + + def _sea_headings( + self, option_by_key: dict[str, OptionRecord], options: tuple[OptionRecord, ...] + ) -> list[float]: + """The headings of a deck's own sea, as ``CD_Sea_Synthesise`` lays them out.""" + bins_row = option_by_key.get("wave_directions") + bins = _SEA_DEFAULT_DIRECTIONS + if bins_row is not None and round(_native_float(bins_row.values[0])) > 0: + bins = round(_native_float(bins_row.values[0])) + spreading_row = option_by_key.get("wave_spreading") + spreading = 0.0 if spreading_row is None else _native_float(spreading_row.values[0]) + headings: list[float] = [] + waves = option_by_key.get("waves") + if waves is not None and waves.values[0].lower() != "none": + direction, _ = _train_direction(waves.values) + headings.extend(_spread_headings(direction, spreading, bins)) + for option in options: + if option.keyword.lower() in _WAVETRAIN_KEYWORDS: + direction, own = _train_direction(option.values) + headings.extend(_spread_headings(direction, own, bins)) + return headings + + def _check_syrope_files(self) -> None: + """Mirror ``read_syrope_settings``, and ``CD_Syrope_Init`` for the types lines use.""" + used = {row.tokens[1].lower() for row in self.sections} + for row in self.line_types: + parts = row.tokens[3].split("|") + if not parts[0].lower().startswith("syrope:"): + continue + records = self._ancillary_records(parts[0][len("syrope:") :]) + if records is None: + continue + owc, shape, k1, k2 = _read_syrope_settings(records) + settings = self._ancillary_path(parts[0][len("syrope:") :]) + name = _strip_quotes(owc) + absolute = name.startswith(("/", "\\")) or name[1:2] == ":" + table = self._ancillary_records(name if absolute else str(settings.parent / name)) + if table is None: + continue + rows = _read_syrope_table(table) + if row.tokens[0].lower() in used: + _check_syrope_curve( + rows, shape, k1, k2, _native_float(parts[1]), _native_float(parts[2]) + ) + + def _check_motion_file( + self, + records: list[str], + dt: float, + tmax: float, + roll_ends: set[str] | None, + has_rods: bool, + has_rigid6: bool, + start_row_only: bool, + ) -> None: + """Mirror ``read_motion_file`` on the route the native dispatch takes. + + Only the routes with lines read the file, and only those of lines alone its roll column. + """ + if not self.lines and (has_rods or has_rigid6): + return + moved = {"coupled", "vessel"} + ids: dict[int, str] = { + _native_int(row.tokens[0]): row.tokens[1].lower() for row in self.points + } + turbine_rows = self._rows("TURBINES") + eligible: set[int] | None = { + point for point, kind in ids.items() if kind in moved or _is_turbine_point_type(kind) + } + if has_rods and has_rigid6: + eligible = None + elif has_rods: + assert eligible is not None + eligible |= {point for point, kind in ids.items() if _rod_point(kind) is not None} + elif has_rigid6: + assert eligible is not None + eligible |= {point for point, kind in ids.items() if kind.startswith("body")} + if eligible is not None and not eligible: + raise _AncillaryError("motionFile requires at least one Coupled/Vessel point") + turbines: dict[int, list[int]] | None = None + if turbine_rows: + turbines = {_native_int(row.tokens[0]): [] for row in turbine_rows} + for point, kind in ids.items(): + if _is_turbine_point_type(kind): + turbines.setdefault(_turbine_id(kind), []).append(point) + _check_motion_rows( + records, + dt, + tmax, + eligible, + None + if roll_ends is None or has_rods or has_rigid6 + else {int(point) for point in roll_ends}, + turbines, + start_row_only, + ) + + def _ancillary_path(self, value: str) -> Path: + """Return the file an ancillary value names, as the native ``path_join``.""" + name = _strip_quotes(value) + absolute = name.startswith(("/", "\\")) or name[1:2] == ":" + return Path(name) if absolute else self.path.parent / name + + def _ancillary_records(self, value: str) -> list[str] | None: + """Return the records of the ancillary file ``value`` names, ``None`` if it is not there. + + A relative name is taken from the deck's folder. + """ + source = self._ancillary_path(value) + try: + payload = source.read_bytes() + except OSError: + return None + records, _ = _split_records(payload.decode("utf-8", "surrogateescape")) + if records and records[0].startswith(_UTF8_BOM): + records[0] = records[0][len(_UTF8_BOM) :] + return records + + def _waterkin_wave_mode(self, value: str) -> int | None: + """Return the ``WaveKinMod`` of WaterKin file ``value``, ``None`` if it is not there. + + Mirrors the head of the native ``read_waterkin_records``: two header lines, a waves + header, then the WaveKinMod line. + """ + records = self._ancillary_records(value) + if records is None: + return None + if len(records) < 3: + raise DeckFormatError(f"{self._label}: WaterKin file ended inside its header") + if len(records) < 4: + raise DeckFormatError(f"{self._label}: WaterKin file has no WaveKinMod line") + tokens = records[3].split() + if not tokens: + raise DeckFormatError(f"{self._label}: WaterKin WaveKinMod line has no value") + if _strip_quotes(tokens[0]).lower() == "seastate": + return 2 + if re.fullmatch(r"[+-]?[0-9]{1,18}", tokens[0]) is None: + raise DeckFormatError( + f"{self._label}: WaterKin WaveKinMod must be 0, 1, 2, or SEASTATE" + ) + mode = int(tokens[0]) + if not 0 <= mode <= 2: + raise DeckFormatError( + f"{self._label}: WaterKin WaveKinMod supports 0 (none), 1 (elevation history), " + "or 2 (coupled SeaState hybrid)" + ) + return mode + + def _check_wave_frequencies(self, depth: float) -> None: + """Check ``wave_frequencies.txt`` in the deck's folder, if it is there. + + Mirrors the ``WaveKin 7`` branch of the native ``read_moordyn_c_kinematics``, the + component bounds of its validation and the effective depth of the wave precompute. + A row takes four numbers, or three and a direction of zero. + """ + records = self._ancillary_records(_MOORDYN_C_WAVE_FILES[7]) + if records is None: + return + label = f"{self._label}: WaveKin 7: wave_frequencies.txt" + rows = _wave_frequency_rows(records, label) + if len(rows) < 2: + raise DeckFormatError(f"{label} needs at least two `omega Re Im` rows") + if rows[0][0] != 0.0: + raise DeckFormatError(f"{label}: the first row must be at omega = 0") + for index, (omega, real_part, imaginary, beta) in enumerate(rows): + if abs(beta) > 2.0 * math.pi: + raise DeckFormatError(f"{label} directions are radians within +-2 pi") + if index == 0: + continue + if omega <= 0.0: + raise DeckFormatError(f"{label} frequencies after the first row must be positive") + if math.hypot(real_part, imaginary) > 1.0e3 or omega > 1.0e4: + raise DeckFormatError( + f"{label} components need amplitudes of at most 1e3 m and frequencies of " + "at most 1e4 rad/s" + ) + if depth + rows[0][2] <= 0.0: + raise DeckFormatError(f"{label}: the mean level leaves a non-positive effective depth") def _check_torsion_normal( self, diff --git a/python/cabledyn/driver.py b/python/cabledyn/driver.py index ee6ab8df..724e730b 100644 --- a/python/cabledyn/driver.py +++ b/python/cabledyn/driver.py @@ -24,6 +24,7 @@ from cabledyn.results import ( LineNodeHistory, LineSegmentHistory, + NaturalModes, OutputTable, StaticProfile, TimeHistory, @@ -174,6 +175,9 @@ class DriverResult: Captured standard error of the driver. returncode : int Native exit code; always ``0`` for a result. + modes_output : pathlib.Path | None + Natural frequencies and mode shapes ``.modes.out``, or ``None`` + if not written. """ executable: Path @@ -187,6 +191,7 @@ class DriverResult: stdout: str stderr: str returncode: int + modes_output: Path | None = None def read_main(self) -> TimeHistory: """Read the primary channel history again from disk. @@ -230,6 +235,29 @@ def read_static(self) -> StaticProfile: raise OutputFormatError(f"{self.static_output}: static output is not a line profile") return table + def read_modes(self) -> NaturalModes: + """Read the natural modes, raising if this run did not write them. + + Returns + ------- + NaturalModes + The frequency table of each line, with the mode shapes in its + ``shapes`` attribute. + + Raises + ------ + FileNotFoundError + If the run did not write a modal output (the deck sets no ``nModes``). + OutputFormatError + If the file is malformed or not a modal output. + """ + if self.modes_output is None: + raise FileNotFoundError("the run did not produce a .modes.out file") + table = read_output(self.modes_output) + if not isinstance(table, NaturalModes): + raise OutputFormatError(f"{self.modes_output}: modal output has no modal tables") + return table + @property def line_ids(self) -> tuple[int, ...]: """Line identifiers for which a per-line output file was produced.""" @@ -646,6 +674,7 @@ def _execute( ) from exc static_candidate = Path(f"{root}.static.out") elements_candidate = Path(f"{root}.elements.out") + modes_candidate = Path(f"{root}.modes.out") line_outputs = _auxiliary_outputs(root, "Line") rod_outputs = _auxiliary_outputs(root, "Rod") return DriverResult( @@ -660,4 +689,5 @@ def _execute( stdout=completed.stdout, stderr=completed.stderr, returncode=completed.returncode, + modes_output=modes_candidate if modes_candidate.is_file() else None, ) diff --git a/python/cabledyn/model.py b/python/cabledyn/model.py index 97a81963..ac0b6a49 100644 --- a/python/cabledyn/model.py +++ b/python/cabledyn/model.py @@ -35,6 +35,7 @@ __all__ = ["Body", "CableDyn", "CableDynError", "ConvergenceError", "Line", "Point", "Rod"] FloatArray = npt.NDArray[np.float64] +IntArray = npt.NDArray[np.intc] ArrayInput = npt.ArrayLike # The C library retains up to 4096 characters per handle; +1 for the NUL. @@ -86,27 +87,123 @@ def _as_dof_array(x: ArrayInput, n: int, name: str) -> FloatArray: return arr +def _as_field(x: ArrayInput, n: int, name: str) -> FloatArray: + """Return a per-node vector field as flat ``(3 n,)``; accepts ``(3, n)`` or flat.""" + return _as_dof_array(x, 3 * n, name) + + +def _as_node_values(x: ArrayInput, n: int, name: str) -> FloatArray: + """Return one finite value per node as a contiguous ``(n,)`` array.""" + arr = _as_real_array(x, name) + if arr.ndim != 1 or arr.size != n: + raise ValueError(f"{name} must have {n} entries") + if not np.all(np.isfinite(arr)): + raise ValueError(f"{name} must be finite (no NaN or Inf)") + return np.ascontiguousarray(arr) + + def _dbl_ptr(arr: FloatArray) -> Any: return arr.ctypes.data_as(POINTER(c_double)) -def _host_positions(q0: ArrayInput) -> FloatArray: +def _int_ptr(arr: IntArray) -> Any: + return arr.ctypes.data_as(POINTER(c_int)) + + +def _as_index_array(x: ArrayInput, name: str) -> IntArray: + """Return ``x`` as a contiguous C ``int`` array, rejecting non-integer input. + + Connectivity, DOF and count arrays stay exact: a float array is not rounded to one. + """ + src = np.asarray(x) + if src.size == 0: + return np.zeros(src.shape, dtype=np.intc) + if src.dtype.kind not in "iu": + raise TypeError(f"{name} must be an integer array; got dtype {src.dtype}") + limits = np.iinfo(np.intc) + if int(src.min()) < limits.min or int(src.max()) > limits.max: + raise ValueError(f"{name} holds a value outside the range of a C int") + return np.ascontiguousarray(src, dtype=np.intc) + + +def _as_flag(value: object, name: str) -> int: + """Return a true-or-false argument as the C ``int`` 1 or 0.""" + if not isinstance(value, (bool, np.bool_, numbers.Integral)): + raise TypeError(f"{name} must be a boolean; got {type(value).__name__}") + return 1 if value else 0 + + +def _as_spectral_radius(value: object) -> float: + """Return ``rho_inf``, a finite real number in ``[0, 1]``.""" + result = _as_real_scalar(value, "rho_inf", positive=False) + if result > 1.0: + raise ValueError(f"rho_inf must lie in [0, 1]; got {result}") + return result + + +def _as_element_array(x: ArrayInput, n_elem: int, name: str) -> FloatArray: + """Return a per-element array: flat, ``n_elem`` finite values.""" + arr = _as_real_array(x, name) + if arr.ndim != 1 or arr.size != n_elem: + raise ValueError(f"{name} must be flat ({n_elem},); got shape {np.shape(x)}") + if not np.all(np.isfinite(arr)): + raise ValueError(f"{name} must be finite (no NaN or Inf)") + return arr + + +def _raw_line_arrays( + q0: ArrayInput, + v0: ArrayInput, + elem_conn: ArrayInput, + l0: ArrayInput, + ea: ArrayInput, + rho_a: ArrayInput, + fixed_dofs: ArrayInput, +) -> tuple[FloatArray, FloatArray, IntArray, FloatArray, FloatArray, FloatArray, IntArray]: + """The arrays of :meth:`CableDyn.init_line` and :meth:`CableDyn.init_lines`, checked for shape. + + Returns them in that order, each flat. The values are the library's to judge. + """ + q = _host_positions(q0) + v = _host_positions(v0, "v0") + if v.size != q.size: + raise ValueError(f"v0 must hold as many values as q0 ({q.size}); got {v.size}") + length = _as_real_array(l0, "l0") + if length.ndim != 1: + raise ValueError(f"l0 must be flat (n_elem,); got shape {np.shape(l0)}") + n_elem = int(length.size) + length = _as_element_array(length, n_elem, "l0") + stiffness = _as_element_array(ea, n_elem, "ea") + mass = _as_element_array(rho_a, n_elem, "rho_a") + conn = _as_index_array(elem_conn, "elem_conn") + if conn.shape not in {(2 * n_elem,), (n_elem, 2)}: + raise ValueError( + f"elem_conn must be flat ({2 * n_elem},) or one row per element ({n_elem}, 2); " + f"got shape {conn.shape}" + ) + fixed = _as_index_array(fixed_dofs, "fixed_dofs") + if fixed.ndim != 1: + raise ValueError(f"fixed_dofs must be flat; got shape {fixed.shape}") + return q, v, conn.reshape(-1), length, stiffness, mass, fixed + + +def _host_positions(q0: ArrayInput, name: str = "q0") -> FloatArray: """``q0`` of :meth:`CableDyn.init_deck_at`: flat ``(3 n,)`` or column-per-point ``(3, n)``.""" - q = _as_real_array(q0, "q0") + q = _as_real_array(q0, name) if q.ndim == 2 and q.shape == (3, 3): # three points: column-per-point and row-per-point look alike raise ValueError( - "q0 of shape (3, 3) is ambiguous (column-per-point or row-per-point); " + f"{name} of shape (3, 3) is ambiguous (column-per-point or row-per-point); " "pass it flat, x1 y1 z1 x2 y2 z2 ..." ) if q.ndim == 2 and q.shape[0] == 3: q = np.ascontiguousarray(q.reshape(-1, order="F")) if q.ndim != 1 or q.size % 3 != 0: raise ValueError( - f"q0 must be flat (3 * n,) or column-per-point (3, n); got shape {np.shape(q0)}" + f"{name} must be flat (3 * n,) or column-per-point (3, n); got shape {np.shape(q0)}" ) if not np.all(np.isfinite(q)): - raise ValueError("q0 must be finite (no NaN or Inf)") + raise ValueError(f"{name} must be finite (no NaN or Inf)") return q @@ -136,7 +233,8 @@ class CableDyn: A MoorDyn-style sectioned ``.dat`` deck (the deck format in the documentation, https://cabledyn.readthedocs.io). When given, the static initial condition is solved immediately. - ``None`` creates an unbound handle for a later :meth:`init_deck`. + ``None`` creates an unbound handle for a later :meth:`init_deck`, or for + :meth:`init_line` or :meth:`init_lines` with lines meshed by the caller. The coupled exchange follows the coupling boundary described in the documentation (https://cabledyn.readthedocs.io): kinematics in @@ -222,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: @@ -302,14 +423,199 @@ def init_deck_rotating( self._check("CableDyn_InitDeckRotating", status.value) self._initialized_from(resolved) - def _initialized_from(self, deck: Path) -> None: - """Book-keeping after a successful deck initialization (the caller holds the lock).""" + def init_line( + self, + q0: ArrayInput, + v0: ArrayInput, + elem_conn: ArrayInput, + l0: ArrayInput, + ea: ArrayInput, + rho_a: ArrayInput, + fixed_dofs: ArrayInput, + tension_only: bool, + rho_inf: float, + ) -> None: + """Initialize from one line meshed by the caller (``CableDyn_InitLine``). + + The model is structural only: two-node ``EI = 0`` axial elements with a + consistent mass matrix, without gravity, buoyancy, seabed, hydrodynamics or + structural damping, and without a static solve: the state starts at ``q0``, + ``v0`` exactly. ``q0`` and ``v0`` are the node positions [m] and velocities + [m/s], flat ``(3 * n_nodes,)`` or column-per-point ``(3, n_nodes)`` (a + ``(3, 3)`` array is rejected as ambiguous). ``elem_conn`` is the **1-based** + node pair of each element, flat ``(2 * n_elem,)`` or ``(n_elem, 2)``. + ``l0``, ``ea`` and ``rho_a`` are the unstretched length [m], axial stiffness + [N] and mass per unit length [kg/m] of each element, ``(n_elem,)``. + ``fixed_dofs`` lists the **1-based** DOFs the caller prescribes; node ``k`` + owns DOFs ``3k-2, 3k-1, 3k``, and their order is the order of the coupled + vector. A true ``tension_only`` lets an element go slack instead of carrying + compression. ``rho_inf`` is the generalised-alpha spectral radius in + ``[0, 1]``. + + The handle then has no deck and no points: :attr:`deck` is ``None`` and every + coupled DOF is prescribed by the caller. The views of :attr:`lines` run from + the last node of ``q0`` to its first, as they run from End A on a deck line, + whose model holds End B first. + + A wrong shape, a non-finite value or a non-integer index raises + :class:`ValueError` or :class:`TypeError` before the library is called, and + changes nothing. The library judges the values and raises + :class:`CableDynError`. After that error the handle must be initialized + again, and a saved state is lost, unless it held a model on the coupled + aggregate (finite-EI lines, bodies or rods), which stays live with its save. + """ + q, v, conn, length, stiffness, mass, fixed = _raw_line_arrays( + q0, v0, elem_conn, l0, ea, rho_a, fixed_dofs + ) + slack = _as_flag(tension_only, "tension_only") + radius = _as_spectral_radius(rho_inf) + if q.size < 6 or length.size < 1: + raise ValueError( + f"a line needs at least 2 nodes and 1 element; got {q.size // 3} and {length.size}" + ) + with self._lock: + status = c_int() + _lib.CableDyn_InitLine( + self._handle, + q.size // 3, + length.size, + _dbl_ptr(q), + _dbl_ptr(v), + _int_ptr(conn), + _dbl_ptr(length), + _dbl_ptr(stiffness), + _dbl_ptr(mass), + _int_ptr(fixed) if fixed.size else None, + fixed.size, + slack, + radius, + byref(status), + ) + self._finish_raw_init("CableDyn_InitLine", status.value) + + def init_lines( + self, + n_nodes: ArrayInput, + n_elem: ArrayInput, + n_fixed: ArrayInput, + q0: ArrayInput, + v0: ArrayInput, + elem_conn: ArrayInput, + l0: ArrayInput, + ea: ArrayInput, + rho_a: ArrayInput, + fixed_dofs: ArrayInput, + tension_only: bool, + rho_inf: float, + coupled_map: ArrayInput | None = None, + ) -> None: + """Initialize from several lines meshed by the caller (``CableDyn_InitLines``). + + The lines are those of :meth:`init_line`, in one system. ``n_nodes``, + ``n_elem`` and ``n_fixed`` hold the counts of each line; every other array is + the concatenation, in line order, of that line's :meth:`init_line` array. + ``elem_conn`` and ``fixed_dofs`` stay local to each line: node 1 is the first + node of that line. ``tension_only`` and ``rho_inf`` apply to every line. + + Without ``coupled_map`` the lines are independent, and the coupled vector is + their ``fixed_dofs`` in turn. ``coupled_map`` connects lines at shared points: + every line has exactly six fixed DOFs, and entry ``6 * i + j`` (0-based ``i``, + ``j``) is the **1-based** system DOF of line ``i``'s ``j``-th fixed DOF. The + entries use every index from 1 to their maximum, which is the coupled DOF + count; loads and Jacobian entries of a shared DOF are summed. + + Errors are those of :meth:`init_line`. + """ + q, v, conn, length, stiffness, mass, fixed = _raw_line_arrays( + q0, v0, elem_conn, l0, ea, rho_a, fixed_dofs + ) + slack = _as_flag(tension_only, "tension_only") + radius = _as_spectral_radius(rho_inf) + nodes = _as_index_array(n_nodes, "n_nodes") + elements = _as_index_array(n_elem, "n_elem") + fixed_counts = _as_index_array(n_fixed, "n_fixed") + n_lines = nodes.size + if nodes.ndim != 1 or n_lines < 1: + raise ValueError(f"n_nodes must be flat with an entry per line; got {nodes.shape}") + for name, counts in (("n_elem", elements), ("n_fixed", fixed_counts)): + if counts.shape != nodes.shape: + raise ValueError(f"{name} must have {n_lines} entries; got shape {counts.shape}") + if np.any(nodes < 2) or np.any(elements < 1) or np.any(fixed_counts < 0): + raise ValueError( + "every line needs at least 2 nodes, 1 element and 0 fixed DOFs; got " + f"n_nodes {nodes.tolist()}, n_elem {elements.tolist()}, " + f"n_fixed {fixed_counts.tolist()}" + ) + totals = ( + ("q0", q.size, 3 * int(nodes.sum(dtype=np.int64))), + ("l0", length.size, int(elements.sum(dtype=np.int64))), + ("fixed_dofs", fixed.size, int(fixed_counts.sum(dtype=np.int64))), + ) + for name, size, expected in totals: + if size != expected: + raise ValueError(f"{name} must hold {expected} values in all; got {size}") + shared = _as_index_array((), "coupled_map") + if coupled_map is not None: + shared = _as_index_array(coupled_map, "coupled_map") + if shared.size: + if shared.shape != (6 * n_lines,): + raise ValueError( + f"coupled_map must be flat ({6 * n_lines},); got shape {shared.shape}" + ) + if np.any(fixed_counts != 6): + raise ValueError("coupled_map needs exactly six fixed DOFs on every line") + with self._lock: + status = c_int() + _lib.CableDyn_InitLines( + self._handle, + n_lines, + _int_ptr(nodes), + _int_ptr(elements), + _int_ptr(fixed_counts), + _dbl_ptr(q), + _dbl_ptr(v), + _int_ptr(conn), + _dbl_ptr(length), + _dbl_ptr(stiffness), + _dbl_ptr(mass), + _int_ptr(fixed) if fixed.size else None, + _int_ptr(shared) if shared.size else None, + shared.size, + slack, + radius, + byref(status), + ) + self._finish_raw_init("CableDyn_InitLines", status.value) + + def _finish_raw_init(self, call: str, status: int) -> None: + """Book-keeping after InitLine or InitLines (the caller holds the lock). + + A failed call can leave the earlier model live, whose time and views then stand. + """ + if status != 0: + message = self._last_error() + if not _lib.CableDyn_IsInitialized(self._handle): + self._initialized_from(None) + raise CableDynError(call, status, message) + self._initialized_from(None) + + def _initialized_from(self, deck: Path | None) -> None: + """Book-keeping after a successful initialization (the caller holds the lock).""" self._generation += 1 self._views_valid = False self._time = 0.0 self._saved_time = None self._deck = deck + @staticmethod + def _require_host_fluid() -> None: + if _lib_module.abi_minor() < _lib_module.HOST_FLUID_ABI_MINOR: + # not a C status: the loaded library lacks the entry points + raise RuntimeError( + "the loaded CableDyn library predates the host fluid calls " + "(ABI 1 minor extension 4); rebuild it from this checkout" + ) + @staticmethod def _require_host_rotation() -> None: if _lib_module.abi_minor() < _lib_module.HOST_ROTATION_ABI_MINOR: @@ -550,6 +856,60 @@ def calc_output(self) -> FloatArray: self._check("CableDyn_CalcOutput", status.value) return loads + def calc_output_derivatives( + self, q: ArrayInput, v: ArrayInput, a: ArrayInput + ) -> tuple[FloatArray, FloatArray, FloatArray, FloatArray, FloatArray]: + """The coupled loads and their analytic Jacobians at an operating point. + + ``q``, ``v`` and ``a`` are the operating point, as in :meth:`update_states`. + Returns ``(loads, dload_dq, dload_dv, dload_da, added_mass)``: ``loads`` is + ``(n_dof,)`` [N], and the others are ``(n_dof, n_dof)`` with ``J[i, j]`` the + derivative of load ``i`` with respect to coupled DOF ``j``, in N/m, N s/m and + kg; ``added_mass`` is ``-dload_da``. + + Each line's interior DOFs are condensed out at the operating point: interior + positions and velocities are held, and interior accelerations are eliminated + through the mass matrix, so ``dload_da`` is + ``-(M_cc - M_cf inv(M_ff) M_fc)``. The Jacobians are therefore those of + :meth:`update_states` followed by :meth:`calc_output`, the instantaneous + response of the end loads to the end motion. + + This call **sets the model state**, as :meth:`update_states` does, also when + a later stage of it fails; :attr:`time` stays. Call :meth:`update_states` with + the previous kinematics to return to them. A model on the coupled aggregate + (finite-EI lines, bodies or rods) has no load derivatives and raises + :class:`CableDynError`. + """ + with self._lock: + n = self.n_coupled_dof + qa = _as_dof_array(q, n, "q") + va = _as_dof_array(v, n, "v") + aa = _as_dof_array(a, n, "a") + loads = np.zeros(n) + # The library writes J[i + j*n]: column-major, so J[i, j] reads as it should. + dload_dq = np.zeros((n, n), order="F") + dload_dv = np.zeros((n, n), order="F") + dload_da = np.zeros((n, n), order="F") + added_mass = np.zeros((n, n), order="F") + status = c_int() + # The library's eps_fd argument is unused: the blocks are analytic. + _lib.CableDyn_CalcOutputDerivatives( + self._handle, + _dbl_ptr(qa) if n else None, + _dbl_ptr(va) if n else None, + _dbl_ptr(aa) if n else None, + n, + 0.0, + _dbl_ptr(loads) if n else None, + _dbl_ptr(dload_dq) if n else None, + _dbl_ptr(dload_dv) if n else None, + _dbl_ptr(dload_da) if n else None, + _dbl_ptr(added_mass) if n else None, + byref(status), + ) + self._check("CableDyn_CalcOutputDerivatives", status.value) + return loads, dload_dq, dload_dv, dload_da, added_mass + # -- host coupling: outer correctors and the coupled layout ------------------- def save_state(self) -> None: @@ -687,6 +1047,83 @@ def host_dofs(self) -> npt.NDArray[np.intp]: blocks = [k for k, (_, kind) in enumerate(layout) if kind == "coupled"] return np.array([3 * k + c for k in blocks for c in range(3)], dtype=np.intp) + def set_host_fluid(self, enable: bool = True) -> None: + """Choose, before a deck initialization, whether the host supplies the fluid. + + With ``enable`` the following :meth:`init_deck`, :meth:`init_deck_at` and + :meth:`init_deck_rotating` take the field of :meth:`update_fluid_fields`: + deck waves are refused, and a steady deck current sets the static start + only. The deck needs ``dtM`` and a seabed. + """ + with self._lock: + self._require_host_fluid() + status = c_int() + _lib.CableDyn_SetHostFluid(self._handle, 1 if enable else 0, byref(status)) + self._check("CableDyn_SetHostFluid", status.value) + + @property + def n_fluid_nodes(self) -> int: + """Nodes of the host fluid field; zero without one.""" + with self._lock: + self._require_host_fluid() + status = c_int() + n = _lib.CableDyn_NFluidNodes(self._handle, byref(status)) + self._check("CableDyn_NFluidNodes", status.value) + return int(n) + + def fluid_node_positions(self) -> FloatArray: + """Committed fluid node positions ``(3, n_fluid_nodes)``, global axes [m]. + + Where the host evaluates the field of the coming step. + """ + with self._lock: + n = self.n_fluid_nodes + xyz = np.zeros(3 * n) + status = c_int() + _lib.CableDyn_GetFluidNodePositions( + self._handle, _dbl_ptr(xyz) if n else None, n, byref(status) + ) + self._check("CableDyn_GetFluidNodePositions", status.value) + return xyz.reshape(n, 3).T.copy() + + def update_fluid_fields( + self, + fluid_velocity: ArrayInput, + fluid_acceleration: ArrayInput, + waterline_z: ArrayInput, + dynamic_pressure: ArrayInput | None = None, + ) -> None: + """Prescribe the ambient fluid at every fluid node, held until the next call. + + ``fluid_velocity`` and ``fluid_acceleration`` are ``(3, n_fluid_nodes)`` in + global axes, ``waterline_z`` the free-surface elevation ``(n_fluid_nodes,)``, + ``dynamic_pressure`` the wave pressure ``(n_fluid_nodes,)`` rods use for their + end caps. Call it once with the field at ``t = 0`` before the first step, then + before each step from ``t`` to ``t + dt`` with the field at ``t + dt`` at the + positions of :meth:`fluid_node_positions`. The density is the deck's. + """ + with self._lock: + n = self.n_fluid_nodes + fv = _as_field(fluid_velocity, n, "fluid_velocity") + fa = _as_field(fluid_acceleration, n, "fluid_acceleration") + wl = _as_node_values(waterline_z, n, "waterline_z") + dp = ( + None + if dynamic_pressure is None + else _as_node_values(dynamic_pressure, n, "dynamic_pressure") + ) + status = c_int() + _lib.CableDyn_UpdateFluidFields( + self._handle, + _dbl_ptr(fv) if n else None, + _dbl_ptr(fa) if n else None, + _dbl_ptr(wl) if n else None, + _dbl_ptr(dp) if dp is not None and n else None, + n, + byref(status), + ) + self._check("CableDyn_UpdateFluidFields", status.value) + def update_point_fluid_fields( self, fluid_velocity: ArrayInput, diff --git a/python/cabledyn/objects.py b/python/cabledyn/objects.py index c8078548..12a8c0b3 100644 --- a/python/cabledyn/objects.py +++ b/python/cabledyn/objects.py @@ -54,6 +54,7 @@ "declination", "azimuth", "segment_tension", + "section_curvature", ] _LINE_QUANTITIES: dict[str, tuple[int, int]] = { "position": (1, 3), @@ -65,6 +66,7 @@ "declination": (7, 1), "azimuth": (8, 1), "segment_tension": (9, 1), + "section_curvature": (11, 2), } #: C ABI code of the deck node of each mesh node (``CD_C_LINE_DECK_NODE``). @@ -219,7 +221,7 @@ def values(self, quantity: LineQuantity, out: FloatArray | None = None) -> Float ) code, width = _LINE_QUANTITIES[quantity] count = self.n_segments if quantity == "segment_tension" else self.n_nodes - shape = (count, 3) if width == 3 else (count,) + shape = (count, width) if width > 1 else (count,) buf = _buffer(out, shape) with self._model._lock: handle = self._handle() @@ -258,6 +260,14 @@ def curvature(self, out: FloatArray | None = None) -> FloatArray: """Curvature at each node ``(n_nodes,)`` [1/m] (``CurvN``).""" return self.values("curvature", out) + def section_curvature(self, out: FloatArray | None = None) -> FloatArray: + """Curvature on the section axes at each node ``(n_nodes, 2)`` [1/m]. + + Columns ``CurvXN`` and ``CurvYN``, on a finite-EI line; one evaluation + for the whole line, so it is much cheaper than a channel per node. + """ + return self.values("section_curvature", out) + def bend_moment(self, out: FloatArray | None = None) -> FloatArray: """Bend moment magnitude at each node ``(n_nodes,)`` [N-m]; zero when EI = 0.""" return self.values("bend_moment", out) @@ -267,7 +277,10 @@ def declination(self, out: FloatArray | None = None) -> FloatArray: return self.values("declination", out) def azimuth(self, out: FloatArray | None = None) -> FloatArray: - """Azimuth at each node ``(n_nodes,)`` [deg] (``LNAzi``).""" + """Azimuth at each node ``(n_nodes,)`` [deg] (``LNAzi``), in the line's range. + + (-180, 180] on a line whose deck chord heads within 45 deg of +X, otherwise [0, 360). + """ return self.values("azimuth", out) def arc_length(self) -> FloatArray: diff --git a/python/cabledyn/results.py b/python/cabledyn/results.py index 4c766ac1..02c3bcef 100644 --- a/python/cabledyn/results.py +++ b/python/cabledyn/results.py @@ -1197,6 +1197,29 @@ def plot_envelope( return ax +def _check_positive_integers(table: OutputTable, channels: tuple[str, ...]) -> None: + """Refuse a table whose identifier columns are not positive integers.""" + for channel in channels: + values = table.column(channel) + if np.any(values <= 0.0) or np.any(values != np.floor(values)): + raise OutputFormatError(f"{table.path}: {channel} values must be positive integers") + + +def _check_contiguous_lines(table: OutputTable) -> None: + """Refuse a table whose rows of one line are split by rows of another.""" + seen: set[int] = set() + active: int | None = None + for value in table.column("LineID"): + identifier = int(value) + if identifier != active: + if identifier in seen: + raise OutputFormatError( + f"{table.path}: LineID {identifier} rows are not contiguous" + ) + seen.add(identifier) + active = identifier + + @dataclass(frozen=True) class StaticProfile(OutputTable): """One or more CableDyn line profiles indexed by line and arc length. @@ -1236,21 +1259,8 @@ def __post_init__(self) -> None: raise OutputFormatError( f"{self.path}: static profile missing {', '.join(sorted(missing))}" ) - for channel in ("LineID", "Node"): - values = self.column(channel) - if np.any(values <= 0.0) or np.any(values != np.floor(values)): - raise OutputFormatError(f"{self.path}: {channel} values must be positive integers") - seen: set[int] = set() - active: int | None = None - for value in self.column("LineID"): - identifier = int(value) - if identifier != active: - if identifier in seen: - raise OutputFormatError( - f"{self.path}: LineID {identifier} rows are not contiguous" - ) - seen.add(identifier) - active = identifier + _check_positive_integers(self, ("LineID", "Node")) + _check_contiguous_lines(self) for line_id in self.line_ids: mask = self.column("LineID") == line_id arc = self.column("ArcLength")[mask] @@ -1515,8 +1525,432 @@ def plot_geometry( return ax +@dataclass(frozen=True) +class ModeShapes(OutputTable): + """The mode-shape table of a ``.modes.out`` file. + + A subclass of :class:`cabledyn.OutputTable` holding one row per line, mode + and node, with the columns ``LineID``, ``Mode``, ``Node``, ``X``, ``Y``, + ``Z``, ``dX``, ``dY``, and ``dZ``, and ``dTwist`` in a file that carries + twist angles. It is read as :attr:`cabledyn.NaturalModes.shapes`. + + ``X``, ``Y``, ``Z`` are the static node positions in m. ``dX``, ``dY``, + ``dZ`` are the nodal translations of the mode, scaled so that its largest + nodal displacement is 1. ``dTwist`` is the rotation of the section about + the line tangent, positive right-handed from End A to End B, on the same + scale; a torsional mode is scaled to a unit largest ``dTwist`` instead. + Nodes are numbered from End A. The attributes are those of + :class:`cabledyn.OutputTable`. + + Raises + ------ + OutputFormatError + If a required column is missing, ``LineID``, ``Mode``, or ``Node`` + values are not positive integers, a line's rows are not contiguous, + they are not the nodes 1, 2, ... of the modes 1, 2, ... in order, or + the static position of a node differs between modes. + """ + + def __post_init__(self) -> None: + super().__post_init__() + required = {"LineID", "Mode", "Node", "X", "Y", "Z", "dX", "dY", "dZ"} + missing = required.difference(self.channels) + if missing: + raise OutputFormatError( + f"{self.path}: mode-shape table missing {', '.join(sorted(missing))}" + ) + _check_positive_integers(self, ("LineID", "Mode", "Node")) + _check_contiguous_lines(self) + static = np.column_stack([self.column(axis) for axis in ("X", "Y", "Z")]) + for line_id in self.line_ids: + mask = self.column("LineID") == line_id + mode = self.column("Mode")[mask] + node = self.column("Node")[mask] + n_modes = int(np.count_nonzero(node == 1.0)) + n_nodes = mode.size // n_modes if n_modes else 0 + if not ( + np.array_equal(mode, np.repeat(np.arange(1, n_modes + 1), n_nodes)) + and np.array_equal(node, np.tile(np.arange(1, n_nodes + 1), n_modes)) + ): + raise OutputFormatError( + f"{self.path}: LineID {line_id} rows are not the nodes 1, 2, ... " + "of the modes 1, 2, ..." + ) + positions = static[mask].reshape(n_modes, n_nodes, 3) + if np.any(positions != positions[0]): + raise OutputFormatError( + f"{self.path}: LineID {line_id} static positions differ between modes" + ) + + @property + def line_ids(self) -> tuple[int, ...]: + """Line identifiers in first-appearance order.""" + return tuple(dict.fromkeys(int(value) for value in self.column("LineID"))) + + def _line_rows(self, line_id: int) -> npt.NDArray[np.bool_]: + mask: npt.NDArray[np.bool_] = self.column("LineID") == int(line_id) + if not np.any(mask): + raise KeyError( + f"LineID {line_id} is not in {self.path.name}; available: {self.line_ids}" + ) + return mask + + def _mode_rows(self, line_id: int, mode: int) -> npt.NDArray[np.bool_]: + mask: npt.NDArray[np.bool_] = self._line_rows(line_id) & (self.column("Mode") == int(mode)) + if not np.any(mask): + raise KeyError(f"LineID {line_id} has no mode {mode} in {self.path.name}") + return mask + + def line(self, line_id: int) -> ModeShapes: + """Return the rows belonging to one line identifier. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + + Returns + ------- + ModeShapes + A new table holding only that line's rows. + + Raises + ------ + KeyError + If the table has no rows for ``line_id``. + """ + return ModeShapes( + self.path, + self.title, + self.channels, + self.units, + self.values[self._line_rows(line_id)].copy(), + ) + + def node_ids(self, line_id: int) -> tuple[int, ...]: + """Return the node numbers of one line, from End A. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + + Returns + ------- + tuple[int, ...] + Node numbers ``1, 2, ...``. + + Raises + ------ + KeyError + If the table has no rows for ``line_id``. + """ + return tuple(int(value) for value in self.column("Node")[self._mode_rows(line_id, 1)]) + + def positions(self, line_id: int) -> npt.NDArray[Any]: + """Return the static node positions of one line. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + + Returns + ------- + numpy.ndarray + ``(n_nodes, 3)`` array of ``X``, ``Y``, ``Z`` in m, from End A. + + Raises + ------ + KeyError + If the table has no rows for ``line_id``. + """ + rows = self._mode_rows(line_id, 1) + return np.column_stack([self.column(axis)[rows] for axis in ("X", "Y", "Z")]) + + def displacements(self, line_id: int, mode: int) -> npt.NDArray[Any]: + """Return the nodal translations of one mode. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + mode : int + One-based mode number, as in the ``Mode`` column. + + Returns + ------- + numpy.ndarray + ``(n_nodes, 3)`` array of ``dX``, ``dY``, ``dZ``, from End A. + + Raises + ------ + KeyError + If the table does not hold that mode of that line. + """ + rows = self._mode_rows(line_id, mode) + return np.column_stack([self.column(axis)[rows] for axis in ("dX", "dY", "dZ")]) + + def twist(self, line_id: int, mode: int) -> npt.NDArray[Any]: + """Return the nodal twist of one mode. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + mode : int + One-based mode number, as in the ``Mode`` column. + + Returns + ------- + numpy.ndarray + ``(n_nodes,)`` array of ``dTwist``, from End A; zeros in a file + without twist angles, as on a line without torsion. + + Raises + ------ + KeyError + If the table does not hold that mode of that line. + """ + rows = self._mode_rows(line_id, mode) + if "dTwist" not in self.channels: + return np.zeros(int(np.count_nonzero(rows))) + return self.column("dTwist")[rows].copy() + + +@dataclass(frozen=True) +class NaturalModes(OutputTable): + """Natural frequencies and mode shapes of a ``.modes.out`` file. + + A subclass of :class:`cabledyn.OutputTable` whose own table is the frequency + table, one row per line and mode, with the columns ``LineID``, ``Mode``, + ``Frequency`` (Hz), ``Period`` (s), and ``Omega`` (rad/s), and + ``PolarShare`` in a file that carries twist angles. The mode-shape table is + :attr:`shapes`. + + A negative eigenvalue is recorded with a negative ``Frequency`` and + ``Omega`` and a ``Period`` of 0. A line without free nodes has no modes and + no rows, so a file of such lines reads as two empty tables. + + Attributes + ---------- + shapes : ModeShapes + The mode-shape table of the same file. + + Raises + ------ + OutputFormatError + If a required column is missing, ``LineID`` or ``Mode`` values are not + positive integers, a line's rows are not contiguous or its modes are + not numbered 1, 2, ..., only one table carries the twist columns, or + the two tables do not hold the same modes. + """ + + shapes: ModeShapes + + def __post_init__(self) -> None: + super().__post_init__() + missing = {"LineID", "Mode", "Frequency", "Period", "Omega"}.difference(self.channels) + if missing: + raise OutputFormatError( + f"{self.path}: frequency table missing {', '.join(sorted(missing))}" + ) + _check_positive_integers(self, ("LineID", "Mode")) + _check_contiguous_lines(self) + if self.has_twist != ("dTwist" in self.shapes.channels): + raise OutputFormatError( + f"{self.path}: PolarShare and dTwist must appear together in the two tables" + ) + if self.line_ids != self.shapes.line_ids: + raise OutputFormatError(f"{self.path}: the two tables do not hold the same lines") + shape_line = self.shapes.column("LineID") + shape_mode = self.shapes.column("Mode") + for line_id in self.line_ids: + mode = self.column("Mode")[self.column("LineID") == line_id] + if np.any(mode != np.arange(1, mode.size + 1)): + raise OutputFormatError( + f"{self.path}: LineID {line_id} modes are not numbered 1, 2, ..." + ) + n_shapes = int(shape_mode[shape_line == line_id].max()) + if n_shapes != mode.size: + raise OutputFormatError( + f"{self.path}: LineID {line_id} has {mode.size} frequency rows " + f"but {n_shapes} mode shapes" + ) + + @property + def line_ids(self) -> tuple[int, ...]: + """Line identifiers in first-appearance order.""" + return tuple(dict.fromkeys(int(value) for value in self.column("LineID"))) + + @property + def has_twist(self) -> bool: + """Whether the file carries the ``PolarShare`` and ``dTwist`` columns.""" + return "PolarShare" in self.channels + + def _line_column(self, channel: str, line_id: int) -> npt.NDArray[Any]: + mask = self.column("LineID") == int(line_id) + if not np.any(mask): + raise KeyError( + f"LineID {line_id} is not in {self.path.name}; available: {self.line_ids}" + ) + values: npt.NDArray[Any] = self.column(channel)[mask].copy() + return values + + def line(self, line_id: int) -> NaturalModes: + """Return the frequency and shape rows of one line. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + + Returns + ------- + NaturalModes + New tables holding only that line's rows. + + Raises + ------ + KeyError + If the file has no modes for ``line_id``. + """ + shapes = self.shapes.line(line_id) + rows = self.values[self.column("LineID") == int(line_id)].copy() + return NaturalModes(self.path, self.title, self.channels, self.units, rows, shapes) + + def mode_numbers(self, line_id: int) -> tuple[int, ...]: + """Return the mode numbers of one line, lowest frequency first. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + + Returns + ------- + tuple[int, ...] + Mode numbers ``1, 2, ...``. + + Raises + ------ + KeyError + If the file has no modes for ``line_id``. + """ + return tuple(int(value) for value in self._line_column("Mode", line_id)) + + def frequencies(self, line_id: int) -> npt.NDArray[Any]: + """Return the natural frequencies of one line in Hz, by mode. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + + Returns + ------- + numpy.ndarray + ``(n_modes,)`` array of ``Frequency``. + + Raises + ------ + KeyError + If the file has no modes for ``line_id``. + """ + return self._line_column("Frequency", line_id) + + def periods(self, line_id: int) -> npt.NDArray[Any]: + """Return the natural periods of one line in s, by mode. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + + Returns + ------- + numpy.ndarray + ``(n_modes,)`` array of ``Period``; 0 for a negative eigenvalue. + + Raises + ------ + KeyError + If the file has no modes for ``line_id``. + """ + return self._line_column("Period", line_id) + + def angular_frequencies(self, line_id: int) -> npt.NDArray[Any]: + """Return the angular natural frequencies of one line in rad/s, by mode. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + + Returns + ------- + numpy.ndarray + ``(n_modes,)`` array of ``Omega``. + + Raises + ------ + KeyError + If the file has no modes for ``line_id``. + """ + return self._line_column("Omega", line_id) + + def polar_shares(self, line_id: int) -> npt.NDArray[Any]: + """Return the polar share of the kinetic energy of each mode of one line. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + + Returns + ------- + numpy.ndarray + ``(n_modes,)`` array of ``PolarShare``, 1 for a pure torsional mode; + zeros in a file without twist angles, as on a line without torsion. + + Raises + ------ + KeyError + If the file has no modes for ``line_id``. + """ + if self.has_twist: + return self._line_column("PolarShare", line_id) + return np.zeros_like(self._line_column("Mode", line_id)) + + def torsional_modes(self, line_id: int) -> tuple[int, ...]: + """Return the modes of one line that are mostly torsional. + + Parameters + ---------- + line_id : int + Line identifier, as in the ``LineID`` column. + + Returns + ------- + tuple[int, ...] + Mode numbers whose ``PolarShare`` is above 0.5, the modes a file + scales to a unit largest ``dTwist``. + + Raises + ------ + KeyError + If the file has no modes for ``line_id``. + """ + share = self.polar_shares(line_id) + return tuple(int(mode) for mode in self._line_column("Mode", line_id)[share > 0.5]) + + _HEADER_FIRST = frozenset({"time", "time(s)", "lineid", "node", "segment"}) _TIME_HEADERS = frozenset({"time", "time(s)"}) +# The first columns of the frequency table of a ``.modes.out`` file, and the comment that +# introduces its mode-shape table. +_MODE_FREQUENCY_CHANNELS = ("LineID", "Mode", "Frequency", "Period", "Omega") +_MODE_SHAPE_COMMENT = "# Mode shapes" # Fortran formatted real output: optional sign, digits with an optional decimal # point, and an optional exponent. ``ES``/``E`` editing omits the exponent @@ -1636,6 +2070,76 @@ def _unique_channels(channels: tuple[str, ...], source: str) -> tuple[str, ...]: return tuple(unique) +def _read_units( + table_path: Path, lines: list[str], cursor: int, channels: tuple[str, ...] +) -> tuple[tuple[str, ...] | None, int]: + """Return the units row at ``cursor``, if there is one, and the index of the first data row.""" + if cursor < len(lines): + tokens = tuple(lines[cursor].split()) + if tokens and all(token.startswith("(") and token.endswith(")") for token in tokens): + if len(tokens) != len(channels): + raise OutputFormatError( + f"{table_path}:{cursor + 1}: expected {len(channels)} units, got {len(tokens)}" + ) + return tokens, cursor + 1 + return None, cursor + + +def _read_rows( + table_path: Path, lines: list[str], start: int, stop: int, channels: tuple[str, ...] +) -> list[list[float]]: + """Parse the data rows ``lines[start:stop]``, one finite value per channel.""" + rows: list[list[float]] = [] + for i in range(start, stop): + fields = lines[i].split() + if not fields: + continue + if len(fields) != len(channels): + raise OutputFormatError( + f"{table_path}:{i + 1}: expected {len(channels)} fields, got {len(fields)}" + ) + rows.append([_parse_float(field, table_path, i + 1) for field in fields]) + return rows + + +def _read_natural_modes( + table_path: Path, + lines: list[str], + header_index: int, + channels: tuple[str, ...], + title: str, +) -> NaturalModes: + """Read the frequency table at ``header_index`` and the mode-shape table after it.""" + units, cursor = _read_units(table_path, lines, header_index + 1, channels) + comment = next( + (i for i in range(cursor, len(lines)) if lines[i].startswith(_MODE_SHAPE_COMMENT)), + None, + ) + if comment is None or comment + 1 >= len(lines): + raise OutputFormatError(f"{table_path}: modal output has no mode-shape table") + rows = _read_rows(table_path, lines, cursor, comment, channels) + shape_channels = tuple(lines[comment + 1].split()) + if len(shape_channels) != len(set(shape_channels)): + raise OutputFormatError(f"{table_path}:{comment + 2}: duplicate channel name") + shape_units, shape_cursor = _read_units(table_path, lines, comment + 2, shape_channels) + shape_rows = _read_rows(table_path, lines, shape_cursor, len(lines), shape_channels) + shapes = ModeShapes( + table_path, + lines[comment], + shape_channels, + shape_units, + np.asarray(shape_rows, dtype=np.float64).reshape(-1, len(shape_channels)), + ) + return NaturalModes( + table_path, + title, + channels, + units, + np.asarray(rows, dtype=np.float64).reshape(-1, len(channels)), + shapes, + ) + + def read_output(path: str | os.PathLike[str]) -> OutputTable: """Read and strictly validate a CableDyn numeric output table. @@ -1654,8 +2158,9 @@ def read_output(path: str | os.PathLike[str]) -> OutputTable: ------- OutputTable A :class:`TimeHistory`, :class:`LineNodeHistory`, - :class:`LineSegmentHistory`, or :class:`StaticProfile` when the layout - is recognized, otherwise a plain :class:`OutputTable`. + :class:`LineSegmentHistory`, :class:`StaticProfile`, or + :class:`NaturalModes` when the layout is recognized, otherwise a plain + :class:`OutputTable`. Raises ------ @@ -1681,28 +2186,10 @@ def read_output(path: str | os.PathLike[str]) -> OutputTable: elif len(channels) != len(set(channels)): raise OutputFormatError(f"{table_path}:{header_index + 1}: duplicate channel name") title = "\n".join(line for line in lines[:header_index] if line.strip()) - cursor = header_index + 1 - units: tuple[str, ...] | None = None - if cursor < len(lines): - tokens = tuple(lines[cursor].split()) - if tokens and all(token.startswith("(") and token.endswith(")") for token in tokens): - if len(tokens) != len(channels): - raise OutputFormatError( - f"{table_path}:{cursor + 1}: expected {len(channels)} units, got {len(tokens)}" - ) - units = tokens - cursor += 1 - - rows: list[list[float]] = [] - for i in range(cursor, len(lines)): - fields = lines[i].split() - if not fields: - continue - if len(fields) != len(channels): - raise OutputFormatError( - f"{table_path}:{i + 1}: expected {len(channels)} fields, got {len(fields)}" - ) - rows.append([_parse_float(field, table_path, i + 1) for field in fields]) + if channels[: len(_MODE_FREQUENCY_CHANNELS)] == _MODE_FREQUENCY_CHANNELS: + return _read_natural_modes(table_path, lines, header_index, channels, title) + units, cursor = _read_units(table_path, lines, header_index + 1, channels) + rows = _read_rows(table_path, lines, cursor, len(lines), channels) if not rows: raise OutputFormatError(f"{table_path}: table has no data rows") values = np.asarray(rows, dtype=np.float64) diff --git a/python/pyproject.toml b/python/pyproject.toml index d487c85f..88a70094 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "cabledyn" -version = "0.4.0" +version = "0.4.1" description = "Python tools for CableDyn, a geometrically exact finite-element solver for mooring lines and dynamic power cables" readme = "README.md" requires-python = ">=3.10" diff --git a/python/tests/test_deck_file.py b/python/tests/test_deck_file.py index bac4331f..25fea080 100644 --- a/python/tests/test_deck_file.py +++ b/python/tests/test_deck_file.py @@ -834,7 +834,7 @@ def _vessel_source(rows: str) -> str: def test_vessel_motion_options_parse_and_rebase(tmp_path): source = _vessel_source("vessel.dat vesselMotion\n80.0|0.0|-10.0 vesselRef") - path = tmp_path / "vessel.dat" + path = tmp_path / "deck.dat" path.write_text(source, encoding="utf-8") deck = DeckFile.read(path) @@ -1924,7 +1924,7 @@ def test_host_route_does_not_relax_deck_owned_kinematics_clock(tmp_path, option_ with pytest.raises(DeckFormatError, match=message) as err: DeckFile.read(path, caller_driven=caller_driven) # the standalone error says how to check the deck's static start; the host route's not - assert ("give dtM with TMax = 0" in str(err.value)) is not caller_driven + assert ("give TMax = 0" in str(err.value)) is not caller_driven def test_current_needs_the_deck_clock_only_on_the_standalone_route(tmp_path): @@ -2210,7 +2210,10 @@ def test_case_generation_rebases_relative_ancillary_paths(tmp_path): bathymetry = source_directory / "data" / "bathy_profile.dat" bathymetry.parent.mkdir() - bathymetry.write_text("placeholder\n", encoding="utf-8") + bathymetry.write_text( + "".join(f"{x} {y} 100.0\n" for x in (-2000.0, 2000.0) for y in (-2000.0, 2000.0)), + encoding="utf-8", + ) deck_text = (ROOT / "examples/chain_catenary_r3_100m.dat").read_text(encoding="utf-8") deck_text = deck_text.replace("100.0 WtrDpth", "data/bathy_profile.dat bathymetryFile") deck_path = source_directory / "base.dat" @@ -2547,6 +2550,29 @@ def test_range_flag_and_range_start_follow_native_rules(): assert DeckFile.from_text(_edit(_STATIC, ("1 2 1 -\n", "1 2 1 r\n"))).lines[0].tokens[3] == "r" +def test_coupled_range_flag_and_range_start_take_the_host_clock(): + # the host owns the run time: no deck TMax is needed, and one is not a bound on RangeStart + no_tmax = _edit( + _STATIC, + ("1 2 1 -\n", "1 2 1 r\n"), + ("50.0 WtrDpth\n", "50.0 WtrDpth\n100.0 RangeStart\n"), + ) + deck = DeckFile.from_text(no_tmax, caller_driven=True) + assert deck.option("RangeStart").values == ("100.0",) + assert deck.lines[0].tokens[3] == "r" + deck_tmax = _edit( + _DYNAMIC, ("1 2 1 -\n", "1 2 1 r\n"), ("10.0 TMax\n", "10.0 TMax\n12.0 RangeStart\n") + ) + with pytest.raises(DeckFormatError, match="exceeds TMax"): + DeckFile.from_text(deck_tmax) + assert DeckFile.from_text(deck_tmax, caller_driven=True).option("RangeStart").values == ( + "12.0", + ) + # the flag is still what RangeStart needs + with pytest.raises(DeckFormatError, match="needs a line with the LINES Outputs flag r"): + DeckFile.from_text(_edit(no_tmax, ("1 2 1 r\n", "1 2 1 -\n")), caller_driven=True) + + def test_fixtures_are_valid_decks(): static = DeckFile.from_text(_STATIC) dynamic = DeckFile.from_text(_DYNAMIC) diff --git a/python/tests/test_deck_native_parity.py b/python/tests/test_deck_native_parity.py index c51115fa..f968114a 100644 --- a/python/tests/test_deck_native_parity.py +++ b/python/tests/test_deck_native_parity.py @@ -12,6 +12,7 @@ import json import os import re +import shutil import subprocess from pathlib import Path @@ -86,6 +87,671 @@ def _rejects(text: str, message: str, *, caller_driven: bool = False) -> None: # --------------------------------------------------------------------------- options +_WATERKIN = ( + "MoorDyn v2 water kinematics file\n(header line 2)\n--- WAVES ---\n" + '{mode} WaveKinMod\n"eta.txt" WaveKinFile\n0.25 dtWave\n37.0 WaveDir\n' + "0 X\n0.0\n0 Y\n0.0\n0 Z\n0.0\n--- CURRENT ---\n0 CurrentMod\n0 Z\n0.0\n" + "--- need this line ---\n" +) +_ETA = "time elevation\n" + "".join( + f"{0.5 * i:g} {0.4 * (-1.0) ** (i // 4):g}\n" for i in range(41) +) +_RAO = (EXAMPLES / "data" / "vessel" / "sample_rao.txt").read_text(encoding="utf-8") +_ROWS = "0 0 0 0\n0.5 0.5 0 0.3\n0.6 0.3 0.1 2.4\n0.6 0.2 -0.1 -1.0\n" + + +def _waterkin(mode: str) -> dict[str, str]: + return {"wk.dat": _WATERKIN.format(mode=mode), "eta.txt": _ETA} + + +# (rows of the deck, files next to it): every case is judged by DeckFile and by the driver +_WAVE_RECORD_CASES = [ + # a reference point needs component waves + (("12.5 WaveRefX",), {}), + (("0 WaveRefY",), {}), + (("airy 2 8 0 waves", "1 WaveRefX"), {}), + (("airy 2 8 0 wavetrain", "1 WaveRefY"), {}), + (("7 WaterKin", "12.5 WaveRefX", "-3 WaveRefY"), {"wave_frequencies.txt": _ROWS}), + (("7 WaterKin", "1.0e6 WaveRefX"), {"wave_frequencies.txt": _ROWS}), + (("7 WaterKin", "1.0000001e6 WaveRefX"), {"wave_frequencies.txt": _ROWS}), + (("7 WaterKin", "-2e6 WaveRefY"), {"wave_frequencies.txt": _ROWS}), + (("7 WaterKin", "nan WaveRefY"), {"wave_frequencies.txt": _ROWS}), + (("7 WaterKin", "north WaveRefY"), {"wave_frequencies.txt": _ROWS}), + (("7 WaterKin", "1e-320 WaveRefX"), {"wave_frequencies.txt": _ROWS}), + (("7 WaterKin", "2e6 WaveRefX", "1 WaveRefX"), {"wave_frequencies.txt": _ROWS}), + (("7 WaterKin", "1 WAVEREFX"), {"wave_frequencies.txt": _ROWS}), + # the WaveKinMod of a WaterKin file decides + (("wk.dat WaterKin", "12.5 WaveRefX"), _waterkin("1")), + (("wk.dat WaterKin", "12.5 WaveRefX"), _waterkin("0")), + (("wk.dat WaterKin", "0 WaveRefY"), _waterkin("0")), + (("wk.dat WaterKin", "12.5 WaveRefX"), _waterkin("2")), + (("wk.dat WaterKin", "12.5 WaveRefX"), _waterkin("SEASTATE")), + (("wk.dat WaterKin", "12.5 WaveRefX"), _waterkin("3")), + (("wk.dat WaterKin", "12.5 WaveRefX"), _waterkin("one")), + (("wk.dat WaterKin", "12.5 WaveRefX"), {"wk.dat": "header\nheader\n"}), + (("wk.dat WaterKin", "12.5 WaveRefX"), {"wk.dat": "header\nheader\nwaves\n"}), + (("wk.dat WaterKin", "12.5 WaveRefX"), {"wk.dat": "header\nheader\nwaves\n\n"}), + (("wk.dat WaterKin", "rao.txt vesselRAO"), {**_waterkin("0"), "rao.txt": _RAO}), + (("wk.dat WaterKin", "rao.txt vesselRAO"), {**_waterkin("1"), "rao.txt": _RAO}), + # the rows of wave_frequencies.txt + (("7 WaterKin",), {"wave_frequencies.txt": _ROWS}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 1.0\n0.5 0.5 0 0.3\n\n0.6 0.3 0.1 0.3\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0\n0.5 0.5 0\n0.6 0.3 0.1 -6.2\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n0.5 0.5 0 6.3\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 -6.3\n0.5 0.5 0 0\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n0.5 0.5 0 east\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n0.5 0.5 0 nan\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n0.5 nan 0 0\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n0.5 0.5\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "omega Re Im\n0 0 0 0\n0.5 0.5 0 0\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0.1 0 0 0\n0.5 0.5 0 0\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n0.5 0.5 0 0\n0 0.5 0 0\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n0.5 2000 0 0\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n2e4 0.1 0 0\n"}), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 -60 0\n0.5 0.5 0 0\n"}), +] + + +_CURRENT_TABLES = { + "2": "a\nb\nc\nd\n-40 -10\ne\n0 0.1 0.2\n5 0.2 0.3\n10 0.2 0.3\n", + "5": "1 1 2 2\n0\n0\n-40 -10\n0 10\n" + + "".join(f"0 0 {z} {t} 0.1 0 0\n" for z in (-40, -10) for t in (0, 10)), +} +_PROFILE = "z ux uy uz\n-40 0.1 0 0\n-10 0.2 0.1 0\n" +_TABLE = "-40 0.1 0.0\n-10 0.2 0.1\n" + + +def _kin(wave="0", current="0", table="", dt="0.25", direction="37.0", record='"eta.txt"') -> str: + return ( + "MoorDyn v2 water kinematics file\n(header line 2)\n--- WAVES ---\n" + f"{wave} WaveKinMod\n{record} WaveKinFile\n{dt} dtWave\n{direction} WaveDir\n" + "0 X\n0.0\n0 Y\n0.0\n0 Z\n0.0\n--- CURRENT ---\n" + f"{current} CurrentMod\nz ux uy\n(m) (m/s) (m/s)\n{table}--- need this line ---\n" + ) + + +def _hold(point=2, *, tail=(), steps=101, last=None) -> str: + rows = [f"{0.1 * i:.1f} {point} 0 0 0 0 0 0 0 0 0\n" for i in range(steps)] + return "".join(rows[:last]) + "".join(tail) + + +def _vessel(*, form=19, steps=101, tail=()) -> str: + pose = "0 0 0 0 0 0" if form == 19 else "0 0 0 1 0 0 0" + rows = [f"{0.1 * i:.1f} {pose} {'0 ' * 12}\n" for i in range(steps)] + return "".join(rows) + "".join(tail) + + +def _rao( + heads=(-180.0, 179.0), period_row="0.5 0 0 0 0 1 0 0 0 0 0 0 0\n100 0 0 0 0 1 0 0 0 0 0 0 0\n" +): + return "".join(f"HEADING {head}\n{period_row}" for head in heads) + + +def _sea_bed(depth=50.0, **changes) -> str: + rows = {(x, y): depth for x in (-1000.0, 1000.0) for y in (-1000.0, 1000.0)} + rows.update(changes.get("rows", {})) + return "".join(f"{x} {y} {value}\n" for (x, y), value in rows.items()) + changes.get("tail", "") + + +_SETTINGS = (EXAMPLES / "data" / "syrope" / "syrope_settings.dat").read_text(encoding="utf-8") +_OWC = (EXAMPLES / "data" / "syrope" / "syrope_owc.dat").read_text(encoding="utf-8") + + +def _syrope(settings=_SETTINGS, owc=_OWC) -> dict[str, str]: + return {"data/syrope/syrope_settings.dat": settings, "data/syrope/syrope_owc.dat": owc} + + +# a deck of objects and no lines: the only route that does not read a motion file +_OBJECTS = """Dry rod pinned to a heavy body +--- ROD TYPES --- +Name Diam Mass Cd Ca CdEnd CaEnd +(-) (m) (kg/m) (-) (-) (-) (-) +pend 0.2 50 0 0 0 0 +--- BODIES --- +ID Attachment X0 Y0 Z0 r0 p0 y0 Mass CG* I* Volume CdA* Ca* +(#) (-) (m) (m) (m) (deg) (deg) (deg) (kg) (m) (kg-m^2) (m^3) (m^2) (-) +1 Free 5 0 20 0 0 0 5.0e10 0 1.0e13 4.87804882926829E+07 0 0 +--- RODS --- +ID RodType Attachment XA YA ZA XB YB ZB NumSegs Outputs +(-) (-) (-) (m) (m) (m) (m) (m) (m) (-) (-) +1 pend Body1Pinned 0 0 0 5.0 0 -8.66025403784439 4 - +--- OPTIONS --- +100 WtrDpth +deck bodyIC +moordyn bodyWetting +0.05 dtM +1.0 TMax +--- OUTPUTS --- +Rod1Px Body1Pz +--- end --- +""" +_FREE = "wave_frequencies.txt" +_ONE_BETA = "0 0 0 0.5\n0.5 0.5 0 0.5\n" +_WK = ("wk.dat WaterKin",) +_C1 = "current_profile.txt" +_C2 = "current_profile_dynamic.txt" +_C5 = "current_profile_4d.txt" +_RAO_ROWS = ("rao.txt vesselRAO", "0|0|0 vesselRef") +# (deck, rows, files): every case is judged by DeckFile and by the driver +_ANCILLARY_CASES = [ + # WaterKin: the rest of the file + ("held", _WK, {"wk.dat": _kin()}), + ("held", _WK, {"wk.dat": _kin(dt="-1")}), + ("held", _WK, {"wk.dat": _kin(dt="x")}), + ("held", _WK, {"wk.dat": _kin(direction="north")}), + ("held", _WK, {"wk.dat": 'a\nb\nc\n0\n""\n0\n0\n0\n'}), + ("held", _WK, {"wk.dat": _kin(current="x")}), + ("held", _WK, {"wk.dat": _kin(current="3")}), + ("held", _WK, {"wk.dat": _kin(current="2")}), + ("held", _WK, {"wk.dat": _kin(wave="1", current="2"), "eta.txt": _ETA}), + ("held", _WK, {"wk.dat": _kin(wave="1", record='""')}), + ("held", _WK, {"wk.dat": _kin(wave="1", dt="0"), "eta.txt": _ETA}), + ("held", ("wk.dat WaterKin", "airy 2 8 0 waves"), {"wk.dat": _kin("1"), "eta.txt": _ETA}), + # the WaterKin current table, and a current doubled + ("held", _WK, {"wk.dat": _kin(current="1", table=_TABLE)}), + ("held", _WK, {"wk.dat": _kin(current="1", table="10 0.2 0.1\n40 0.1 0.0\n")}), + ( + "held", + ("wk.dat WaterKin", "uniform 0.1 0 0 current"), + {"wk.dat": _kin(current="1", table=_TABLE)}, + ), + ("held", ("wk.dat WaterKin", "uniform 0.1 0 0 current"), {"wk.dat": _kin()}), + ( + "held", + ("wk.dat WaterKin", "1 Currents"), + {"wk.dat": _kin(current="1", table=_TABLE), _C1: _PROFILE}, + ), + ("held", ("wk.dat WaterKin", "1 Currents"), {"wk.dat": _kin(), _C1: _PROFILE}), + ("held", _WK, {"wk.dat": _kin(current="1", table="-40 0.1 0\n")}), + ("held", _WK, {"wk.dat": _kin(current="1", table="-40 0.1 0\n10 0.2 0\n")}), + ("held", _WK, {"wk.dat": _kin(current="1", table="-40 0.1 0\n-40 0.2 0\n")}), + ("held", _WK, {"wk.dat": _kin(current="1", table="-40 0.1\n-10 0.2 0\n")}), + ("held", _WK, {"wk.dat": _kin(current="1", table="-40 2000 0\n-10 0.2 0\n")}), + # the record of WaveKinMod 1 + ("held", _WK, {"wk.dat": _kin("1"), "eta.txt": "0 0\n1 0\n2 0\n"}), + ("held", _WK, {"wk.dat": _kin("1"), "eta.txt": "1 0\n2 0\n3 0\n4 0\n"}), + ("held", _WK, {"wk.dat": _kin("1"), "eta.txt": "0 0\n2 0\n1 0\n3 0\n"}), + ("held", _WK, {"wk.dat": _kin("1"), "eta.txt": "0 0\n1 0\nend\n2 0\n3 0\n"}), + ("held", _WK, {"wk.dat": _kin("1"), "eta.txt": "0 0\n1 x\n2 0\n3 0\n"}), + ("held", _WK, {"wk.dat": _kin("1"), "eta.txt": _ETA.replace(" 0.4\n", " 4000\n")}), + ( + "held", + _WK, + { + "wk.dat": _kin("1"), + "eta.txt": _ETA.replace(" 0.4\n", " -70\n").replace(" -0.4\n", " -70\n"), + }, + ), + ("held", ("wk.dat WaterKin", "0.1 WvHiCOff"), {"wk.dat": _kin("1"), "eta.txt": _ETA}), + ("held", ("wk.dat WaterKin", "none WvHiCOff"), {"wk.dat": _kin("1"), "eta.txt": _ETA}), + # Currents 1, 2 and 5 + ("held", ("1 Currents",), {_C1: _PROFILE}), + ("held", ("1 Currents",), {_C1: "z ux uy uz\n"}), + ("held", ("1 Currents",), {_C1: "-10 0 0 0\n-40 0 0 0\n"}), + ("held", ("1 Currents",), {_C1: "-40 2000 0 0\n-10 0 0 0\n"}), + ("held", ("1 Currents",), {_C1: "-40 0 0\n-10 0 0 x\n"}), + ("held", ("1 Currents",), {_C1: "-40 0\n"}), + ("held", ("2 Currents",), {_C2: _CURRENT_TABLES["2"]}), + ("held", ("2 Currents",), {_C2: "a\nb\n"}), + ("held", ("2 Currents",), {_C2: "a\nb\nc\nd\n-40 -10\ne\n0 0.1 0.2\n"}), + ("held", ("2 Currents",), {_C2: "a\nb\nc\nd\n-10 -40\ne\n0 1 1\n5 1 1\n"}), + ("held", ("2 Currents",), {_C2: "a\nb\nc\nd\n-40 -10\ne\n0 1 1\n0 1 1\n"}), + ("held", ("2 Currents",), {_C2: "a\nb\nc\nd\n-40 -10\ne\n0 1\n5 1 1\n"}), + ("held", ("2 Currents",), {_C2: "a\nb\nc\nd\n-40 -10\ne\n0 x 1\n5 1 1\n"}), + ("held", ("2 Currents",), {_C2: "a\nb\nc\nd\n-40 -10\ne\n0 2000 1\n5 1 1\n"}), + ("held", ("5 Currents",), {_C5: _CURRENT_TABLES["5"]}), + ("held", ("5 Currents",), {_C5: "1 1 x 2\n"}), + ("held", ("5 Currents",), {_C5: "1 1 2 2\n0\n0\n-40\n0 10\n"}), + ("held", ("5 Currents",), {_C5: _CURRENT_TABLES["5"] + "0 0 -40 0 0.1 0 0\n"}), + ( + "held", + ("5 Currents",), + {_C5: _CURRENT_TABLES["5"].replace("0 0 -40 0 0.1", "0 0 -41 0 0.1")}, + ), + ("held", ("5 Currents",), {_C5: _CURRENT_TABLES["5"].replace("0 0 -40 0 0.1 0 0\n", "")}), + ("held", ("5 Currents",), {_C5: "1 1 1 1\n0\n0\n-40\n0\n0 0 -40 0 0.1 0 0\n"}), + ( + "held", + ("5 Currents",), + {_C5: _CURRENT_TABLES["5"].replace("0 0 -40 0 0.1", "0 0 -40 0 2000")}, + ), + # motionFile + ("held", ("m.txt motionFile",), {"m.txt": _hold()}), + ("held", ("m.txt motionFile",), {"m.txt": _hold(tail=("10.1 2 0 0 0 0 0 0 0 0 0\n",))}), + ("held", ("m.txt motionFile",), {"m.txt": _hold(last=100)}), + ("held", ("m.txt motionFile",), {"m.txt": _hold(tail=("0.0 2 0 0 0 0 0 0 0 0 0\n",))}), + ("held", ("m.txt motionFile",), {"m.txt": _hold(tail=("0.05 2 0 0 0 0 0 0 0 0 0\n",))}), + ("held", ("m.txt motionFile",), {"m.txt": _hold(tail=("0.0 1 0 0 0 0 0 0 0 0 0\n",))}), + ("held", ("m.txt motionFile",), {"m.txt": _hold(tail=("0.0 2.5 0 0 0 0 0 0 0 0 0\n",))}), + ("held", ("m.txt motionFile",), {"m.txt": _hold(tail=("0.0 2 0 0 0\n",))}), + ("held", ("m.txt motionFile",), {"m.txt": _hold(point=1)}), + ("held", ("m.txt motionFile",), {"m.txt": "0.0 2 2e6 0 0 0 0 0 0 0 0\n"}), + ("held", ("m.txt motionFile",), {"m.txt": "0.0 2 0 0 0 2e4 0 0 0 0 0\n"}), + ("held", ("m.txt motionFile",), {"m.txt": "0.0 2 0 0 0 0 0 0 2e6 0 0\n"}), + # vesselMotion + ("held", ("v.txt vesselMotion", "0|0|0 vesselRef"), {"v.txt": _vessel()}), + ("held", ("v.txt vesselMotion", "0|0|0 vesselRef"), {"v.txt": _vessel(form=20)}), + ("held", ("v.txt vesselMotion", "0|0|0 vesselRef"), {"v.txt": _vessel(steps=100)}), + ( + "held", + ("v.txt vesselMotion", "0|0|0 vesselRef"), + {"v.txt": _vessel(form=19, tail=("0.0 " + "0 " * 19 + "\n",))}, + ), + ( + "held", + ("v.txt vesselMotion", "0|0|0 vesselRef"), + {"v.txt": _vessel(form=20).replace("0 0 0 1 0 0 0", "0 0 0 2 0 0 0", 1)}, + ), + ("held", ("v.txt vesselMotion", "0|0|0 vesselRef"), {"v.txt": "0.05 " + "0 " * 18 + "\n"}), + ("held", ("v.txt vesselMotion", "0|0|0 vesselRef"), {"v.txt": "0.0 2e6 " + "0 " * 17 + "\n"}), + # vesselRAO, its rows and the headings of the sea + ("held", ("airy 2 8 30 waves", *_RAO_ROWS), {"rao.txt": _rao()}), + ("held", ("airy 2 8 30 waves", *_RAO_ROWS), {"rao.txt": _rao(heads=(30.0,))}), + ("held", ("airy 2 8 30 waves", *_RAO_ROWS), {"rao.txt": _rao(heads=(0.0,))}), + ("held", ("airy 2 8 30 waves", *_RAO_ROWS), {"rao.txt": _rao(heads=(40.0, 90.0))}), + ("held", ("airy 2 8 30 waves", *_RAO_ROWS), {"rao.txt": _rao(heads=(0.0, 90.0))}), + ("held", ("airy 2 8 30 waves", *_RAO_ROWS), {"rao.txt": _rao(heads=(0.0, 360.0))}), + ("held", ("airy 2 8 30 waves", *_RAO_ROWS), {"rao.txt": "1 0 0 0 0 1 0 0 0 0 0 0 0\n"}), + ("held", ("airy 2 8 30 waves", *_RAO_ROWS), {"rao.txt": "HEADING 30\n"}), + ("held", ("airy 2 8 30 waves", *_RAO_ROWS), {"rao.txt": "HEADING 30 40\n"}), + ("held", ("airy 2 8 30 waves", *_RAO_ROWS), {"rao.txt": "HEADING 30\n1 0 0\n"}), + ("held", ("airy 2 8 30 waves", *_RAO_ROWS), {"rao.txt": "HEADING 30\n-1 " + "0 " * 12 + "\n"}), + ( + "held", + ("airy 2 8 30 waves", *_RAO_ROWS), + {"rao.txt": "HEADING 30\n1 -1 " + "0 " * 11 + "\n"}, + ), + ( + "held", + ("airy 2 8 30 waves", *_RAO_ROWS), + {"rao.txt": "HEADING 30\n1 " + "0 " * 12 + "\nHEADING 40\n2 " + "0 " * 12 + "\n"}, + ), + ("held", ("jonswap 2 9 3.3 30 waves", *_RAO_ROWS), {"rao.txt": _rao(heads=(30.0,))}), + ( + "held", + ("jonswap 2 9 3.3 30 waves", "4 WaveSpreading", *_RAO_ROWS), + {"rao.txt": _rao(heads=(30.0,))}, + ), + ( + "held", + ("jonswap 2 9 3.3 30 waves", "4 WaveSpreading", *_RAO_ROWS), + {"rao.txt": _rao(heads=(-60.0, 120.0))}, + ), + ( + "held", + ("jonswap 2 9 3.3 30 waves", "4 WaveSpreading", "5 WaveDirections", *_RAO_ROWS), + {"rao.txt": _rao(heads=(-42.0, 102.0))}, + ), + # a narrow spreading underflows the outer bins: only the bins with energy need a heading + ( + "held", + ("jonswap 2 9 3.3 0 waves", "1000 WaveSpreading", *_RAO_ROWS), + {"rao.txt": _rao(heads=(-40.0, 40.0))}, + ), + ( + "held", + ("jonswap 2 9 3.3 0 waves", "1000 WaveSpreading", *_RAO_ROWS), + {"rao.txt": _rao(heads=(-20.0, 20.0))}, + ), + ( + "held", + ("jonswap 2 9 3.3 0 waves", "838 WaveSpreading", *_RAO_ROWS), + {"rao.txt": _rao(heads=(-40.0, 40.0))}, + ), + ( + "held", + ("jonswap 2 9 3.3 0 waves", "700 WaveSpreading", *_RAO_ROWS), + {"rao.txt": _rao(heads=(-40.0, 40.0))}, + ), + ( + "held", + ("jonswap 2 9 3.3 0 1000 wavetrain", *_RAO_ROWS), + {"rao.txt": _rao(heads=(-40.0, 40.0))}, + ), + ( + "held", + ("jonswap 2 9 3.3 0 1000 wavetrain", "5 WaveDirections", *_RAO_ROWS), + {"rao.txt": _rao(heads=(-36.0, 36.0))}, + ), + ("held", ("7 WaterKin", *_RAO_ROWS), {"rao.txt": _rao(), _FREE: _ROWS}), + ("held", ("7 WaterKin", *_RAO_ROWS), {"rao.txt": _rao(heads=(0.0, 100.0)), _FREE: _ROWS}), + ("held", ("7 WaterKin", *_RAO_ROWS), {"rao.txt": _rao(heads=(28.0, 29.0)), _FREE: _ONE_BETA}), + ( + "held", + ("wk.dat WaterKin", *_RAO_ROWS), + {"rao.txt": _rao(heads=(37.0,)), "wk.dat": _kin("1"), "eta.txt": _ETA}, + ), + ( + "held", + ("wk.dat WaterKin", *_RAO_ROWS), + {"rao.txt": _rao(heads=(38.0,)), "wk.dat": _kin("1"), "eta.txt": _ETA}, + ), + # bathymetry + ("bed", (), {"b.txt": _sea_bed()}), + ("bed", (), {"b.txt": _sea_bed(depth=49.0)}), + ("bed", (), {"b.txt": _sea_bed(depth=50.0004)}), + ("bed", (), {"b.txt": _sea_bed(depth=0.0)}), + ("bed", (), {"b.txt": "0 0 50\n1 0 50\n"}), + ("bed", (), {"b.txt": _sea_bed(tail="0 0 50\n")}), + ("bed", (), {"b.txt": _sea_bed(tail="-1000.0 -1000.0 50\n")}), + ("bed", (), {"b.txt": _sea_bed(tail="1 2\n")}), + ("bed", (), {"b.txt": _sea_bed(tail="1 x 3\n")}), + ("bed", (), {"b.txt": _sea_bed(tail="-1000.0 0.0 50\n1000.0 0.0 50\n")}), + # a crease at the anchor: the rounded and the tabulated surfaces differ there + ( + "bed", + (), + { + "b.txt": "".join( + f"{x} {y} {d}\n" + for x, d in ((-1000.0, 40.0), (400.0, 50.0), (1000.0, 40.0)) + for y in (-1000.0, 1000.0) + ) + }, + ), + # Syrope settings and OWC table + ("syrope", (), _syrope()), + ("syrope", (), _syrope(settings=_SETTINGS + "1.0 k1\n")), + ("syrope", (), _syrope(settings="data/x.dat OWC\n")), + ( + "syrope", + (), + _syrope( + settings=_SETTINGS.replace("LINEAR", "CUBIC") + .replace("QUADRATIC", "CUBIC") + .replace("EXP", "CUBIC") + ), + ), + ("syrope", (), _syrope(owc="strain tension\n0.0 0.0\n")), + ("syrope", (), _syrope(owc="0.0 0.0\n0.01 x\n")), + ("syrope", (), _syrope(owc="0.02 1e6\n0.01 2e6\n0.03 3e6\n")), + ("syrope", (), _syrope(owc="0.0 2e6\n0.01 1e6\n0.03 3e6\n")), + # the routes of the objects: which of them read a motion file + ("rigid6_buoy.dat", ("m.txt motionFile",), {"m.txt": "not a motion row\n"}), + ("rod_moored_spar.dat", ("m.txt motionFile",), {"m.txt": "not a motion row\n"}), + ("spar_pinned_rods.dat", ("m.txt motionFile",), {"m.txt": "not a motion row\n"}), + ("mixed_body_rods_points.dat", ("m.txt motionFile",), {"m.txt": "not a motion row\n"}), + ("objects", ("m.txt motionFile",), {"m.txt": "not a motion row\n"}), +] +# every spelling the reader takes of the options and kinds these rules read +_KEYWORD_SPELLINGS = ( + ("waves", "wave"), + ("wavetrain", "wave_train"), + ("WaveSpreading", "wave_spreading"), + ("WaveDirections", "wave_directions"), + ("WaveComponents", "wave_components"), + ("WtrDpth", "water_depth"), + ("dtM", "dt"), + ("WaterKin", "WaveKin"), + ("bathymetryFile", "seafloor_file"), + ("rampTime", "tramp"), +) +_KIND_VALUES = { + "airy": ("2 8 30", "2 8 30"), + "Airy": ("2 8 30", "2 8 30"), + "jonswap": ("2 9 3.3 30", "2 9 3.3 30 0"), + "JONSWAP": ("2 9 3.3 30", "2 9 3.3 30 0"), + "pm": ("2 9 30", "2 9 30 0"), + "issc": ("2 9 30", "2 9 30 0"), + "bretschneider": ("2 9 30", "2 9 30 0"), + "torsethaugen": ("6 12 30", "6 12 30 0"), + "ochihubble": ("2 8 1 1 12 1 30", "2 8 1 1 12 1 30 0"), + "ochi-hubble": ("2 8 1 1 12 1 30", "2 8 1 1 12 1 30 0"), + "ochi_hubble": ("2 8 1 1 12 1 30", "2 8 1 1 12 1 30 0"), + "Ochi_Hubble": ("2 8 1 1 12 1 30", "2 8 1 1 12 1 30 0"), +} +# every kind and keyword against a one-heading RAO table: at the sea's heading and off it, on +# the dynamic clock and on the static check's +_SPELLED_CASES = [ + (base, (f"{kind} {values[train]} {keyword}", *_RAO_ROWS), {"rao.txt": _rao(heads=(heading,))}) + for kind, values in _KIND_VALUES.items() + for train, keyword in ((0, "waves"), (0, "wave"), (1, "wavetrain"), (1, "wave_train")) + for heading in (30.0, 31.0) + for base in ("held", "held_static") +] +_SPELLED_CASES += [ + # the kinds whose rows spread, and the spellings of the options that spread them + (base, (f"pm 2 9 30 {keyword}", f"4 {spread}", *_RAO_ROWS), {"rao.txt": _rao(heads=heads)}) + for base in ("held", "held_static") + for keyword in ("waves", "wave") + for spread in ("WaveSpreading", "wave_spreading", "WAVESPREADING") + for heads in ((30.0,), (-180.0, 179.0)) +] + [ + (base, (f"ochi_hubble 2 8 1 1 12 1 30 4 {keyword}", *_RAO_ROWS), {"rao.txt": _rao(heads=heads)}) + for base in ("held", "held_static") + for keyword in ("wavetrain", "wave_train") + for heads in ((30.0,), (-180.0, 179.0)) +] +# a record or a kinematics file on the static check's clock, under each spelling of its selector +_STATIC_CASES = [ + ("held_static", (f"{value} {keyword}", *rows), files) + for keyword in ("WaterKin", "WaveKin", "WATERKIN") + for value, rows, files in ( + ("wk.dat", (), {"wk.dat": _kin("1"), "eta.txt": _ETA}), + ("wk.dat", ("12.5 WaveRefX",), {"wk.dat": _kin("1"), "eta.txt": _ETA}), + ("wk.dat", ("12.5 WaveRefX",), {"wk.dat": _kin("0"), "eta.txt": _ETA}), + ("wk.dat", (), {"wk.dat": _kin("1"), "eta.txt": "0 0\n1 0\n"}), + ("wk.dat", (), {"wk.dat": _kin("1", dt="100"), "eta.txt": _ETA}), + ( + "wk.dat", + _RAO_ROWS, + {"wk.dat": _kin("1"), "eta.txt": _ETA, "rao.txt": _rao(heads=(37.0,))}, + ), + ( + "wk.dat", + _RAO_ROWS, + {"wk.dat": _kin("1"), "eta.txt": _ETA, "rao.txt": _rao(heads=(38.0,))}, + ), + ("7", (), {_FREE: _ROWS}), + ("7", ("12.5 WaveRefY",), {_FREE: _ROWS}), + ("7", (), {_FREE: "0 0 0 0\n0.5 0.5 0 6.3\n"}), + ("3", ("0.1 dtWave",), {"wave_elevation.txt": _ETA.replace("time elevation\n", "")}), + ) +] + [ + ("held_static", ("m.txt motionFile",), {"m.txt": "0.0 2 0 0 0 0 0 0 0 0 0\n"}), + ( + "held_static", + ("m.txt motionFile",), + {"m.txt": "0.0 2 0 0 0 0 0 0 0 0 0\n0.05 2 0 0 0 0 0 0 0 0 0\n"}, + ), + ("held_static", ("m.txt motionFile",), {"m.txt": _hold()}), + ("held_static", ("m.txt motionFile",), {"m.txt": "1.0 2 0 0 0 0 0 0 0 0 0\n"}), + ("held_static", ("v.txt vesselMotion", "0|0|0 vesselRef"), {"v.txt": _vessel(steps=1)}), + ("held_static", ("v.txt vesselMotion", "0|0|0 vesselRef"), {"v.txt": _vessel()}), + ("bed_static", (), {"b.txt": _sea_bed()}), + ("held", ("airy 2 8 0 waves", "4 WaveSpreading"), {}), +] + + +def _shipped_decks() -> list[str]: + decks = [path.name for path in sorted(EXAMPLES.glob("*.dat"))] + decks.remove("cabledyn_options_reference.dat") + return [*decks, "moordynC_wavekin/chain_wavekin7_currents1.dat"] + + +def _carries_waves(name: str) -> bool: + text = (EXAMPLES / name).read_text(encoding="utf-8") + return re.search(r"(?mi)^(?!none\b|--)\S.*\s(?:waves?|wave_?train)\b", text) is not None or ( + re.search(r"(?mi)^(?!0\s|none\s)\S+\s+(?:WaterKin|WaveKin)\b", text) is not None + ) + + +# every shipped deck as a static check without dtM; every shipped wave deck under each spelling +_SHIPPED_CASES = [(f"shipped:static:{name}", (), {}) for name in _shipped_decks()] + [ + (f"shipped:{variant}:{name}", (), {}) + for name in _shipped_decks() + if _carries_waves(name) + for variant in ("spelled", "upper") +] +_ALL_FILE_CASES = [ + *[("held", rows, files) for rows, files in _WAVE_RECORD_CASES], + *_ANCILLARY_CASES, + *_SPELLED_CASES, + *_STATIC_CASES, + *_SHIPPED_CASES, +] + + +def _respell(text: str, old: str, new: str) -> str: + """``text`` with the first token ``old`` of each row (any case) spelled ``new``.""" + token = re.compile(rf"(?i)(? str: + """``text`` as a static check: TMax 0, and without dtM unless it is kept.""" + text = re.sub(r"(?mi)^\s*\S+(\s+TMax\b)", r"0.0\1", text, count=1) + return text if keep_dtm else re.sub(r"(?mi)^\s*\S+\s+dtM\b.*\n", "", text, count=1) + + +def _shipped_text(variant: str, name: str) -> str: + text = (EXAMPLES / name).read_text(encoding="utf-8") + if variant == "static": + return _static_check(text) + text = _static_check(text, keep_dtm=True) + for old, new in _KEYWORD_SPELLINGS: + text = _respell(text, old, new if variant == "spelled" else old.upper()) + if variant == "upper": + for kind in ("airy", "jonswap", "pm", "issc", "torsethaugen", "ochihubble", "stream"): + text = _respell(text, kind, kind.upper()) + return text + + +def _deck_text(deck: str, rows: tuple[str, ...]) -> str: + if deck.startswith("shipped:"): + return _shipped_text(*deck.split(":")[1:]) + if deck.endswith("_static"): + return _static_check(_deck_text(deck[: -len("_static")], rows)) + if deck == "bed": + return _options(_HELD, "b.txt bathymetryFile", *rows).replace("50.0 WtrDpth\n", "") + if deck == "syrope": + return (EXAMPLES / "syrope_polyester_mooring.dat").read_text(encoding="utf-8") + if deck == "objects": + return _OBJECTS.replace("1.0 TMax\n", "1.0 TMax\n" + "".join(f"{r}\n" for r in rows)) + if deck.endswith(".dat"): + text = (EXAMPLES / deck).read_text(encoding="utf-8") + return re.sub(r"(?m)^(\S+\s+TMax\b.*)$", "\\1\n" + "\n".join(rows), text, count=1) + return _options(_HELD, *rows) + + +def _stage(folder: Path, rows: tuple[str, ...], files: dict[str, str], deck: str = "held") -> Path: + folder.mkdir(parents=True, exist_ok=True) + for name, text in files.items(): + (folder / name).parent.mkdir(parents=True, exist_ok=True) + (folder / name).write_text(text, encoding="utf-8") + if deck.startswith("shipped:"): + source = (EXAMPLES / deck.split(":")[2]).parent + for item in source.iterdir(): + if item.is_dir(): + shutil.copytree(item, folder / item.name, dirs_exist_ok=True) + elif item.suffix != ".dat": + shutil.copy2(item, folder / item.name) + path = folder / "deck.dat" + path.write_text(_deck_text(deck, rows), encoding="utf-8") + return path + + +def _accepted(deck: Path) -> bool: + try: + DeckFile.read(deck) + except DeckFormatError: + return False + return True + + +@pytest.mark.skipif( + not os.environ.get("CABLEDYN_DRIVER", "").strip(), + reason="needs the native driver (CABLEDYN_DRIVER)", +) +@pytest.mark.parametrize(("base", "rows", "files"), _ALL_FILE_CASES) +def test_wave_record_rules_match_the_driver(tmp_path, base, rows, files): + deck = _stage(tmp_path, rows, files, base) + run = subprocess.run( + [os.environ["CABLEDYN_DRIVER"], str(deck), str(tmp_path / "out")], + capture_output=True, + text=True, + errors="replace", + env={**os.environ, "OMP_NUM_THREADS": "1", "OPENBLAS_NUM_THREADS": "1"}, + check=False, + ) + assert _accepted(deck) == (run.returncode == 0), run.stdout[-400:] + run.stderr[-400:] + + +@pytest.mark.parametrize( + ("rows", "files", "message"), + [ + ( + ("wk.dat WaterKin", "1 WaveRefX"), + _waterkin("0"), + "reference point of an elevation record", + ), + ( + ("wk.dat WaterKin", "1 WaveRefX"), + _waterkin("SEASTATE"), + "reference point of an elevation", + ), + (("wk.dat WaterKin", "1 WaveRefX"), _waterkin("3"), "WaveKinMod supports 0"), + (("wk.dat WaterKin", "1 WaveRefX"), _waterkin("one"), "must be 0, 1, 2, or SEASTATE"), + (("wk.dat WaterKin", "1 WaveRefX"), {"wk.dat": "a\nb\n"}, "ended inside its header"), + (("wk.dat WaterKin", "1 WaveRefX"), {"wk.dat": "a\nb\nc\n"}, "no WaveKinMod line"), + (("wk.dat WaterKin", "1 WaveRefX"), {"wk.dat": "a\nb\nc\n \n"}, "line has no value"), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n0.5 0.5 0 6.3\n"}, "within \\+-2 pi"), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n"}, "at least two"), + (("7 WaterKin",), {"wave_frequencies.txt": "1 0 0 0\n2 1 0 0\n"}, "at omega = 0"), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n0 1 0 0\n"}, "must be positive"), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n0.5 x 0 0\n"}, "plain numbers"), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 0 0\n0.5 2000 0 0\n"}, "at most 1e3 m"), + (("7 WaterKin",), {"wave_frequencies.txt": "0 0 -60 0\n0.5 1 0 0\n"}, "effective depth"), + ], +) +def test_wave_files_next_to_the_deck_are_read(tmp_path, rows, files, message): + with pytest.raises(DeckFormatError, match=message): + DeckFile.read(_stage(tmp_path, rows, files)) + + +@pytest.mark.parametrize("kind", ["ochihubble", "ochi-hubble", "ochi_hubble", "OCHI_HUBBLE"]) +def test_every_spelling_of_a_kind_takes_its_heading_from_its_own_column(tmp_path, kind): + rows = (f"{kind} 2 8 1 1 12 1 30 waves", *_RAO_ROWS) + DeckFile.read(_stage(tmp_path, rows, {"rao.txt": _rao(heads=(30.0,))})) + with pytest.raises(DeckFormatError, match="one-heading RAO table"): + DeckFile.read(_stage(tmp_path, rows, {"rao.txt": _rao(heads=(31.0,))})) + + +def test_a_static_check_reads_a_record_on_its_own_clock(tmp_path): + # TMax = 0 without dtM: the record resamples on its dtWave, whatever the nominal step + files = {"wk.dat": _kin("1"), "eta.txt": _ETA} + DeckFile.read(_stage(tmp_path, _WK, files, "held_static")) + with pytest.raises(DeckFormatError, match="four `time elevation` rows"): + DeckFile.read(_stage(tmp_path, _WK, {"eta.txt": "0 0\n1 0\n"}, "held_static")) + + +def test_wave_files_that_are_not_there_are_left_to_the_solver(tmp_path): + # In memory under the nominal path, or on disk ahead of its files: the deck passes here, + # and the driver, which must open the file, refuses it by name while it is missing. + for rows in (("wk.dat WaterKin", "1 WaveRefX"), ("7 WaterKin", "1 WaveRefX")): + DeckFile.from_text(_options(_HELD, *rows)) + DeckFile.from_text(_options(_HELD, *rows), path=tmp_path / "deck.dat") + # once the file is there the same text is judged by it + (tmp_path / "wk.dat").write_text(_WATERKIN.format(mode="0"), encoding="utf-8") + with pytest.raises(DeckFormatError, match="reference point of an elevation record"): + DeckFile.from_text( + _options(_HELD, "wk.dat WaterKin", "1 WaveRefX"), path=tmp_path / "deck.dat" + ) + + +def test_a_waterkin_file_is_found_as_the_native_reader_finds_it(tmp_path): + # quoted, in a subfolder, and by an absolute path + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "wk.dat").write_text(_WATERKIN.format(mode="0"), encoding="utf-8") + for value in ('"sub/wk.dat"', "sub/wk.dat", (tmp_path / "sub" / "wk.dat").as_posix()): + with pytest.raises(DeckFormatError, match="reference point of an elevation record"): + DeckFile.read(_stage(tmp_path, (f"{value} WaterKin", "1 WaveRefX"), {})) + + @pytest.mark.parametrize("value", ["0", "-0.1"]) def test_dtwave_must_be_positive(value): _rejects(_options(_HELD, f"{value} dtWave"), "OPTION dtWave must be positive") @@ -105,6 +771,16 @@ def test_dtwave_must_be_positive(value): (("uniform 0.1 0 0 current", "1 Currents"), "both a current OPTION and Currents 1"), (("uniform 0.1 0 0 current", "2 Currents"), "both a current OPTION and Currents 2"), (("uniform 0.1 0 0 current", "5 Currents"), "both a current OPTION and Currents 5"), + (("7 WaterKin", "12.5 WaveRefX", "-3 WaveRefY"), None), + (("3 WaveKin", "0.1 dtWave", "0 WaveRefY"), None), + (("wk.dat WaterKin", "12.5 WaveRefX"), None), + (("12.5 WaveRefX",), "reference point of an elevation record"), + (("0 WaveRefY",), "reference point of an elevation record"), + (("airy 2 8 0 waves", "1 WaveRefX"), "reference point of an elevation record"), + (("airy 2 8 0 wavetrain", "1 WaveRefY"), "reference point of an elevation record"), + (("7 WaterKin", "2e6 WaveRefX"), "at most 1e6 m"), + (("7 WaterKin", "nan WaveRefY"), "must be"), + (("7 WaterKin", "north WaveRefY"), "must be numeric"), ], ) def test_moordyn_c_kinematics_follow_the_native_rules(rows, message): @@ -633,7 +1309,7 @@ def test_generated_cases_carry_the_fixed_name_kinematics_files(tmp_path): ("mode", "name"), [("2", "current_profile_dynamic.txt"), ("5", "current_profile_4d.txt")] ) def test_generated_cases_carry_the_moordyn_c_current_tables(tmp_path, mode, name): - table = "a MoorDyn-C current table\n" + table = _CURRENT_TABLES[mode] base = _kinematic_base(tmp_path / "src", f"{mode} Currents", files={name: table}) out = tmp_path / "cases" generate_deck_cases(base, out, {"a": {"option.dtM": 0.05}}) @@ -1813,3 +2489,87 @@ def test_mixed_route_currents_get_the_native_verdict(rows, tmp_path): assert python_accepts == (completed.returncode != 1), completed.stdout + completed.stderr if python_accepts: assert "mixed standalone aggregate" in completed.stdout, completed.stdout + + +# A static check: TMax = 0 and no dtM. +_CHECK = _edit(_HELD, ("0.1 dtM\n10.0 TMax\n", "0.0 TMax\n")) +_CHECK_MIXED = _edit(_MIXED, ("0.1 dtM\n10.0 TMax\n", "0.0 TMax\n")) +_CHECK_FINITE = _edit( + _CHECK, ("chain 0.252 390.0 1.674e9 -1.0 0.0 ", "chain 0.252 390.0 1.674e9 -1.0 1.0e6 ") +) + + +@pytest.mark.parametrize( + "rows", + [ + (), + ("uniform 1 0 0 current",), + ("airy 2 8 0 waves",), + ("0.3 frictionMu",), + ("m.txt motionFile",), + ("v.txt vesselMotion", "0|0|0 vesselRef"), + ], +) +def test_static_check_needs_no_dtm(rows): + DeckFile.from_text(_options(_CHECK, *rows)) + DeckFile.from_text(_options(_CHECK_FINITE, *rows)) + if "airy 2 8 0 waves" not in rows: + DeckFile.from_text(_options(_CHECK_MIXED, *rows)) + + +def test_static_check_takes_a_negative_zero_tmax_and_no_other(): + DeckFile.from_text(_edit(_CHECK, ("0.0 TMax", "-0.0 TMax"))) + _rejects(_edit(_CHECK, ("0.0 TMax", "1.0e-9 TMax")), "dynamic run requires both dtM and TMax") + _rejects(_edit(_CHECK, ("0.0 TMax", "10.0 TMax")), "dynamic run requires both dtM and TMax") + + +def test_static_check_leaves_the_host_clock_rules_alone(): + # a host marches a caller-driven deck: its TMax says nothing about the deck-owned kinematics + DeckFile.from_text(_CHECK, caller_driven=True) + _rejects(_options(_CHECK, "airy 2 8 0 waves"), "waves requires dtM", caller_driven=True) + _rejects(_options(_CHECK, "m.txt motionFile"), "motionFile requires dtM", caller_driven=True) + + +def test_a_lone_dtm_says_how_to_check_the_static_start(): + _rejects(_edit(_HELD, ("10.0 TMax\n", "")), "requires both dtM and TMax.*give TMax = 0") + + +@pytest.mark.skipif( + not _PARITY_DRIVER or not Path(_PARITY_DRIVER).is_file(), + reason="set CABLEDYN_TEST_DRIVER to a built native driver to run the native clock parity", +) +@pytest.mark.parametrize( + ("text", "accepted"), + [ + (_CHECK, True), + (_options(_CHECK, "uniform 1 0 0 current"), True), + (_options(_CHECK, "airy 2 8 0 waves"), True), + (_options(_CHECK, "0.3 frictionMu"), True), + (_CHECK_FINITE, True), + (_options(_CHECK_FINITE, "uniform 1 0 0 current"), True), + (_CHECK_MIXED, True), + (_options(_CHECK_MIXED, "uniform 0.5 0 0 current"), True), + (_edit(_CHECK, ("0.0 TMax", "-0.0 TMax")), True), + (_edit(_CHECK, ("0.0 TMax", "1.0 TMax")), False), + (_edit(_CHECK_MIXED, ("0.0 TMax", "1.0 TMax")), False), + (_edit(_HELD, ("10.0 TMax\n", "")), False), + ], +) +def test_static_check_clock_gets_the_native_verdict(text, accepted, tmp_path): + try: + DeckFile.from_text(text) + python_accepts = True + except DeckFormatError: + python_accepts = False + deck = tmp_path / "check.dat" + deck.write_text(text, encoding="utf-8") + completed = subprocess.run( + [_PARITY_DRIVER, str(deck), str(tmp_path / "check")], + capture_output=True, + text=True, + cwd=tmp_path, + timeout=600, + check=False, + ) + assert python_accepts == accepted + assert (completed.returncode == 0) == accepted, completed.stdout + completed.stderr diff --git a/python/tests/test_host_fluid.py b/python/tests/test_host_fluid.py new file mode 100644 index 00000000..1991c761 --- /dev/null +++ b/python/tests/test_host_fluid.py @@ -0,0 +1,427 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""In-process stepping of a deck in moving water, against the standalone driver. + +The driver reads the deck's current and waves and a motion file. The same deck runs here on the +library with the same motion: its current either from the deck, or with every fluid value computed +in this file and handed over node by node (``update_fluid_fields``). Both routes hold the field at +``t + dt`` at the step-start nodes over a step, so they agree to the digits of the driver's output. +""" + +from __future__ import annotations + +import math +import runpy +import subprocess +from pathlib import Path + +import numpy as np +import pytest + +try: + from cabledyn import _lib # noqa: F401 +except OSError as exc: + pytest.skip(f"CableDyn shared library not available: {exc}", allow_module_level=True) + +from cabledyn.driver import CableDynDriver +from cabledyn.errors import CableDynError, DriverNotFoundError +from cabledyn.model import CableDyn + +DT, TMAX = 0.02, 6.0 +HEIGHT, PERIOD, DEPTH, GRAVITY = 2.0, 8.0, 60.0, 9.80665 +SPEED = 0.4 +TOP = np.array([0.0, 0.0, -10.0]) +SWAY, MOTION_RATE = 0.6, 2.0 * math.pi / 10.0 +CHANNELS = ["FairTen1", "AnchTen1"] +TOLERANCE = 1.0e-5 + +DECK = """--------------------- CableDyn Input File ------------------------------------ +A cable between a moving top end and an anchor +--------------------- LINE TYPES --------------------------------------------- +TypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-) +cable 0.16 36.7 4.69e8 -1.0 1.99e4 1.2 0.1 1.0 0.0 +--------------------- POINTS ------------------------------------------------ +ID Type X Y Z +(-) (-) (m) (m) (m) +1 Coupled 0.0 0.0 -10.0 +2 Fixed 70.0 0.0 -60.0 +--------------------- LINES ------------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +1 1 2 - +--------------------- SECTIONS ---------------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +1 cable 100.0 40 +{tables}--------------------- OPTIONS ----------------------------------------------- +9.80665 g +1025.0 rhoW +60.0 WtrDpth +1.0e5 kBot +1.0e4 cBot +0.02 dtM +6.0 TMax +{options} +--------------------- OUTPUTS ----------------------------------------------- +"FairTen1" +"AnchTen1" +--------------------- need this line ---------------------------------------- +""" + +EI0_DECK = DECK.replace(" 1.99e4 ", " 0 ") + +WATERKIN = """MoorDyn v2 water kinematics file +a sheared current +--------------------------- WAVES ------------------------------------- +0 WaveKinMod - type of wave input +"" WaveKinFile - file containing wave elevation time series +0.000000E+00 dtWave - time step to use in setting up wave kinematics grid +0 WaveDir - wave heading (deg) +2 - X wave input type +-10, 110, 2 - X wave grid point data +2 - Y wave input type +-10, 10, 2 - Y wave grid point data +2 - Z wave input type +-100, 0, 2 - Z wave grid point data +--------------------------- CURRENT ------------------------------------- +1 CurrentMod - type of current input +z-depth x-current y-current +(m) (m/s) (m/s) +0.0 0.5 0.1 +-30.0 0.3 0.0 +-60.0 0.1 0.0 +--------------------- need this line ------------------ +""" + +# a tapered stiffener at a clamped top end, on a line resting on a seabed with friction +STIFFENER = """--------------------- STIFFENERS -------------------------------------------- +LineID End Length RootOD TipOD InnerD E Density +(-) (-) (m) (m) (m) (m) (Pa) (kg/m^3) +1 A 4.0 0.45 0.18 0.17 5.0e7 1150 +--------------------- END CONNECTIONS --------------------------------------- +LineID End Stiffness EzX EzY EzZ +(-) (-) (N-m/rad) (-) (-) (-) +1 A Rigid 0.3 0.0 -0.9539 +""" + +FRICTION = "0.5 frictionMu\nasLaid staticsFriction\n0 layAzimuth" +CURRENT = f"uniform {SPEED} 0 0 current" +WAVE = f"airy {HEIGHT} {PERIOD} 0 waves" + + +def _driver() -> CableDynDriver: + try: + return CableDynDriver() + except DriverNotFoundError as exc: + pytest.skip(f"the standalone driver is not available: {exc}") + + +def _motion(t: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Host motion with zero velocity and acceleration at t = 0.""" + s, c = math.sin(MOTION_RATE * t), math.cos(MOTION_RATE * t) + q = TOP + np.array([SWAY * s**3, 0.0, 0.0]) + v = np.array([3.0 * SWAY * MOTION_RATE * s * s * c, 0.0, 0.0]) + a = np.array([3.0 * SWAY * MOTION_RATE**2 * (2.0 * s * c * c - s**3), 0.0, 0.0]) + return q, v, a + + +def _wave_number() -> tuple[float, float]: + omega = 2.0 * math.pi / PERIOD + k = omega * omega / GRAVITY + for _ in range(50): + th = math.tanh(k * DEPTH) + k -= (GRAVITY * k * th - omega * omega) / ( + GRAVITY * th + GRAVITY * k * DEPTH / math.cosh(k * DEPTH) ** 2 + ) + return omega, k + + +OMEGA, WAVE_NUMBER = _wave_number() + + +def _airy(xyz: np.ndarray, t: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """The deck's Airy wave along +x with Wheeler stretching: velocity, acceleration, elevation.""" + theta = WAVE_NUMBER * xyz[0] - OMEGA * t + eta = 0.5 * HEIGHT * np.cos(theta) + wet = xyz[2] <= eta + level = (xyz[2] - eta) * DEPTH / (DEPTH + eta) + DEPTH + ratio_h = np.cosh(WAVE_NUMBER * level) / math.sinh(WAVE_NUMBER * DEPTH) + ratio_v = np.sinh(WAVE_NUMBER * level) / math.sinh(WAVE_NUMBER * DEPTH) + amp = 0.5 * HEIGHT * OMEGA + velocity = np.zeros_like(xyz) + acceleration = np.zeros_like(xyz) + velocity[0] = np.where(wet, amp * ratio_h * np.cos(theta), 0.0) + velocity[2] = np.where(wet, amp * ratio_v * np.sin(theta), 0.0) + acceleration[0] = np.where(wet, amp * OMEGA * ratio_h * np.sin(theta), 0.0) + acceleration[2] = np.where(wet, -amp * OMEGA * ratio_v * np.cos(theta), 0.0) + return velocity, acceleration, eta + + +def _deck(folder: Path, name: str, options: str, tables: str = "", template: str = DECK) -> Path: + path = folder / f"{name}.dat" + path.write_text(template.format(options=options, tables=tables), encoding="utf-8") + return path + + +def _driver_run(folder: Path, options: str, tables: str = "", template: str = DECK) -> np.ndarray: + """FairTen1 and AnchTen1 of the driver on the deck with the motion as a motionFile.""" + with (folder / "motion.txt").open("w", encoding="ascii") as stream: + for i in range(round(TMAX / DT) + 1): + q, v, a = _motion(i * DT) + row = " ".join(f"{x:.14e}" for x in (*q, *v, *a)) + stream.write(f"{i * DT:.10f} 1 {row}\n") + deck = _deck(folder, "driver", f"{options}\nmotion.txt motionFile", tables, template) + subprocess.run( + [str(_driver().executable), deck.name, "driver"], + cwd=folder, + capture_output=True, + text=True, + check=True, + ) + rows = (folder / "driver.out").read_text(encoding="utf-8").splitlines()[2:] + return np.array([[float(x) for x in row.split()[1:3]] for row in rows]) + + +def _host_field(model: CableDyn, t: float, current: float) -> None: + xyz = model.fluid_node_positions() + velocity, acceleration, eta = _airy(xyz, t) + velocity[0] += current + model.update_fluid_fields(velocity, acceleration, eta) + + +def _march(model: CableDyn, current: float | None) -> np.ndarray: + """The library run: with ``current`` set, the host field of that current and the wave.""" + if current is not None: + _host_field(model, 0.0, current) + model.calc_output() + history = [model.channels(CHANNELS)] + for i in range(1, round(TMAX / DT) + 1): + t = i * DT + if current is not None: + _host_field(model, t, current) + model.step(DT, *_motion(t)) + model.calc_output() + history.append(model.channels(CHANNELS)) + return np.array(history) + + +def _nrmse(reference: np.ndarray, value: np.ndarray) -> float: + assert reference.shape == value.shape + span = np.ptp(reference, axis=0) + return float(np.max(np.sqrt(np.mean((reference - value) ** 2, axis=0)) / span)) + + +def test_deck_current_is_marched_in_process(tmp_path): + reference = _driver_run(tmp_path, CURRENT) + with CableDyn(_deck(tmp_path, "library", CURRENT)) as model: + history = _march(model, None) + assert _nrmse(reference, history) < TOLERANCE + + +def test_waterkin_depth_table_is_marched_in_process(tmp_path): + (tmp_path / "water.dat").write_text(WATERKIN, encoding="utf-8") + reference = _driver_run(tmp_path, "water.dat WaterKin") + still = _driver_run(tmp_path, "") + with CableDyn(_deck(tmp_path, "library", "water.dat WaterKin")) as model: + history = _march(model, None) + assert _nrmse(reference, history) < TOLERANCE + # the table reaches the static start as well as the march + assert abs(history[0, 0] - still[0, 0]) > 1.0 + + +def test_deck_current_starts_at_host_positions(tmp_path): + deck = _deck(tmp_path, "library", CURRENT) + with CableDyn(deck) as model: + start = model.calc_output() + with CableDyn() as model: + model.init_deck_at(deck, TOP) + np.testing.assert_allclose(model.calc_output(), start, rtol=1e-12) + + +def test_host_field_matches_the_driver_in_current_and_waves(tmp_path): + reference = _driver_run(tmp_path, f"{CURRENT}\n{WAVE}") + with CableDyn() as model: + model.set_host_fluid() + model.init_deck(_deck(tmp_path, "library", CURRENT)) + assert model.n_fluid_nodes == 41 + assert model.fluid_node_positions().shape == (3, 41) + history = _march(model, SPEED) + assert _nrmse(reference, history) < TOLERANCE + + +def test_host_field_takes_a_stiffener_and_as_laid_friction(tmp_path): + options = f"{CURRENT}\n{FRICTION}" + reference = _driver_run(tmp_path, f"{options}\n{WAVE}", STIFFENER) + with CableDyn() as model: + model.set_host_fluid() + model.init_deck_rotating(_deck(tmp_path, "library", options, STIFFENER)) + history = _march(model, SPEED) + assert _nrmse(reference, history) < TOLERANCE + + +def test_a_host_marches_past_the_deck_tmax(tmp_path): + deck = _deck(tmp_path, "library", CURRENT) + deck.write_text(deck.read_text(encoding="utf-8").replace("6.0 TMax", "0.1 TMax"), "utf-8") + with CableDyn() as model: + model.set_host_fluid() + model.init_deck(deck) + _host_field(model, 0.0, SPEED) + for i in range(1, 21): + _host_field(model, i * DT, SPEED) + model.step(DT, *_motion(i * DT)) + assert np.all(np.isfinite(model.calc_output())) + + +@pytest.mark.parametrize("policy", ["SEASTATE WaterKin", "water.dat WaterKin"]) +def test_host_water_policies_of_a_deck_take_the_host_field(tmp_path, policy): + text = WATERKIN.replace("0 WaveKinMod", "2 WaveKinMod") + text = text.replace("1 CurrentMod", "2 CurrentMod") + (tmp_path / "water.dat").write_text(text, encoding="utf-8") + with CableDyn() as model: + model.set_host_fluid() + model.init_deck(_deck(tmp_path, "library", policy)) + _host_field(model, 0.0, SPEED) + _host_field(model, DT, SPEED) + model.step(DT, *_motion(DT)) + assert np.all(np.isfinite(model.calc_output())) + + +def test_a_host_field_deck_needs_no_tmax(tmp_path): + deck = _deck(tmp_path, "library", CURRENT) + deck.write_text(deck.read_text(encoding="utf-8").replace("6.0 TMax", ""), "utf-8") + with CableDyn() as model: + model.set_host_fluid() + model.init_deck(deck) + _host_field(model, 0.0, SPEED) + _host_field(model, DT, SPEED) + model.step(DT, *_motion(DT)) + assert np.all(np.isfinite(model.calc_output())) + + +@pytest.mark.parametrize( + "options, tables", + [(CURRENT, ""), (CURRENT + "\n" + FRICTION, STIFFENER)], + ids=["plain", "stiffener-and-as-laid-friction"], +) +def test_save_and_restore_carry_the_held_field(tmp_path, options, tables): + with CableDyn() as model: + model.set_host_fluid() + model.init_deck_rotating(_deck(tmp_path, "library", options, tables)) + _host_field(model, 0.0, SPEED) + for i in range(1, 6): + _host_field(model, i * DT, SPEED) + model.step(DT, *_motion(i * DT)) + _host_field(model, 6 * DT, SPEED) + model.save_state() + model.step(DT, *_motion(6 * DT)) + expected = model.calc_output() + # a different field after the save goes with the restore + _host_field(model, 7 * DT, 2.0) + model.restore_state() + model.step(DT, *_motion(6 * DT)) + np.testing.assert_array_equal(model.calc_output(), expected) + + +def test_a_step_needs_a_host_field(tmp_path): + with CableDyn() as model: + model.set_host_fluid() + model.init_deck(_deck(tmp_path, "library", "")) + q, v, a = _motion(DT) + with pytest.raises(CableDynError, match="before the first step"): + model.step(DT, q, v, a) + model.save_state() + _host_field(model, 0.0, 0.0) + model.restore_state() + with pytest.raises(CableDynError, match="before the first step"): + model.step(DT, q, v, a) + + +def test_host_field_calls_need_the_switch(tmp_path): + with CableDyn(_deck(tmp_path, "library", "")) as model: + assert model.n_fluid_nodes == 0 + with pytest.raises(CableDynError, match="takes no host fluid field"): + model.fluid_node_positions() + with pytest.raises(CableDynError, match="takes no host fluid field"): + model.update_fluid_fields(np.zeros((3, 0)), np.zeros((3, 0)), np.zeros(0)) + + +def test_host_field_inputs_are_checked(tmp_path): + with CableDyn() as model: + model.set_host_fluid() + model.init_deck(_deck(tmp_path, "library", "")) + n = model.n_fluid_nodes + with pytest.raises(ValueError, match="fluid_velocity"): + model.update_fluid_fields(np.zeros((3, n - 1)), np.zeros((3, n)), np.zeros(n)) + with pytest.raises(ValueError, match="waterline_z"): + model.update_fluid_fields(np.zeros((3, n)), np.zeros((3, n)), np.zeros(n - 1)) + bad = np.zeros(n) + bad[0] = math.nan + with pytest.raises(ValueError, match="finite"): + model.update_fluid_fields(np.zeros((3, n)), np.zeros((3, n)), bad) + + +def test_deck_waves_are_refused_with_a_host_field(tmp_path): + with CableDyn() as model: + model.set_host_fluid() + with pytest.raises(CableDynError, match="remove the deck waves"): + model.init_deck(_deck(tmp_path, "library", WAVE)) + + +def test_ei0_lines_of_a_host_field_need_a_seabed(tmp_path): + deck = _deck(tmp_path, "library", "", template=EI0_DECK) + text = deck.read_text(encoding="utf-8").replace("60.0 WtrDpth\n", "") + deck.write_text(text, encoding="utf-8") + with CableDyn() as model: + model.set_host_fluid() + with pytest.raises(CableDynError, match="seabed"): + model.init_deck(deck) + + +def test_ei0_line_takes_the_host_field(tmp_path): + reference = _driver_run(tmp_path, f"{CURRENT}\n{WAVE}", template=EI0_DECK) + with CableDyn() as model: + model.set_host_fluid() + model.init_deck(_deck(tmp_path, "library", CURRENT, template=EI0_DECK)) + history = _march(model, SPEED) + print("EI0 NRMSE", _nrmse(reference, history)) + assert _nrmse(reference, history) < TOLERANCE + + +def test_the_switch_can_be_turned_off(tmp_path): + deck = _deck(tmp_path, "library", CURRENT) + with CableDyn() as model: + model.set_host_fluid() + model.set_host_fluid(False) + model.init_deck(deck) + assert model.n_fluid_nodes == 0 + + +def test_the_stepping_example_runs(): + example = ( + Path(__file__).resolve().parents[2] / "examples" / "python" / "step_in_moving_water.py" + ) + tensions = runpy.run_path(str(example))["main"](duration=2.0) + assert len(tensions) == 40 + assert all(math.isfinite(value) and value > 0.0 for value in tensions) + + +def test_section_curvature_of_a_line_matches_its_channels(tmp_path): + deck = _deck(tmp_path, "library", "", STIFFENER) + with CableDyn() as model: + model.init_deck_rotating(deck) + model.calc_output() + line = model.lines[0] + values = line.section_curvature() + assert values.shape == (line.n_nodes, 2) + tokens = [f"Curv{axis}1N{j}" for j in range(1, line.n_nodes + 1) for axis in "XY"] + expected = model.channels(tokens).reshape(-1, 2) + np.testing.assert_array_equal(values, expected) + assert np.abs(values).max() > 0.0 + np.testing.assert_allclose(np.hypot(*values.T), line.curvature(), rtol=1e-9, atol=1e-12) + + +def test_section_curvature_needs_a_finite_ei_line(tmp_path): + with CableDyn(_deck(tmp_path, "library", "", template=EI0_DECK)) as model: + with pytest.raises(CableDynError, match="finite-EI"): + model.lines[0].section_curvature() diff --git a/python/tests/test_host_rotation.py b/python/tests/test_host_rotation.py index 794d515f..18bc2bff 100644 --- a/python/tests/test_host_rotation.py +++ b/python/tests/test_host_rotation.py @@ -67,7 +67,7 @@ def _roll(phi: float) -> np.ndarray: def test_abi_minor_reports_the_host_rotation_extension(): assert _lib.HOST_ROTATION_ABI_MINOR == 3 - assert _lib.abi_minor() == cabledyn.abi_minor() == _lib.HOST_ROTATION_ABI_MINOR + assert _lib.abi_minor() == cabledyn.abi_minor() >= _lib.HOST_ROTATION_ABI_MINOR def test_torsion_deck_needs_the_rotating_initialization(tmp_path): diff --git a/python/tests/test_init_notes.py b/python/tests/test_init_notes.py new file mode 100644 index 00000000..645e347a --- /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/python/tests/test_modes_output.py b/python/tests/test_modes_output.py new file mode 100644 index 00000000..d069f034 --- /dev/null +++ b/python/tests/test_modes_output.py @@ -0,0 +1,394 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The reader of ``.modes.out``, on files the native driver really writes. + +The driver gates are skipped unless ``CABLEDYN_DRIVER`` or ``CABLEDYN_TEST_DRIVER`` names a +built driver. The malformed files are built from the layout of the tables, row by row. +""" + +from __future__ import annotations + +import math +import os +import shutil +from pathlib import Path + +import numpy as np +import pytest + +from cabledyn import ( + CableDynDriver, + DriverResult, + ModeShapes, + NaturalModes, + OutputFormatError, + OutputTable, + read_output, +) +from cabledyn.formats import read_table + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" +_DRIVER = ( + os.environ.get("CABLEDYN_DRIVER") or os.environ.get("CABLEDYN_TEST_DRIVER") or "" +).strip() + +needs_driver = pytest.mark.skipif( + not _DRIVER or not Path(_DRIVER).is_file(), + reason="set CABLEDYN_DRIVER to a built native driver to run the modal output gates", +) + +RHO_W, DIAM = 1025.0, 0.1 +# Neutrally buoyant, without added mass: a taut line stays straight. +MASS = RHO_W * math.pi * DIAM**2 / 4.0 +# An EI = 0 line must have weight: this one sags by about a centimetre. +EI0_MASS = 1.01 * MASS +EA, TENSION = 1.0e6, 1000.0 +BAR = "-" * 21 + + +def taut_deck( + lines: list[tuple[float, int]], + *, + ei: float = 0.0, + torsion: tuple[str, ...] = (), + n_modes: int = 6, +) -> str: + """Taut straight lines of (length, segments), side by side, from Coupled to Fixed points. + + ``ei`` above 0 makes a dynamic finite-EI deck, ``torsion`` names the TorsStiffness of the + two ends of each line. + """ + points, line_rows, sections, ends = [], [], [], [] + for index, (length, segments) in enumerate(lines): + line_id = index + 1 + span = length * (1.0 + TENSION / EA) + y = 20.0 * index + points.append(f"{2 * index + 1} Coupled 0.0 {y!r} -100.0") + points.append(f"{2 * index + 2} Fixed {span!r} {y!r} -100.0") + line_rows.append(f"{line_id} {2 * index + 1} {2 * index + 2} -") + sections.append(f"{line_id} cab {length!r} {segments}") + if torsion: + for end in "AB": + ends.append(f"{line_id} {end} Rigid 1 0 0 {torsion[index]} 0 0 1 0") + if ei > 0.0: + types = "Name Diam Mass EA BA EI GAs GJ Irt Irn Cdn Cdt Can Cat\n" + types += f"cab {DIAM!r} {MASS!r} {EA!r} 0.0 {ei!r} 1.0e8 80.0 1.0 0.04 1.2 0.0 0.0 0.0" + march = "0.01 dtM\n0.01 TMax\n" + else: + types = "Name Diam Mass EA BA EI Cdn Cdt Can Cat\n" + types += f"cab {DIAM!r} {EI0_MASS!r} {EA!r} -1.0 0.0 1.2 0.0 0.0 0.0" + march = "" + connections = "" + if ends: + connections = ( + f"{BAR} END CONNECTIONS {BAR}\n" + "LineID End Stiffness EzX EzY EzZ TorsStiffness NxX NxY NxZ Pretwist\n" + + "\n".join(ends) + + "\n" + ) + return ( + f"{BAR} CableDyn Input File {BAR}\n" + "taut straight lines\n" + f"{BAR} LINE TYPES {BAR}\n{types}\n" + f"{BAR} POINTS {BAR}\nID Type X Y Z\n" + "\n".join(points) + "\n" + f"{BAR} LINES {BAR}\nID NodeA NodeB Outputs\n" + "\n".join(line_rows) + "\n" + f"{BAR} SECTIONS {BAR}\nLineID LineType Length NumSegs\n" + + "\n".join(sections) + + "\n" + + connections + + f"{BAR} OPTIONS {BAR}\n9.80665 g\n{RHO_W!r} rhoW\n500.0 WtrDpth\n" + + march + + f"{n_modes} nModes\n{BAR} OUTPUTS {BAR}\nFairTen1\n{BAR} need this line {BAR}\n" + ) + + +def run_modes(tmp_path: Path, deck_text: str, name: str = "modes"): + deck = tmp_path / f"{name}.dat" + deck.write_text(deck_text, encoding="utf-8") + return CableDynDriver(_DRIVER).run(deck, tmp_path / name, timeout=300) + + +@needs_driver +def test_ei0_lines_read_both_tables(tmp_path): + lengths = [(10.0, 20), (20.0, 10)] + result = run_modes(tmp_path, taut_deck(lengths)) + assert result.modes_output == tmp_path / "modes.modes.out" + modes = read_output(result.modes_output) + assert isinstance(modes, NaturalModes) and isinstance(modes, OutputTable) + assert isinstance(modes.shapes, ModeShapes) + assert modes.channels == ("LineID", "Mode", "Frequency", "Period", "Omega") + assert modes.unit("Frequency") == "Hz" and modes.unit("Period") == "s" + assert modes.unit("Omega") == "rad/s" + assert modes.shapes.unit("X") == "m" and not modes.has_twist + assert modes.title.startswith("# CableDyn modal analysis") + assert modes.shapes.title.startswith("# Mode shapes") + assert modes.line_ids == (1, 2) and modes.shapes.line_ids == (1, 2) + assert read_table(result.modes_output).line_ids == (1, 2) + assert result.read_modes().line_ids == (1, 2) + + for line_id, (length, segments) in enumerate(lengths, start=1): + assert modes.mode_numbers(line_id) == (1, 2, 3, 4, 5, 6) + assert modes.shapes.node_ids(line_id) == tuple(range(1, segments + 2)) + frequency = modes.frequencies(line_id) + assert np.all(np.diff(frequency) >= 0.0) and frequency[0] > 0.0 + assert modes.periods(line_id) == pytest.approx(1.0 / frequency, rel=1e-7) + omega = modes.angular_frequencies(line_id) + assert omega == pytest.approx(2.0 * math.pi * frequency, rel=1e-7) + # The two transverse planes of a taut string share each frequency, sqrt(T/m)/(2 L). + string = math.sqrt(TENSION / EI0_MASS) / (2.0 * length * (1.0 + TENSION / EA)) + assert frequency[0] == pytest.approx(string, rel=2e-2) + assert not np.any(modes.polar_shares(line_id)) and modes.torsional_modes(line_id) == () + + xyz = modes.shapes.positions(line_id) + assert xyz.shape == (segments + 1, 3) + assert xyz[0] == pytest.approx([0.0, 20.0 * (line_id - 1), -100.0], abs=1e-6) + for mode in modes.mode_numbers(line_id): + shape = modes.shapes.displacements(line_id, mode) + assert shape.shape == (segments + 1, 3) + assert np.max(np.linalg.norm(shape, axis=1)) == pytest.approx(1.0, rel=1e-7) + assert not np.any(shape[0]) and not np.any(shape[-1]) # held ends + assert not np.any(modes.shapes.twist(line_id, mode)) + # The first mode of a string is a half sine. + arc = np.linspace(0.0, 1.0, segments + 1) + first = np.linalg.norm(modes.shapes.displacements(line_id, 1), axis=1) + assert first == pytest.approx(np.sin(math.pi * arc), abs=2e-2) + + line_2 = modes.line(2) + assert isinstance(line_2, NaturalModes) and line_2.line_ids == (2,) + assert line_2.shapes.line_ids == (2,) + assert line_2.frequencies(2) == pytest.approx(modes.frequencies(2)) + with pytest.raises(KeyError, match="LineID 3"): + modes.line(3) + with pytest.raises(KeyError, match="no mode 7"): + modes.shapes.displacements(1, 7) + + +@needs_driver +def test_finite_ei_lines_read_both_tables(tmp_path): + lengths = [(10.0, 20), (15.0, 30)] + modes = run_modes(tmp_path, taut_deck(lengths, ei=100.0)).read_modes() + assert modes.line_ids == (1, 2) and not modes.has_twist + for line_id, (_, segments) in enumerate(lengths, start=1): + assert len(modes.mode_numbers(line_id)) == 6 + assert np.all(modes.frequencies(line_id) > 0.0) + assert modes.shapes.node_ids(line_id) == tuple(range(1, segments + 2)) + shape = modes.shapes.displacements(line_id, 1) + assert np.max(np.linalg.norm(shape, axis=1)) == pytest.approx(1.0, rel=1e-7) + + +@needs_driver +def test_torsional_modes_on_one_of_several_lines(tmp_path): + deck = taut_deck([(10.0, 40), (10.0, 20)], ei=100.0, torsion=("Rigid", "Free"), n_modes=24) + modes = run_modes(tmp_path, deck).read_modes() + assert modes.has_twist and modes.line_ids == (1, 2) + assert modes.channels[-1] == "PolarShare" and modes.shapes.channels[-1] == "dTwist" + torsional = modes.torsional_modes(1) + assert torsional + assert modes.polar_shares(1)[torsional[0] - 1] > 0.5 + twist = modes.shapes.twist(1, torsional[0]) + assert np.max(np.abs(twist)) == pytest.approx(1.0, rel=1e-7) + assert abs(twist) == pytest.approx(np.sin(math.pi * np.linspace(0.0, 1.0, 41)), abs=1e-3) + # The line without torsion has the columns, at zero. + assert modes.torsional_modes(2) == () and not np.any(modes.polar_shares(2)) + assert not np.any(modes.shapes.twist(2, 1)) + + +@needs_driver +def test_a_line_without_free_nodes_has_no_rows(tmp_path): + result = run_modes(tmp_path, taut_deck([(10.0, 1), (10.0, 8)])) + modes = result.read_modes() + assert modes.line_ids == (2,) and modes.shapes.line_ids == (2,) + with pytest.raises(KeyError, match="LineID 1"): + modes.frequencies(1) + + alone = run_modes(tmp_path, taut_deck([(10.0, 1)]), "alone").read_modes() + assert alone.line_ids == () and alone.values.shape == (0, 5) + assert alone.shapes.values.shape == (0, 9) + + +@needs_driver +def test_a_shipped_modal_example_on_a_seabed(tmp_path): + deck = tmp_path / "chain_modes.dat" + shutil.copyfile(EXAMPLES / deck.name, deck) + modes = CableDynDriver(_DRIVER).run(deck, tmp_path / "chain", timeout=300).read_modes() + assert modes.line_ids == (1,) and len(modes.mode_numbers(1)) == 12 + # doc/tutorial_catenary.rst states the first period. + assert modes.periods(1)[0] == pytest.approx(47.1, abs=0.05) + assert modes.shapes.node_ids(1) == tuple(range(1, 57)) + # Nodes run from End A, the fairlead of this deck. + assert modes.shapes.positions(1)[0] == pytest.approx([0.0, 0.0, 0.0], abs=1e-6) + + +@needs_driver +def test_a_run_without_modes_has_no_modal_output(tmp_path): + deck = taut_deck([(10.0, 8)]).replace("6 nModes\n", "") + result = run_modes(tmp_path, deck) + assert result.modes_output is None + with pytest.raises(FileNotFoundError, match=r"\.modes\.out"): + result.read_modes() + + +HEADER = ( + "# CableDyn modal analysis about the static equilibrium\n" + "# Natural frequencies\n" + "LineID\tMode\tFrequency\tPeriod\tOmega\n" + "(-)\t(-)\t(Hz)\t(s)\t(rad/s)\n" +) +SHAPE_HEADER = ( + "# Mode shapes (nodal translations, unit largest nodal displacement)\n" + "LineID\tMode\tNode\tX\tY\tZ\tdX\tdY\tdZ\n" + "(-)\t(-)\t(-)\t(m)\t(m)\t(m)\t(-)\t(-)\t(-)\n" +) +FREQUENCY_ROWS = "1\t1\t2.0E-1\t5.0E+0\t1.25E+0\n1\t2\t4.0E-1\t2.5E+0\t2.5E+0\n" +SHAPE_ROWS = ( + "1\t1\t1\t0.0\t0.0\t-1.0\t0.0\t0.0\t0.0\n" + "1\t1\t2\t1.0\t0.0\t-1.0\t0.0\t0.0\t1.0\n" + "1\t2\t1\t0.0\t0.0\t-1.0\t0.0\t0.0\t0.0\n" + "1\t2\t2\t1.0\t0.0\t-1.0\t0.0\t1.0\t0.0\n" +) + + +def write(tmp_path: Path, text: str) -> Path: + path = tmp_path / "table.modes.out" + path.write_text(text, encoding="utf-8") + return path + + +def test_a_negative_eigenvalue_reads_as_written(tmp_path): + rows = "1\t1\t-2.0E-1\t0.0E+0\t-1.25E+0\n1\t2\t4.0E-1\t2.5E+0\t2.5E+0\n" + modes = read_output(write(tmp_path, HEADER + rows + SHAPE_HEADER + SHAPE_ROWS)) + assert modes.frequencies(1)[0] == -0.2 and modes.periods(1)[0] == 0.0 + assert modes.angular_frequencies(1)[0] == -1.25 + + +def test_the_accessors_of_a_file_with_twist_columns(tmp_path): + header = HEADER.replace("\tOmega", "\tOmega\tPolarShare").replace("(rad/s)", "(rad/s)\t(-)") + frequency = "1\t1\t2.0E-1\t5.0E+0\t1.25E+0\t0.0E+0\n1\t2\t4.0E-1\t2.5E+0\t2.5E+0\t9.0E-1\n" + shape_header = SHAPE_HEADER.replace("\tdZ", "\tdZ\tdTwist").replace( + "(-)\t(-)\t(-)\n", "(-)\t(-)\t(-)\t(-)\n" + ) + shapes = SHAPE_ROWS.replace("0.0\n", "0.0\t0.0\n").replace("1.0\n", "1.0\t1.0\n") + modes = read_output(write(tmp_path, header + frequency + shape_header + shapes)) + assert modes.has_twist and modes.torsional_modes(1) == (2,) + assert modes.mode_numbers(1) == (1, 2) + assert list(modes.polar_shares(1)) == [0.0, 0.9] + assert list(modes.periods(1)) == [5.0, 2.5] + assert list(modes.frequencies(1)) == [0.2, 0.4] + assert list(modes.angular_frequencies(1)) == [1.25, 2.5] + assert list(modes.shapes.twist(1, 1)) == [0.0, 1.0] + assert modes.shapes.node_ids(1) == (1, 2) + assert modes.shapes.positions(1).tolist() == [[0.0, 0.0, -1.0], [1.0, 0.0, -1.0]] + assert modes.shapes.displacements(1, 2).tolist() == [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + assert modes.line(1).shapes.values.shape == modes.shapes.values.shape + assert modes.values.flags.writeable is False + + +def test_the_accessors_of_a_file_without_twist_columns(tmp_path): + modes = read_output(write(tmp_path, HEADER + FREQUENCY_ROWS + SHAPE_HEADER + SHAPE_ROWS)) + assert not modes.has_twist and modes.torsional_modes(1) == () + assert list(modes.polar_shares(1)) == [0.0, 0.0] + assert list(modes.shapes.twist(1, 1)) == [0.0, 0.0] + with pytest.raises(KeyError, match="LineID 2"): + modes.frequencies(2) + with pytest.raises(KeyError, match="LineID 2"): + modes.shapes.positions(2) + + +def test_read_modes_refuses_a_file_of_another_kind(tmp_path): + other = tmp_path / "other.out" + other.write_text("Time(s) FairTen1\n0.0 1.0\n", encoding="utf-8") + result = DriverResult( + executable=tmp_path / "driver", + deck=tmp_path / "deck.dat", + output_root=tmp_path / "run", + main_output=tmp_path / "run.out", + static_output=None, + elements_output=None, + line_outputs=(), + rod_outputs=(), + stdout="", + stderr="", + returncode=0, + modes_output=other, + ) + with pytest.raises(OutputFormatError, match="no modal tables"): + result.read_modes() + + +def test_a_table_built_without_the_frequency_columns_is_refused(tmp_path): + shapes = read_output( + write(tmp_path, HEADER + FREQUENCY_ROWS + SHAPE_HEADER + SHAPE_ROWS) + ).shapes + with pytest.raises(OutputFormatError, match="frequency table missing Omega"): + NaturalModes( + tmp_path / "table.modes.out", + "", + ("LineID", "Mode", "Frequency", "Period"), + None, + np.array([[1.0, 1.0, 0.2, 5.0], [1.0, 2.0, 0.4, 2.5]]), + shapes, + ) + + +def test_the_tables_are_found_by_layout_not_by_file_name(tmp_path): + path = tmp_path / "renamed.txt" + path.write_text(HEADER + FREQUENCY_ROWS + SHAPE_HEADER + SHAPE_ROWS, encoding="utf-8") + assert isinstance(read_output(path), NaturalModes) + + +@pytest.mark.parametrize( + ("text", "match"), + [ + (HEADER + FREQUENCY_ROWS, "no mode-shape table"), + (HEADER + FREQUENCY_ROWS + SHAPE_HEADER.splitlines()[0], "no mode-shape table"), + (HEADER + FREQUENCY_ROWS + SHAPE_HEADER, "same lines"), + ( + HEADER + FREQUENCY_ROWS + SHAPE_HEADER.replace("\tdY", "\tdX") + SHAPE_ROWS, + "duplicate channel name", + ), + # Cut after a whole mode, inside a mode, and inside a row. + ( + HEADER + FREQUENCY_ROWS + SHAPE_HEADER + "".join(SHAPE_ROWS.splitlines(True)[:2]), + "2 frequency rows but 1 mode shapes", + ), + ( + HEADER + FREQUENCY_ROWS + SHAPE_HEADER + "".join(SHAPE_ROWS.splitlines(True)[:3]), + "not the nodes", + ), + (HEADER + FREQUENCY_ROWS + SHAPE_HEADER + SHAPE_ROWS[:-6], "expected 9 fields"), + ( + HEADER + FREQUENCY_ROWS.splitlines(True)[1] + SHAPE_HEADER + SHAPE_ROWS, + "modes are not numbered", + ), + ( + HEADER + "1\t1\tNaN\t5.0\t1.25\n" + SHAPE_HEADER + SHAPE_ROWS, + "nonnumeric", + ), + ( + HEADER + + FREQUENCY_ROWS + + SHAPE_HEADER + + SHAPE_ROWS.replace("1\t2\t2\t1.0", "1\t2\t2\t2.0"), + "static positions differ", + ), + ( + HEADER + FREQUENCY_ROWS + SHAPE_HEADER.replace("\tdZ", "\tdQ") + SHAPE_ROWS, + "missing dZ", + ), + ( + HEADER.replace("\tOmega", "\tOmega\tPolarShare").replace("(rad/s)", "(rad/s)\t(-)") + + FREQUENCY_ROWS.replace("\n", "\t0.0\n") + + SHAPE_HEADER + + SHAPE_ROWS, + "must appear together", + ), + ], +) +def test_malformed_modal_files_are_refused(tmp_path, text, match): + with pytest.raises(OutputFormatError, match=match): + read_output(write(tmp_path, text)) + + +def test_a_line_split_between_rows_is_refused(tmp_path): + frequency = "1\t1\t0.2\t5.0\t1.25\n2\t1\t0.2\t5.0\t1.25\n1\t2\t0.4\t2.5\t2.5\n" + with pytest.raises(OutputFormatError, match="not contiguous"): + read_output(write(tmp_path, HEADER + frequency + SHAPE_HEADER + SHAPE_ROWS)) diff --git a/python/tests/test_native_model.py b/python/tests/test_native_model.py index 9e8b1e61..6e2f7139 100644 --- a/python/tests/test_native_model.py +++ b/python/tests/test_native_model.py @@ -368,7 +368,7 @@ def test_rod_deck_steps_through_the_coupled_aggregate(tmp_path): with pytest.raises(CableDynError) as caught: m.step(0.01, q, np.zeros(3), np.zeros(3)) assert caught.value.status == 3 - with pytest.raises(CableDynError, match="BODIES or RODS"): + with pytest.raises(CableDynError, match="finite-EI lines, BODIES or RODS runs on"): npt = m.n_points m.update_point_fluid_fields(np.zeros(3 * npt), np.zeros(3 * npt), np.zeros(npt), 1025.0) diff --git a/python/tests/test_objects.py b/python/tests/test_objects.py index bfd122d6..6486fc45 100644 --- a/python/tests/test_objects.py +++ b/python/tests/test_objects.py @@ -10,6 +10,7 @@ from __future__ import annotations +import math import os import re from pathlib import Path @@ -270,6 +271,97 @@ def test_body_and_rod_decks_match_driver_outputs_at_the_initial_state(tmp_path): _check_row(model, result, 0.0) +def _heading_deck(cables: bool) -> tuple[str, dict[int, float]]: + """Lines on headings on both sides of +x, and the azimuth each must report at End A. + + Line ``2 k`` runs in its vertical plane from its own coupled point to its own anchor. Within + 45 deg of +x the range is (-180, 180], otherwise [0, 360). With ``cables`` every second line + is finite-EI, which puts the deck on the mixed route. + """ + headings = [-46.0, -44.0, -30.0, -10.0, -1.0, 1.0, 10.0, 30.0, 44.0, 46.0, 180.0, 270.0] + points, lines, sections, outputs, expected = [], [], [], [], {} + for k, heading in enumerate(headings, start=1): + cable = cables and k % 2 == 0 + reach = 70.0 if cable else 240.0 + cx, sy = math.cos(math.radians(heading)), math.sin(math.radians(heading)) + points.append(f"{2 * k - 1} Coupled {5.0 * cx:.15e} {5.0 * sy:.15e} -10.0") + points.append(f"{2 * k} Fixed {(5.0 + reach) * cx:.15e} {(5.0 + reach) * sy:.15e} -60.0") + lines.append(f"{2 * k} {2 * k - 1} {2 * k} -") + sections.append(f"{2 * k} {'cable 100.0 20' if cable else 'chain 260.0 13'}") + outputs.append(f"L{2 * k}N1Azi") + expected[2 * k] = heading if abs(heading) < 45.0 else heading % 360.0 + text = "\n".join( + [ + "Lines on both sides of +x", + "--- LINE TYPES ---", + "Name Diam Mass EA BA EI Cdn Cdt Can Cat", + "(-) (m) (kg/m) (N) (-) (Nm2) (-) (-) (-) (-)", + "cable 0.16 36.7 4.69e8 -1.0 1.99e4 1.2 0.1 1.0 0.0", + "chain 0.10 150.0 8.0e8 -1.0 0.0 1.2 0.4 1.0 0.5", + "--- POINTS ---", + "ID Type X Y Z", + "(-) (-) (m) (m) (m)", + *points, + "--- LINES ---", + "ID NodeA NodeB Outputs", + "(-) (-) (-) (-)", + *lines, + "--- SECTIONS ---", + "LineID LineType Length NumSegs", + "(-) (-) (m) (-)", + *sections, + "--- OPTIONS ---", + "9.80665 g", + "1025.0 rhoW", + "60.0 WtrDpth", + "1.0e6 kBot", + "1.0e5 cBot", + "0.02 dtM", + "0.04 TMax", + "--- OUTPUTS ---", + *outputs, + "--- need this line ---", + "", + ] + ) + return text, expected + + +@needs_driver +@pytest.mark.parametrize("cables", [False, True], ids=["ei0", "mixed"]) +def test_azimuth_range_is_the_same_on_every_route(tmp_path, cables): + text, expected = _heading_deck(cables) + deck = _write(tmp_path, "headings.dat", text) + result = _driver_run(deck, tmp_path) + main = result.read_main() + # a run without dtM and TMax stops at the static solution, which an EI = 0 deck writes out + static_text = text.replace("0.02 dtM\n", "").replace("0.04 TMax\n", "") + static_deck = _write(tmp_path, "headings_static.dat", static_text) + static = _driver_run(static_deck, tmp_path).read_static() + with CableDyn(deck) as model: + for line_id, azimuth in expected.items(): + token = f"L{line_id}N1Azi" + driver = float(main.values[0, list(main.channels).index(token)]) + assert driver == pytest.approx(azimuth, abs=1.0e-5), f"driver {token}" + assert model.channel(token) == pytest.approx(azimuth, abs=1.0e-6), f"library {token}" + line = model.line(line_id) + np.testing.assert_allclose(line.azimuth(), azimuth, rtol=0.0, atol=1.0e-6) + profile = static.line(line_id).column("Azimuth") + np.testing.assert_allclose(profile, azimuth, rtol=0.0, atol=1.0e-5) + + +@needs_driver +def test_azimuth_range_of_a_line_on_a_body(tmp_path): + text = _BODY_DECK.replace("4 Fixed 40.0 0.0 -100.0", "4 Fixed 40.0 -10.0 -100.0") + text = text.replace('"FairTen1"\n', '"FairTen1"\n"L1N1Azi"\n') + deck = _write(tmp_path, "body_heading.dat", text) + main = _driver_run(deck, tmp_path).read_main() + driver = float(main.values[0, list(main.channels).index("L1N1Azi")]) + with CableDyn(deck) as model: + assert model.channel("L1N1Azi") == pytest.approx(driver, abs=1.0e-5) + assert -45.0 < driver < 0.0 + + # --- library-only gates -------------------------------------------------------- @@ -352,8 +444,9 @@ def test_finite_ei_views_report_bending(tmp_path): curv = line.curvature() moment = line.bend_moment() assert curv.max() > 0.01 - # EI is uniform (1.99e4 N.m^2) along this cable - np.testing.assert_allclose(moment, 1.99e4 * curv, rtol=1.0e-9, atol=1.0e-9) + # EI is uniform (1.99e4 N.m^2) along this cable; the moment is EI kappa over the stretch + stretch = 1.0 + (line.values("tension") + 1.99e4 * curv**2) / 4.69e8 + np.testing.assert_allclose(moment * stretch, 1.99e4 * curv, rtol=1.0e-6, atol=1.0e-9) assert line.bend_moment()[95] == model.channel("BendMom1N96") np.testing.assert_allclose(line.node_positions()[0], [5.0, 0.0, -14.0], atol=1e-9) np.testing.assert_allclose(line.node_positions()[-1], [125.0, 0.0, -80.0], atol=1e-9) @@ -450,7 +543,7 @@ def test_unbound_handle_and_raw_lines(monkeypatch, tmp_path): def test_abi_minor_is_reported(): assert _lib.OBJECT_QUERY_ABI_MINOR == 1 - assert _lib.abi_minor() == cabledyn.abi_minor() == _lib.HOST_ROTATION_ABI_MINOR == 3 + assert _lib.abi_minor() == cabledyn.abi_minor() == _lib.HOST_FLUID_ABI_MINOR == 4 assert objects_module.OBJ_ROD == 4 diff --git a/python/tests/test_raw_lines.py b/python/tests/test_raw_lines.py new file mode 100644 index 00000000..3e6f256d --- /dev/null +++ b/python/tests/test_raw_lines.py @@ -0,0 +1,593 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Raw-array lines and load Jacobians through the binding, against the C behaviour. + +A raw line carries no gravity, seabed or hydrodynamics, so the decks that it must reproduce set +a negligible ``g`` and ``rhoW`` (both must be positive) and no drag or added mass. Skipped when the +library cannot be loaded (set ``CABLEDYN_LIBRARY``). +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +try: + from cabledyn import model as model_module # loads the shared library +except (ImportError, OSError) as exc: # a missing library raises OSError + pytest.skip(f"CableDyn shared library not available: {exc}", allow_module_level=True) + +from cabledyn import CableDyn +from cabledyn.errors import CableDynError + +REPO = Path(__file__).resolve().parents[2] +VOLTURNUS = REPO / "examples" / "iea15mw_volturnus_mooring.dat" +BAR = "-" * 21 +BAD_INPUT = 3 + +LENGTH, N_ELEM, EA, MASS, RHO_INF = 100.0, 10, 1.0e7, 50.0, 0.4 +SPAN = 100.5 +TENSION = EA * (SPAN / LENGTH - 1.0) + + +def line_deck( + tmp_path: Path, rows: tuple[str, ...] = ("1 1 2 -",), rho_inf: float = RHO_INF +) -> Path: + """Taut lines from the Coupled point 1 to the Fixed points 2 and 3, in deck form.""" + sections = "\n".join(f"{row.split()[0]} cab {LENGTH} {N_ELEM}" for row in rows) + deck = tmp_path / "taut.dat" + deck.write_text( + f"""{BAR} CableDyn Input File {BAR} +taut lines without weight +{BAR} LINE TYPES {BAR} +Name Diam Mass EA BA EI Cdn Cdt Can Cat +cab 0.1 {MASS} {EA} 0.0 0.0 0.0 0.0 0.0 0.0 +{BAR} POINTS {BAR} +ID Type X Y Z +1 Coupled 0.0 0.0 -10.0 +2 Fixed {SPAN} 0.0 -10.0 +3 Fixed 0.0 {SPAN} -10.0 +{BAR} LINES {BAR} +ID NodeA NodeB Outputs +{chr(10).join(rows)} +{BAR} SECTIONS {BAR} +LineID LineType Length NumSegs +{sections} +{BAR} OPTIONS {BAR} +1.0e-9 g +1.0e-9 rhoW +500.0 WtrDpth +{rho_inf} rhoInf +{BAR} OUTPUTS {BAR} +FairTen1 +{BAR} need this line {BAR} +""", + encoding="utf-8", + ) + return deck + + +def straight(end: tuple[float, float, float], n_elem: int = N_ELEM) -> np.ndarray: + """Flat node positions of a straight line from (0, 0, -10) to ``end``.""" + start = np.array([0.0, 0.0, -10.0]) + weights = np.linspace(0.0, 1.0, n_elem + 1)[:, None] + return (start + weights * (np.array(end) - start)).reshape(-1) + + +def chain_conn(n_elem: int = N_ELEM) -> np.ndarray: + """The 1-based node pairs of a chain of elements, one row per element.""" + first = np.arange(1, n_elem + 1) + return np.column_stack([first, first + 1]) + + +def end_dofs(n_elem: int = N_ELEM) -> list[int]: + """The 1-based DOFs of the two end nodes.""" + last = 3 * n_elem + return [1, 2, 3, last + 1, last + 2, last + 3] + + +def element_values(value: float, n_elem: int = N_ELEM) -> np.ndarray: + return np.full(n_elem, value) + + +def init_taut( + model: CableDyn, + end: tuple[float, float, float] = (SPAN, 0.0, -10.0), + *, + tension_only: bool = True, + fixed: list[int] | None = None, + rho_inf: float = RHO_INF, +) -> np.ndarray: + q0 = straight(end) + model.init_line( + q0, + np.zeros_like(q0), + chain_conn(), + element_values(LENGTH / N_ELEM), + element_values(EA), + element_values(MASS), + end_dofs() if fixed is None else fixed, + tension_only, + rho_inf, + ) + return q0 + + +def fairlead_motion(q: np.ndarray, time: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """A smooth motion of the first coupled block away from ``q``, in x and in y.""" + omega = 2.0 * np.pi / 2.0 + position, velocity, acceleration = q.copy(), np.zeros_like(q), np.zeros_like(q) + for axis, amplitude in ((0, 0.2), (1, 0.5)): + position[axis] += amplitude * (1.0 - np.cos(omega * time)) + velocity[axis] = amplitude * omega * np.sin(omega * time) + acceleration[axis] = amplitude * omega**2 * np.cos(omega * time) + return position, velocity, acceleration + + +@pytest.mark.parametrize("rho_inf", [0.0, 0.4, 0.8]) +def test_a_raw_line_matches_the_same_line_from_a_deck(tmp_path, rho_inf): + with CableDyn(line_deck(tmp_path, rho_inf=rho_inf)) as deck, CableDyn() as raw: + init_taut(raw, rho_inf=rho_inf) + assert raw.initialized and raw.n_coupled_dof == 6 and raw.n_lines == 1 + assert raw.deck is None and raw.n_points == 0 + + q_deck, _, _ = deck.get_coupled_motion() + q_raw, v_raw, a_raw = raw.get_coupled_motion() + assert q_raw == pytest.approx(q_deck, abs=1e-9) + assert not np.any(v_raw) and not np.any(a_raw) + assert raw.calc_output() == pytest.approx(deck.calc_output(), abs=1e-4) + assert raw.calc_output()[0] == pytest.approx(TENSION, rel=1e-12) + + dt = 0.01 + for step in range(1, 201): + motion = fairlead_motion(q_raw, step * dt) + deck.step(dt, *motion) + raw.step(dt, *motion) + loads = deck.calc_output() + assert np.max(np.abs(loads - TENSION * np.array([1, 0, 0, -1, 0, 0]))) > 100.0 + assert raw.calc_output() == pytest.approx(loads, rel=1e-8, abs=1e-3) + # The views run from the last node of the arrays, the End A of a deck model's arrays. + assert raw.lines[0].node_positions()[::-1] == pytest.approx( + deck.lines[0].node_positions(), abs=1e-8 + ) + assert raw.time == pytest.approx(deck.time) + + +def shared_arrays() -> dict[str, np.ndarray]: + """Two lines of the shared-point system, as ``init_lines`` takes them.""" + q0 = np.concatenate([straight((SPAN, 0.0, -10.0)), straight((0.0, SPAN, -10.0))]) + fixed = end_dofs() + end_dofs() + return { + "n_nodes": np.array([N_ELEM + 1] * 2), + "n_elem": np.array([N_ELEM] * 2), + "n_fixed": np.array([6, 6]), + "q0": q0, + "v0": np.zeros_like(q0), + "elem_conn": np.concatenate([chain_conn(), chain_conn()]), + "l0": element_values(LENGTH / N_ELEM, 2 * N_ELEM), + "ea": element_values(EA, 2 * N_ELEM), + "rho_a": element_values(MASS, 2 * N_ELEM), + "fixed_dofs": np.array(fixed), + } + + +def test_shared_points_of_raw_lines_match_a_deck(tmp_path): + deck_file = line_deck(tmp_path, ("1 1 2 -", "2 1 3 -")) + with CableDyn(deck_file) as deck, CableDyn() as raw: + raw.init_lines( + **shared_arrays(), + tension_only=True, + rho_inf=RHO_INF, + coupled_map=[1, 2, 3, 4, 5, 6, 1, 2, 3, 7, 8, 9], + ) + assert raw.n_lines == 2 and raw.n_coupled_dof == 9 == deck.n_coupled_dof + q, _, _ = raw.get_coupled_motion() + assert q == pytest.approx(deck.get_coupled_motion()[0], abs=1e-9) + # The shared point takes the pull of both lines. + loads = raw.calc_output() + assert loads[:3] == pytest.approx([TENSION, TENSION, 0.0], rel=1e-12) + assert loads == pytest.approx(deck.calc_output(), abs=1e-4) + dt = 0.01 + for step in range(1, 101): + motion = fairlead_motion(q, step * dt) + deck.step(dt, *motion) + raw.step(dt, *motion) + assert raw.calc_output() == pytest.approx(deck.calc_output(), rel=1e-8, abs=1e-3) + + +def test_independent_raw_lines_equal_separate_handles(): + arrays = shared_arrays() + with CableDyn() as both, CableDyn() as first, CableDyn() as second: + both.init_lines(**arrays, tension_only=True, rho_inf=RHO_INF) + init_taut(first) + init_taut(second, (0.0, SPAN, -10.0)) + assert both.n_lines == 2 and both.n_coupled_dof == 12 + q, v, a = both.get_coupled_motion() + assert q[:6] == pytest.approx(first.get_coupled_motion()[0]) + assert q[6:] == pytest.approx(second.get_coupled_motion()[0]) + moved = q.copy() + moved[1] += 0.3 + both.step(0.01, moved, v, a) + first.step(0.01, moved[:6], v[:6], a[:6]) + second.step(0.01, moved[6:], v[6:], a[6:]) + expected = np.concatenate([first.calc_output(), second.calc_output()]) + assert both.calc_output() == pytest.approx(expected, rel=1e-13) + + +def central_difference(model, q, v, a, block: int, column: int, step: float) -> np.ndarray: + """d(calc_output)/d(the coupled DOF ``column`` of q, v or a), held as update_states holds it.""" + columns = [] + for sign in (1.0, -1.0): + state = [q.copy(), v.copy(), a.copy()] + state[block][column] += sign * step + model.update_states(*state) + columns.append(model.calc_output()) + return (columns[0] - columns[1]) / (2.0 * step) + + +def check_jacobians(model, q, v, a, steps: tuple[float, float, float]) -> list[np.ndarray]: + loads, dq, dv, da, added = model.calc_output_derivatives(q, v, a) + n = model.n_coupled_dof + assert loads.shape == (n,) + for block in (dq, dv, da, added): + assert block.shape == (n, n) + assert added == pytest.approx(-da, abs=0.0) + model.update_states(q, v, a) + assert loads == pytest.approx(model.calc_output(), rel=1e-12) + for index, (jacobian, step) in enumerate(zip((dq, dv, da), steps, strict=True)): + scale = max(float(np.max(np.abs(jacobian))), 1.0) + for column in range(n): + numeric = central_difference(model, q, v, a, index, column, step) + assert np.max(np.abs(jacobian[:, column] - numeric)) < 2e-6 * scale, (index, column) + return [dq, dv, da] + + +@pytest.mark.parametrize( + ("end", "tension_only"), + [ + ((SPAN, 0.0, -10.0), True), # taut along x + ((70.0, 60.0, 40.0), True), # sloped, past its length + ((70.0, 60.0, 30.0), False), # sloped, nearly at its length + ((60.0, 50.0, 10.0), False), # in compression + ], +) +def test_the_jacobians_of_a_raw_line_match_finite_differences(end, tension_only): + with CableDyn() as model: + # The fixed DOFs out of order, the line moving and accelerating. + init_taut(model, end, tension_only=tension_only, fixed=[33, 1, 2, 31, 3, 32]) + q, _, _ = model.get_coupled_motion() + rng = np.random.default_rng(7) + v = rng.uniform(-0.5, 0.5, 6) + a = rng.uniform(-0.5, 0.5, 6) + dq, dv, da = check_jacobians( + model, q + rng.uniform(-0.05, 0.05, 6), v, a, (1e-5, 1e-3, 1e-3) + ) + assert np.any(dq) + assert not np.any(dv) # a raw line has no damping + # The condensed mass is positive definite. + assert np.linalg.eigvalsh(-0.5 * (da + da.T)).min() > 0.0 + + +def test_the_jacobians_of_a_shared_point_sum_its_lines(): + with CableDyn() as model: + model.init_lines( + **shared_arrays(), + tension_only=True, + rho_inf=RHO_INF, + coupled_map=[1, 2, 3, 4, 5, 6, 1, 2, 3, 7, 8, 9], + ) + q, _, _ = model.get_coupled_motion() + rng = np.random.default_rng(3) + v = rng.uniform(-0.5, 0.5, 9) + a = rng.uniform(-0.5, 0.5, 9) + dq, _, da = check_jacobians(model, q, v, a, (1e-5, 1e-3, 1e-3)) + # The far ends of the two lines do not act on each other. + assert not np.any(dq[3:6, 6:9]) and not np.any(da[3:6, 6:9]) + with CableDyn() as first, CableDyn() as second: + init_taut(first) + init_taut(second, (0.0, SPAN, -10.0)) + one = first.calc_output_derivatives(q[:6], v[:6], a[:6])[1] + two = second.calc_output_derivatives( + q[[0, 1, 2, 6, 7, 8]], v[[0, 1, 2, 6, 7, 8]], a[[0, 1, 2, 6, 7, 8]] + )[1] + assert dq[:3, :3] == pytest.approx(one[:3, :3] + two[:3, :3], rel=1e-12) + + +def test_the_jacobians_are_read_column_major(monkeypatch): + """J[i + j*n] of the library is J[i, j] here. The stiffness blocks are nearly symmetric, so + finite differences could not tell a transposed block; a stand-in writes the flat index.""" + + def write_blocks(_handle, _q, _v, _a, n, _eps, loads, *rest): + *blocks, status = rest + for number, block in enumerate(blocks, start=1): + for j in range(n): + for i in range(n): + block[i + j * n] = 1000.0 * number + 10.0 * i + j + loads[0] = 7.0 + status._obj.value = 0 + + with CableDyn() as model: + init_taut(model) + q, v, a = model.get_coupled_motion() + monkeypatch.setattr(model_module._lib, "CableDyn_CalcOutputDerivatives", write_blocks) + loads, *blocks = model.calc_output_derivatives(q, v, a) + assert loads[0] == 7.0 + for number, block in enumerate(blocks, start=1): + assert block[1, 4] == 1000.0 * number + 14.0 + assert block[4, 1] == 1000.0 * number + 41.0 + + +def test_the_jacobians_of_a_deck_with_hydrodynamics_match_finite_differences(): + with CableDyn(VOLTURNUS) as model: + q, v, a = model.get_coupled_motion() + rng = np.random.default_rng(11) + _, dv, da = check_jacobians( + model, + q + rng.uniform(-0.02, 0.02, q.size), + v + rng.uniform(0.2, 0.6, q.size), + a + rng.uniform(-0.2, 0.2, q.size), + (1e-5, 1e-5, 1e-3), + ) + assert np.any(dv) and np.any(da) + + +def test_calc_output_derivatives_sets_the_state_and_keeps_the_time(): + with CableDyn() as model: + init_taut(model) + q, v, a = model.get_coupled_motion() + model.step(0.01, q, v, a) + moved = q.copy() + moved[1] += 0.1 + velocity = np.full(6, 0.2) + acceleration = np.full(6, -0.1) + model.calc_output_derivatives(moved, velocity, acceleration) + assert model.time == pytest.approx(0.01) + q_now, v_now, a_now = model.get_coupled_motion() + assert q_now == pytest.approx(moved) and v_now == pytest.approx(velocity) + assert a_now == pytest.approx(acceleration) + + +def test_the_jacobians_are_refused_on_the_coupled_aggregate(tmp_path): + deck = aggregate_deck(tmp_path) + with CableDyn(deck) as model: + q, v, a = model.get_coupled_motion() + with pytest.raises(CableDynError, match="no load derivatives"): + model.calc_output_derivatives(q, v, a) + + +def aggregate_deck(tmp_path: Path) -> Path: + """A finite-EI line, which runs on the coupled aggregate.""" + text = line_deck(tmp_path).read_text(encoding="utf-8") + finite = text.replace( + f"Name Diam Mass EA BA EI Cdn Cdt Can Cat\ncab 0.1 {MASS} {EA} 0.0 0.0 0.0 0.0 0.0 0.0", + "Name Diam Mass EA BA EI GAs GJ Irt Irn Cdn Cdt Can Cat\n" + f"cab 0.1 {MASS} {EA} 0.0 100.0 1.0e8 80.0 1.0 0.04 0.0 0.0 0.0 0.0", + ).replace("1.0e-9 g\n1.0e-9 rhoW", "9.8 g\n1025.0 rhoW\n0.01 dtM\n1.0 TMax") + deck = tmp_path / "finite.dat" + deck.write_text(finite, encoding="utf-8") + return deck + + +def test_flat_and_column_per_point_positions_build_the_same_line(): + q0 = straight((SPAN, 0.0, -10.0)) + with CableDyn() as flat, CableDyn() as columns: + init_taut(flat) + columns.init_line( + q0.reshape(-1, 3).T, + np.zeros((3, N_ELEM + 1)), + chain_conn().reshape(-1), + element_values(LENGTH / N_ELEM), + element_values(EA), + element_values(MASS), + end_dofs(), + True, + RHO_INF, + ) + assert columns.calc_output() == pytest.approx(flat.calc_output(), abs=0.0) + + +def test_a_line_without_prescribed_dofs_has_an_empty_coupled_vector(): + q0 = straight((SPAN, 0.0, -10.0)) + with CableDyn() as model: + model.init_line( + q0, + np.zeros_like(q0), + chain_conn(), + element_values(LENGTH / N_ELEM), + element_values(EA), + element_values(MASS), + [], + True, + RHO_INF, + ) + assert model.n_coupled_dof == 0 and model.calc_output().shape == (0,) + loads, dq, dv, da, added = model.calc_output_derivatives([], [], []) + assert loads.shape == (0,) + assert dq.shape == dv.shape == da.shape == added.shape == (0, 0) + + +def test_tension_only_lets_a_line_go_slack(): + span = (LENGTH * 0.9, 0.0, -10.0) + with CableDyn() as slack, CableDyn() as linear: + init_taut(slack, span, tension_only=True) + init_taut(linear, span, tension_only=False) + assert not np.any(slack.calc_output()) + assert linear.calc_output()[0] == pytest.approx(-0.1 * EA, rel=1e-12) + + +def raw_arguments() -> dict: + q0 = straight((SPAN, 0.0, -10.0)) + return { + "q0": q0, + "v0": np.zeros_like(q0), + "elem_conn": chain_conn(), + "l0": element_values(LENGTH / N_ELEM), + "ea": element_values(EA), + "rho_a": element_values(MASS), + "fixed_dofs": end_dofs(), + "tension_only": True, + "rho_inf": RHO_INF, + } + + +@pytest.mark.parametrize( + ("name", "value", "error", "match"), + [ + ("q0", np.zeros(32), ValueError, "q0 must be flat"), + ("q0", np.zeros((3, 3)), ValueError, "ambiguous"), + ("q0", np.full(33, np.nan), ValueError, "q0 must be finite"), + ("q0", np.zeros(33, dtype=complex), TypeError, "real-valued"), + ("v0", np.zeros(30), ValueError, "v0 must hold as many"), + ("v0", np.full(33, np.inf), ValueError, "v0 must be finite"), + ("elem_conn", np.ones((N_ELEM, 3), dtype=int), ValueError, "elem_conn must be"), + ("elem_conn", np.ones((N_ELEM, 2)), TypeError, "elem_conn must be an integer"), + ("elem_conn", np.full((N_ELEM, 2), 2**33), ValueError, "range of a C int"), + ("l0", np.ones((N_ELEM, 1)), ValueError, "l0 must be flat"), + ("l0", np.zeros(0), ValueError, "ea must be flat"), + ("l0", np.full(N_ELEM, np.nan), ValueError, "l0 must be finite"), + ("ea", np.ones(N_ELEM + 1), ValueError, "ea must be flat"), + ("ea", np.full(N_ELEM, np.inf), ValueError, "ea must be finite"), + ("rho_a", np.ones(N_ELEM - 1), ValueError, "rho_a must be flat"), + ("rho_a", np.full(N_ELEM, np.nan), ValueError, "rho_a must be finite"), + ("fixed_dofs", np.ones((2, 3), dtype=int), ValueError, "fixed_dofs must be flat"), + ("fixed_dofs", [1.0, 2.0], TypeError, "fixed_dofs must be an integer"), + ("tension_only", "yes", TypeError, "tension_only must be a boolean"), + ("rho_inf", float("nan"), ValueError, "rho_inf must be finite"), + ("rho_inf", -0.1, ValueError, "rho_inf must be non-negative"), + ("rho_inf", 1.5, ValueError, r"rho_inf must lie in \[0, 1\]"), + ("rho_inf", "0.4", TypeError, "rho_inf must be a real number"), + ], +) +def test_a_bad_array_is_refused_before_the_library(name, value, error, match): + with CableDyn(VOLTURNUS) as model: + n_lines = model.n_lines + arguments = raw_arguments() + arguments[name] = value + with pytest.raises(error, match=match): + model.init_line(**arguments) + assert model.deck == VOLTURNUS.resolve() and model.n_lines == n_lines + + +def test_a_line_needs_two_nodes_and_an_element(): + arguments = raw_arguments() + with CableDyn() as model: + short = {**arguments, "q0": np.zeros(3), "v0": np.zeros(3)} + with pytest.raises(ValueError, match="at least 2 nodes"): + model.init_line(**short) + empty = {**arguments, "elem_conn": [], "l0": [], "ea": [], "rho_a": []} + with pytest.raises(ValueError, match="at least 2 nodes and 1 element"): + model.init_line(**empty) + + +@pytest.mark.parametrize( + ("name", "value", "match"), + [ + ("elem_conn", chain_conn() - 1, "elem_conn|connectivity|node"), + ("fixed_dofs", [0, 1, 2], "fixed"), + ("fixed_dofs", [1, 1, 2], "fixed|duplicate"), + ("fixed_dofs", [34], "fixed"), + ("l0", -element_values(1.0), "l0|length|positive"), + ("ea", 0.0 * element_values(1.0), "ea|EA|positive"), + ("rho_a", -element_values(1.0), "rho_a|mass|positive"), + ], +) +def test_a_bad_value_is_the_library_s_to_refuse(name, value, match): + arguments = raw_arguments() + arguments[name] = value + with CableDyn() as model: + with pytest.raises(CableDynError, match=match) as raised: + model.init_line(**arguments) + assert raised.value.status == BAD_INPUT + assert not model.initialized + + +@pytest.mark.parametrize( + ("change", "error", "match"), + [ + ({"n_nodes": [11]}, ValueError, "n_elem must have 1 entries"), + ({"n_nodes": []}, ValueError, "n_nodes must be flat"), + ({"n_nodes": [11, 1]}, ValueError, "at least 2 nodes"), + ({"n_elem": [10, 0]}, ValueError, "at least 2 nodes"), + ({"n_fixed": [6, -6]}, ValueError, "at least 2 nodes"), + ({"n_nodes": [11, 10]}, ValueError, "q0 must hold 63"), + ({"n_elem": [10, 9]}, ValueError, "l0 must hold 19"), + ({"n_fixed": [6, 5]}, ValueError, "fixed_dofs must hold 11"), + ({"n_nodes": [11.0, 11.0]}, TypeError, "n_nodes must be an integer"), + ({"coupled_map": [1, 2, 3]}, ValueError, r"coupled_map must be flat \(12,\)"), + ({"coupled_map": [1.0] * 12}, TypeError, "coupled_map must be an integer"), + ], +) +def test_a_bad_count_is_refused_before_the_library(change, error, match): + arguments = shared_arrays() + arguments.update(change) + with CableDyn() as model, pytest.raises(error, match=match): + model.init_lines(**arguments, tension_only=True, rho_inf=RHO_INF) + + +def test_a_coupled_map_needs_six_fixed_dofs_on_every_line(): + arguments = shared_arrays() + arguments["n_fixed"] = np.array([3, 3]) + arguments["fixed_dofs"] = np.array([1, 2, 3, 1, 2, 3]) + with CableDyn() as model, pytest.raises(ValueError, match="six fixed DOFs"): + model.init_lines( + **arguments, tension_only=True, rho_inf=RHO_INF, coupled_map=list(range(1, 13)) + ) + + +def test_a_coupled_map_with_a_gap_is_the_library_s_to_refuse(): + with CableDyn() as model: + with pytest.raises(CableDynError) as raised: + model.init_lines( + **shared_arrays(), + tension_only=True, + rho_inf=RHO_INF, + coupled_map=[1, 2, 3, 4, 5, 6, 1, 2, 3, 8, 9, 10], + ) + assert raised.value.status == BAD_INPUT + assert not model.initialized + + +def test_a_raw_line_replaces_a_deck_model_and_its_book_keeping(tmp_path): + with CableDyn(line_deck(tmp_path)) as model: + q, v, a = model.get_coupled_motion() + model.step(0.01, q, v, a) + model.save_state() + old_line = model.lines[0] + init_taut(model) + assert model.deck is None and model.time == 0.0 + with pytest.raises(CableDynError): + model.restore_state() # the library drops the save + with pytest.raises(RuntimeError): + old_line.node_positions() + assert model.lines[0].n_nodes == N_ELEM + 1 + + +def test_a_failed_raw_line_keeps_a_live_aggregate_with_its_save(tmp_path): + deck = aggregate_deck(tmp_path) + with CableDyn(deck) as model: + q, v, a = model.get_coupled_motion() + model.step(0.01, q, v, a) + model.save_state() + view = model.lines[0] + arguments = raw_arguments() + arguments["l0"] = -element_values(1.0) + with pytest.raises(CableDynError): + model.init_line(**arguments) + assert model.initialized and model.deck == deck.resolve() + assert model.time == pytest.approx(0.01) + assert view.n_nodes > 0 + model.step(0.01, q, v, a) + model.restore_state() + assert model.time == pytest.approx(0.01) + + +def test_a_failed_raw_line_loses_the_save_of_a_point_model(tmp_path): + with CableDyn(line_deck(tmp_path)) as model: + model.save_state() + arguments = raw_arguments() + arguments["ea"] = -element_values(1.0) + with pytest.raises(CableDynError): + model.init_line(**arguments) + with pytest.raises(CableDynError): + model.restore_state() diff --git a/release/check_release_metadata.py b/release/check_release_metadata.py index ae9172e7..d5566795 100644 --- a/release/check_release_metadata.py +++ b/release/check_release_metadata.py @@ -26,8 +26,8 @@ def capture(relative_path: str, pattern: str, label: str) -> str: return match.group(1) -# Spellings that name a CableDyn release: "CableDyn 0.4.0", "CableDyn v0.4.0", "v0.4.0", -# "cabledyn-0.4.0-py3-none-any.whl", ".../releases/tag/v0.4.0" and "libcabledyn.so.0.4.0". +# Spellings that name a CableDyn release: "CableDyn 0.4.1", "CableDyn v0.4.1", "v0.4.1", +# "cabledyn-0.4.1-py3-none-any.whl", ".../releases/tag/v0.4.1" and "libcabledyn.so.0.4.1". VERSION_REFERENCE = re.compile( r"(?i)(?:cabledyn[ _-]+v?|cabledyn-|releases/tag/v|libcabledyn\.(?:so\.)?|(? int: "C header ABI minor level": f"CABLEDYN_CAPI_ABI_MINOR {abi_minor}" in read( "src/CableDyn_CAPI.h" ), - "Python ABI minor level": f"HOST_ROTATION_ABI_MINOR = {abi_minor}" in read( + "Python ABI minor level": f"HOST_FLUID_ABI_MINOR = {abi_minor}" in read( "python/cabledyn/_lib.py" ), "C header ABI version": f"CABLEDYN_CAPI_ABI_VERSION {abi_version}" in read( diff --git a/src/CableDyn_Banner.f90 b/src/CableDyn_Banner.f90 index 9dae0f70..f57d0d66 100644 --- a/src/CableDyn_Banner.f90 +++ b/src/CableDyn_Banner.f90 @@ -25,7 +25,7 @@ PURE FUNCTION CD_Banner_Line(i) RESULT(s) CASE (1) s = ' ===================================================================' CASE (2) - s = ' CableDyn v0.4.0' + s = ' CableDyn v0.4.1' CASE (3) s = ' Geometrically exact finite-element statics and dynamics of' CASE (4) diff --git a/src/CableDyn_CAPI.f90 b/src/CableDyn_CAPI.f90 index cea26b21..61655166 100644 --- a/src/CableDyn_CAPI.f90 +++ b/src/CableDyn_CAPI.f90 @@ -15,12 +15,15 @@ MODULE CableDyn_CAPI USE, INTRINSIC :: IEEE_ARITHMETIC, ONLY: IEEE_IS_FINITE 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_Rigid6_Inventory, CD_Rod_Inventory, & - CD_Channel_Token_Parses, CD_Cable_Deck_Node + 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_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_BADINPUT, CD_AGG_NOT_INITIALIZED, CD_AGG_ALLOCFAIL, & + 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, & @@ -30,7 +33,7 @@ MODULE CableDyn_CAPI CD_FAST_CalcOutputDerivatives, CD_FAST_GetCoupledMotion, CD_FAST_IsInitialized, & CD_FAST_NCoupledDOF, CD_FAST_NPoints, CD_FAST_NLines, CD_FAST_OK, CD_FAST_BADINPUT, & CD_FAST_NOT_INITIALIZED, CD_FAST_ALLOCFAIL, CD_FAST_UpdatePointFluidFields - USE CableDyn_OpenFAST_HermiteFMF, ONLY: CD_HFMF_ModuleType + USE CableDyn_OpenFAST_HermiteFMF, ONLY: CD_HFMF_ModuleType, CD_HFMF_Section_Curvature, CD_HFMF_OK USE CableDyn_Precision, ONLY: wp, CD_ZERO, CD_ONE USE CableDyn_System, ONLY: CD_SystemType, CD_System_Line_NDOF, CD_Get_System_PointBlocks, CD_SYSTEM_OK, & CD_POINT_COUPLED @@ -49,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 @@ -68,6 +72,10 @@ MODULE CableDyn_CAPI PUBLIC :: CableDyn_RestoreState PUBLIC :: CableDyn_GetCoupledLayout PUBLIC :: CableDyn_InitDeckRotating + PUBLIC :: CableDyn_SetHostFluid + PUBLIC :: CableDyn_NFluidNodes + PUBLIC :: CableDyn_GetFluidNodePositions + PUBLIC :: CableDyn_UpdateFluidFields PUBLIC :: CableDyn_UpdateRotations PUBLIC :: CableDyn_GetCoupledMoments @@ -79,14 +87,14 @@ MODULE CableDyn_CAPI INTEGER(C_INT), PARAMETER :: CD_C_NOT_INITIALIZED = 5_C_INT INTEGER(C_INT), PARAMETER :: CD_C_VERSION_MAJOR = 0_C_INT INTEGER(C_INT), PARAMETER :: CD_C_VERSION_MINOR = 4_C_INT - INTEGER(C_INT), PARAMETER :: CD_C_VERSION_PATCH = 0_C_INT + INTEGER(C_INT), PARAMETER :: CD_C_VERSION_PATCH = 1_C_INT INTEGER(C_INT), PARAMETER :: CD_C_ABI_VERSION = 1_C_INT - CHARACTER(*), PARAMETER :: CD_C_VERSION_STRING = 'CableDyn 0.4.0 C-ABI 1' + CHARACTER(*), PARAMETER :: CD_C_VERSION_STRING = 'CableDyn 0.4.1 C-ABI 1' !> Minor extension level of ABI 1: 1 adds the in-process object queries, 2 the host-coupling !> calls (initialization at host positions, state save/restore, the coupled layout query), 3 !> the host rotations (initialization for a host that turns its coupled points, their - !> rotations, the coupled moments). - INTEGER(C_INT), PARAMETER :: CD_C_ABI_MINOR = 3_C_INT + !> rotations, the coupled moments), 4 the host fluid field at the nodes of a deck. + INTEGER(C_INT), PARAMETER :: CD_C_ABI_MINOR = 4_C_INT !> Object kinds of CableDyn_NObjects / CableDyn_GetObjectInfo. INTEGER(C_INT), PARAMETER :: CD_C_OBJ_LINE = 1_C_INT, CD_C_OBJ_POINT = 2_C_INT, CD_C_OBJ_BODY = 3_C_INT, & CD_C_OBJ_ROD = 4_C_INT @@ -95,7 +103,8 @@ MODULE CableDyn_CAPI CD_C_LINE_TENSION = 3_C_INT, CD_C_LINE_ACCELERATION = 4_C_INT, & CD_C_LINE_CURVATURE = 5_C_INT, CD_C_LINE_BEND_MOMENT = 6_C_INT, & CD_C_LINE_DECLINATION = 7_C_INT, CD_C_LINE_AZIMUTH = 8_C_INT, & - CD_C_LINE_SEGMENT_TENSION = 9_C_INT, CD_C_LINE_DECK_NODE = 10_C_INT + CD_C_LINE_SEGMENT_TENSION = 9_C_INT, CD_C_LINE_DECK_NODE = 10_C_INT, & + CD_C_LINE_SECTION_CURVATURE = 11_C_INT !> Capacity of the per-handle diagnostic retained for CableDyn_GetLastError. INTEGER, PARAMETER :: CD_C_MSG_LEN = 4096 @@ -155,6 +164,8 @@ END FUNCTION cabledyn_blas_threads_once REAL(wp), ALLOCATABLE :: fluid_velocity_work(:, :) REAL(wp), ALLOCATABLE :: fluid_acceleration_work(:, :) REAL(wp), ALLOCATABLE :: waterline_work(:) + ! CableDyn_GetLineValues: one value per node of the longest line read so far. + REAL(wp), ALLOCATABLE :: line_work(:) ! Decks with BODIES or RODS run on the coupled aggregate (use_agg): its moving points are the ! coupled DOFs, its step is the deck dtM (agg_dt), and agg_t is its committed time. LOGICAL :: use_agg = .FALSE. @@ -167,6 +178,11 @@ END FUNCTION cabledyn_blas_threads_once ! velocity and acceleration in global axes; identity and rest at the static start). ! agg_moment: the coupled moments' workspace of CableDyn_GetCoupledMoments. LOGICAL :: host_rot = .FALSE. + ! CableDyn_SetHostFluid: the next deck initialization takes the host's fluid field; agg_host_fluid + ! is what the live model was built with, and fluid_ready that it holds a field (the march + ! refuses a step without one: the deck's declared water would silently be still). + LOGICAL :: host_fluid = .FALSE., agg_host_fluid = .FALSE., fluid_ready = .FALSE. + LOGICAL :: saved_fluid_ready = .FALSE. REAL(wp), ALLOCATABLE :: agg_dcm(:, :, :), agg_om(:, :), agg_al(:, :), agg_moment(:, :) ! Point-route line inventory for the object queries: the deck-line-id -> system-line map ! (pt_obj, 0 = no such line; pt_is_cable all .FALSE.) the shared channel evaluator resolves @@ -185,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 @@ -312,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 @@ -330,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 @@ -341,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 @@ -389,9 +427,12 @@ SUBROUTINE init_deck_serialised(handle, deck_path_ptr, deck_path_len, caller, er ! A deck with BODIES or RODS (standalone rules: a motionFile-free deck with dtM) runs on the ! coupled aggregate; every other deck keeps the point-system route and its diagnostics. ! A deck with finite-EI lines likewise runs on the aggregate (its Hermite cables). - CALL CD_Deck_Query_dtM(TRIM(deck_path), dtm, has_dtm, es, em, standalone_scan=.TRUE., n_bodies_rods=n_objects, & + ! With a host fluid field the deck is read as the aggregate reads it, a caller-driven deck, so + ! the SEASTATE and WaveKinMod 2 water policies of a host reach it. + CALL CD_Deck_Query_dtM(TRIM(deck_path), dtm, has_dtm, es, em, standalone_scan=.NOT. h%host_fluid, & + n_bodies_rods=n_objects, & n_finite_ei=n_finite) - IF (es == CD_DECKDRV_OK .AND. (n_objects > 0 .OR. n_finite > 0)) THEN + IF (es == CD_DECKDRV_OK .AND. (n_objects > 0 .OR. n_finite > 0 .OR. h%host_fluid)) THEN CALL init_deck_aggregate(h, TRIM(deck_path), has_dtm, dtm, caller, rot, err_stat, positions) RETURN END IF @@ -423,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 @@ -513,8 +554,11 @@ SUBROUTINE init_deck_aggregate(h, deck_path, has_dtm, dtm, caller, rotating, err RETURN END IF ! An absent positions argument stays absent through the optional coupled_positions. + ! The deck's own current is marched here, unless the host supplies the field: then it sets + ! the static start only. CALL CD_AGG_Init_From_Deck(new_agg, deck_path, dtm, es, em, coupled_positions=positions, & - host_orientation=rotating) + host_orientation=rotating, external_fluid=h%host_fluid, & + deck_current=.NOT. h%host_fluid, static_deck_current=h%host_fluid) IF (es /= CD_AGG_OK) THEN CALL map_agg_status(es, err_stat) CALL store_status(h, err_stat, caller//': '//TRIM(em)) @@ -556,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) @@ -578,6 +623,8 @@ SUBROUTINE init_deck_aggregate(h, deck_path, has_dtm, dtm, caller, rotating, err CALL MOVE_ALLOC(al, h%agg_al) CALL MOVE_ALLOC(moment, h%agg_moment) h%host_rot = rotating + h%agg_host_fluid = h%host_fluid + h%fluid_ready = .FALSE. CALL move_aggregate(new_agg, h%agg) CALL clear_point_line_map(h) h%use_agg = .TRUE. @@ -587,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 @@ -645,6 +711,8 @@ SUBROUTINE drop_aggregate(h) IF (ALLOCATED(h%agg_pos)) DEALLOCATE (h%agg_pos, h%agg_vel, h%agg_acc, h%agg_load) IF (ALLOCATED(h%agg_dcm)) DEALLOCATE (h%agg_dcm, h%agg_om, h%agg_al, h%agg_moment) h%host_rot = .FALSE. + h%agg_host_fluid = .FALSE. + h%fluid_ready = .FALSE. END SUBROUTINE drop_aggregate SUBROUTINE map_agg_status(agg_stat, err_stat) @@ -719,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 @@ -847,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) @@ -1051,8 +1121,9 @@ SUBROUTINE CableDyn_UpdatePointFluidFields(handle, fluid_velocity_ptr, fluid_acc err_stat = validate_handle(handle, h) IF (err_stat /= CD_C_OK) RETURN IF (h%use_agg) THEN - CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_UpdatePointFluidFields: point fluid fields are not '// & - 'available on a deck with BODIES or RODS (still water)', err_stat) + CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_UpdatePointFluidFields: a deck with finite-EI lines, BODIES or '// & + 'RODS runs on the coupled aggregate, which takes no point fluid fields; supply the '// & + 'fluid at its nodes with CableDyn_SetHostFluid and CableDyn_UpdateFluidFields', err_stat) RETURN END IF IF (n_point < 0_C_INT) THEN @@ -1101,6 +1172,161 @@ SUBROUTINE CableDyn_UpdatePointFluidFields(handle, fluid_velocity_ptr, fluid_acc CALL store_status(h, err_stat, em) END SUBROUTINE CableDyn_UpdatePointFluidFields + SUBROUTINE CableDyn_SetHostFluid(handle, enable, err_stat) BIND(C, NAME='CableDyn_SetHostFluid') + !! Choose, before a deck initialization, whether the host supplies the ambient fluid + !! (nonzero) or the deck does (0, the default). It applies to every following CableDyn_InitDeck, + !! CableDyn_InitDeckAt and CableDyn_InitDeckRotating of the handle. With it, the deck runs on + !! the coupled aggregate whatever its lines; its waves are refused, a steady deck current sets + !! the static start only, and CableDyn_UpdateFluidFields supplies the whole field of the march. + TYPE(C_PTR), VALUE :: handle + INTEGER(C_INT), VALUE :: enable + INTEGER(C_INT), INTENT(OUT) :: err_stat + TYPE(CD_C_Handle), POINTER :: h + + err_stat = validate_handle(handle, h) + IF (err_stat /= CD_C_OK) RETURN + h%host_fluid = enable /= 0_C_INT + CALL store_status(h, CD_C_OK, '') + END SUBROUTINE CableDyn_SetHostFluid + + FUNCTION CableDyn_NFluidNodes(handle, err_stat) RESULT(n) BIND(C, NAME='CableDyn_NFluidNodes') + !! The number of nodes at which the host supplies the fluid field (every line node, then the + !! bodies, rods and hydrodynamic points of the deck), or zero for a null or uninitialized + !! handle and for a handle without a host fluid field. + TYPE(C_PTR), VALUE :: handle + INTEGER(C_INT), INTENT(OUT) :: err_stat + INTEGER(C_INT) :: n + TYPE(CD_C_Handle), POINTER :: h + INTEGER :: es + CHARACTER(CD_C_MSG_LEN) :: em + + n = 0_C_INT + IF (.NOT. query_ready(handle, h, 'CableDyn_NFluidNodes', err_stat)) RETURN + IF (h%agg_host_fluid) THEN + n = INT(CD_AGG_NFluidNodes(h%agg, es, em), C_INT) + CALL map_agg_status(es, err_stat) + CALL store_status(h, err_stat, em) + ELSE + CALL store_status(h, CD_C_OK, '') + END IF + END FUNCTION CableDyn_NFluidNodes + + SUBROUTINE CableDyn_GetFluidNodePositions(handle, xyz_ptr, n_node, err_stat) & + BIND(C, NAME='CableDyn_GetFluidNodePositions') + !! The committed position of every fluid node (x, y, z triples, global axes): where the host + !! evaluates the field of the coming step. + TYPE(C_PTR), VALUE :: handle, xyz_ptr + INTEGER(C_INT), VALUE :: n_node + INTEGER(C_INT), INTENT(OUT) :: err_stat + TYPE(CD_C_Handle), POINTER :: h + REAL(C_DOUBLE), POINTER :: xyz_c(:) + INTEGER :: es, n, p + CHARACTER(CD_C_MSG_LEN) :: em + + IF (.NOT. host_fluid_call(handle, h, 'CableDyn_GetFluidNodePositions', n_node, n, err_stat)) RETURN + IF (n == 0) THEN + CALL store_status(h, CD_C_OK, '') + RETURN + END IF + IF (.NOT. C_ASSOCIATED(xyz_ptr)) THEN + CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_GetFluidNodePositions: output pointer is null', err_stat) + RETURN + END IF + CALL ensure_c_fluid_workspace(h, n, err_stat) + IF (err_stat /= CD_C_OK) RETURN + CALL CD_AGG_GetFluidNodePositions(h%agg, h%fluid_velocity_work(:, 1:n), es, em) + CALL map_agg_status(es, err_stat) + CALL store_status(h, err_stat, em) + IF (err_stat /= CD_C_OK) RETURN + CALL C_F_POINTER(xyz_ptr, xyz_c, [3*n]) + DO p = 1, n + xyz_c(3*p - 2:3*p) = REAL(h%fluid_velocity_work(:, p), C_DOUBLE) + END DO + END SUBROUTINE CableDyn_GetFluidNodePositions + + SUBROUTINE CableDyn_UpdateFluidFields(handle, fluid_velocity_ptr, fluid_acceleration_ptr, waterline_z_ptr, & + dynamic_pressure_ptr, n_node, err_stat) & + BIND(C, NAME='CableDyn_UpdateFluidFields') + !! The ambient fluid at every fluid node (CableDyn_GetFluidNodePositions), held until the next + !! call: velocity and acceleration as x, y, z triples in global axes, the free-surface + !! elevation, and the wave dynamic pressure (may be NULL; rods use it for their end caps). + !! The first call, before the first step, is the field at t = 0 and sets the start + !! acceleration; later calls are the field of the coming step, the field at t + dt at the + !! step-start positions. The density is the deck's. + TYPE(C_PTR), VALUE :: handle, fluid_velocity_ptr, fluid_acceleration_ptr, waterline_z_ptr + TYPE(C_PTR), VALUE :: dynamic_pressure_ptr + INTEGER(C_INT), VALUE :: n_node + INTEGER(C_INT), INTENT(OUT) :: err_stat + TYPE(CD_C_Handle), POINTER :: h + REAL(C_DOUBLE), POINTER :: velocity_c(:), acceleration_c(:), waterline_c(:), pressure_c(:) + INTEGER :: es, n, p + CHARACTER(CD_C_MSG_LEN) :: em + + IF (.NOT. host_fluid_call(handle, h, 'CableDyn_UpdateFluidFields', n_node, n, err_stat)) RETURN + IF (n > 0 .AND. .NOT. (C_ASSOCIATED(fluid_velocity_ptr) .AND. C_ASSOCIATED(fluid_acceleration_ptr) .AND. & + C_ASSOCIATED(waterline_z_ptr))) THEN + CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_UpdateFluidFields: input pointer is null', err_stat) + RETURN + END IF + CALL ensure_c_fluid_workspace(h, n, err_stat) + IF (err_stat /= CD_C_OK) RETURN + IF (n > 0) THEN + CALL C_F_POINTER(fluid_velocity_ptr, velocity_c, [3*n]) + CALL C_F_POINTER(fluid_acceleration_ptr, acceleration_c, [3*n]) + CALL C_F_POINTER(waterline_z_ptr, waterline_c, [n]) + DO p = 1, n + h%fluid_velocity_work(:, p) = REAL(velocity_c(3*p - 2:3*p), wp) + h%fluid_acceleration_work(:, p) = REAL(acceleration_c(3*p - 2:3*p), wp) + END DO + h%waterline_work(1:n) = REAL(waterline_c, wp) + END IF + IF (n > 0 .AND. C_ASSOCIATED(dynamic_pressure_ptr)) THEN + CALL C_F_POINTER(dynamic_pressure_ptr, pressure_c, [n]) + CALL CD_AGG_SetFluidFields(h%agg, h%fluid_velocity_work(:, 1:n), h%fluid_acceleration_work(:, 1:n), & + h%waterline_work(1:n), es, em, dynamic_pressure=REAL(pressure_c, wp), & + initial_condition=.NOT. h%fluid_ready) + ELSE + CALL CD_AGG_SetFluidFields(h%agg, h%fluid_velocity_work(:, 1:n), h%fluid_acceleration_work(:, 1:n), & + h%waterline_work(1:n), es, em, initial_condition=.NOT. h%fluid_ready) + END IF + CALL map_agg_status(es, err_stat) + CALL store_status(h, err_stat, em) + IF (err_stat == CD_C_OK) h%fluid_ready = .TRUE. + END SUBROUTINE CableDyn_UpdateFluidFields + + LOGICAL FUNCTION host_fluid_call(handle, h, caller, n_node, n, err_stat) RESULT(ready) + !! Validate a host fluid call: an initialized model built for a host field, with n_node equal + !! to the fluid node count n. + TYPE(C_PTR), VALUE :: handle + TYPE(CD_C_Handle), POINTER, INTENT(OUT) :: h + CHARACTER(*), INTENT(IN) :: caller + INTEGER(C_INT), INTENT(IN) :: n_node + INTEGER, INTENT(OUT) :: n + INTEGER(C_INT), INTENT(OUT) :: err_stat + INTEGER :: es + CHARACTER(CD_C_MSG_LEN) :: em + + ready = .FALSE. + n = 0 + IF (.NOT. query_ready(handle, h, caller, err_stat)) RETURN + IF (.NOT. h%agg_host_fluid) THEN + CALL set_status(h, CD_C_BAD_INPUT, caller//': the model takes no host fluid field: call '// & + 'CableDyn_SetHostFluid before its deck initialization', err_stat) + RETURN + END IF + n = CD_AGG_NFluidNodes(h%agg, es, em) + IF (es /= CD_AGG_OK) THEN + CALL map_agg_status(es, err_stat) + CALL store_status(h, err_stat, em) + RETURN + END IF + IF (INT(n_node) /= n) THEN + CALL set_status(h, CD_C_BAD_INPUT, caller//': node count does not match CableDyn_NFluidNodes', err_stat) + RETURN + END IF + ready = .TRUE. + END FUNCTION host_fluid_call + SUBROUTINE CableDyn_Step(handle, dt, q_ptr, v_ptr, a_ptr, n_coupled_dof, converged, stalled, n_iter, err_stat) & BIND(C, NAME='CableDyn_Step') !! Advance one coupled time step. @@ -1121,6 +1347,11 @@ SUBROUTINE CableDyn_Step(handle, dt, q_ptr, v_ptr, a_ptr, n_coupled_dof, converg CALL coupled_inputs(handle, q_ptr, v_ptr, a_ptr, n_coupled_dof, h, q_c, v_c, a_c, err_stat) IF (err_stat /= CD_C_OK) RETURN IF (h%use_agg) THEN + IF (h%agg_host_fluid .AND. .NOT. h%fluid_ready) THEN + CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_Step: the model takes a host fluid field: call '// & + 'CableDyn_UpdateFluidFields before the first step', err_stat) + RETURN + END IF IF (n_coupled_dof > 0_C_INT) THEN h%agg_pos = RESHAPE(REAL(q_c, wp), SHAPE(h%agg_pos)) h%agg_vel = RESHAPE(REAL(v_c, wp), SHAPE(h%agg_vel)) @@ -1233,8 +1464,8 @@ SUBROUTINE CableDyn_CalcOutputDerivatives(handle, q_ptr, v_ptr, a_ptr, n_coupled CALL coupled_inputs(handle, q_ptr, v_ptr, a_ptr, n_coupled_dof, h, q_c, v_c, a_c, err_stat) IF (err_stat /= CD_C_OK) RETURN IF (h%use_agg) THEN - CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_CalcOutputDerivatives: load derivatives are not available on '// & - 'a deck with BODIES or RODS', err_stat) + CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_CalcOutputDerivatives: a deck with finite-EI lines, BODIES or '// & + 'RODS runs on the coupled aggregate, which has no load derivatives', err_stat) RETURN END IF IF (n_coupled_dof > 0_C_INT .AND. & @@ -1359,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, @@ -1559,39 +1817,32 @@ SUBROUTINE CableDyn_GetLineValues(handle, index, quantity, out_ptr, n_out, err_s !! quantity: CD_C_LINE_POSITION / VELOCITY / ACCELERATION (3 per node, xyz interleaved), !! TENSION / CURVATURE / BEND_MOMENT / DECLINATION / AZIMUTH (1 per node), or !! SEGMENT_TENSION (1 per segment), or DECK_NODE (1 per node: the deck node of each node of the - !! line's final mesh, the J of the node channels; 0 for a node a refinement added). Every quantity - !! covers every node of the final mesh. n_out must equal the value count exactly. + !! line's final mesh, the J of the node channels; 0 for a node a refinement added), or + !! SECTION_CURVATURE (2 per node, CurvX and CurvY interleaved; finite-EI lines). Every quantity + !! covers every node of the final mesh. n_out must equal the value count exactly. The node + !! channels are evaluated for the whole line at once (CD_Eval_Aggregate_Line_Values). TYPE(C_PTR), VALUE :: handle, out_ptr INTEGER(C_INT), VALUE :: index, quantity, n_out INTEGER(C_INT), INTENT(OUT) :: err_stat TYPE(CD_C_Handle), POINTER :: h REAL(C_DOUBLE), POINTER :: out_c(:) - INTEGER, ALLOCATABLE :: ids(:), nnodes(:), sub(:) - INTEGER :: nnode, nvals, line_id, node, c, es - REAL(wp) :: val - CHARACTER(CD_DECK_NAMELEN) :: token, stem + INTEGER :: nnode, nvals, line_id, node, es, n_done, bad CHARACTER(CD_C_MSG_LEN) :: em - CHARACTER(1) :: field - CHARACTER(1), PARAMETER :: xyz(3) = ['x', 'y', 'z'] IF (.NOT. query_ready(handle, h, 'CableDyn_GetLineValues', err_stat)) RETURN - CALL object_inventory(h, INT(CD_C_OBJ_LINE), ids, nnodes, sub, err_stat) - IF (err_stat /= CD_C_OK) RETURN - IF (index < 0_C_INT .OR. INT(index) >= SIZE(ids)) THEN + CALL line_at_index(h, INT(index), line_id, nnode) + IF (line_id < 1) THEN CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_GetLineValues: line index out of range', err_stat) RETURN END IF - line_id = ids(index + 1) - nnode = nnodes(index + 1) - field = 'p' SELECT CASE (quantity) CASE (CD_C_LINE_POSITION, CD_C_LINE_VELOCITY, CD_C_LINE_ACCELERATION) nvals = 3*nnode - IF (quantity == CD_C_LINE_VELOCITY) field = 'v' - IF (quantity == CD_C_LINE_ACCELERATION) field = 'a' CASE (CD_C_LINE_TENSION, CD_C_LINE_CURVATURE, CD_C_LINE_BEND_MOMENT, CD_C_LINE_DECLINATION, & CD_C_LINE_AZIMUTH, CD_C_LINE_DECK_NODE) nvals = nnode + CASE (CD_C_LINE_SECTION_CURVATURE) + nvals = 2*nnode CASE (CD_C_LINE_SEGMENT_TENSION) nvals = nnode - 1 CASE DEFAULT @@ -1619,6 +1870,34 @@ SUBROUTINE CableDyn_GetLineValues(handle, index, quantity, out_ptr, n_out, err_s RETURN END IF + IF (quantity == CD_C_LINE_SECTION_CURVATURE) THEN + ! one evaluation for the whole line, in internal node order (node nn is End A) + IF (.NOT. h%use_agg) THEN + CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_GetLineValues: the section curvature is defined on '// & + 'finite-EI lines', err_stat) + RETURN + END IF + IF (.NOT. h%agg%line_is_cable(line_id)) THEN + CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_GetLineValues: the section curvature is defined on '// & + 'finite-EI lines', err_stat) + RETURN + END IF + BLOCK + REAL(wp) :: kx(nnode), ky(nnode) + CALL CD_HFMF_Section_Curvature(h%agg%cables(h%agg%line_obj_index(line_id)), kx, ky, es, em) + IF (es /= CD_HFMF_OK) THEN + CALL set_status(h, CD_C_SOLVE_FAIL, 'CableDyn_GetLineValues: '//TRIM(em), err_stat) + RETURN + END IF + DO node = 1, nnode + out_c(2*node - 1) = REAL(kx(nnode + 1 - node), C_DOUBLE) + out_c(2*node) = REAL(ky(nnode + 1 - node), C_DOUBLE) + END DO + END BLOCK + CALL store_status(h, CD_C_OK, '') + RETURN + END IF + IF (quantity == CD_C_LINE_SEGMENT_TENSION) THEN BLOCK REAL(wp) :: ten(nvals) @@ -1640,31 +1919,35 @@ SUBROUTINE CableDyn_GetLineValues(handle, index, quantity, out_ptr, n_out, err_s RETURN END IF - DO node = 1, nnode - stem = TRIM(int_text(line_id))//'N'//TRIM(int_text(node)) - SELECT CASE (quantity) - CASE (CD_C_LINE_POSITION, CD_C_LINE_VELOCITY, CD_C_LINE_ACCELERATION) - DO c = 1, 3 - CALL eval_token(h, 'L'//TRIM(stem)//field//xyz(c), val, err_stat, mesh_node=.TRUE.) - IF (err_stat /= CD_C_OK) RETURN - out_c(3*(node - 1) + c) = REAL(val, C_DOUBLE) - END DO - CYCLE - CASE (CD_C_LINE_TENSION) - token = 'Ten'//TRIM(stem) - CASE (CD_C_LINE_CURVATURE) - token = 'Curv'//TRIM(stem) - CASE (CD_C_LINE_BEND_MOMENT) - token = 'BendMom'//TRIM(stem) - CASE (CD_C_LINE_DECLINATION) - token = 'L'//TRIM(stem)//'Dec' - CASE DEFAULT - token = 'L'//TRIM(stem)//'Azi' - END SELECT - CALL eval_token(h, token, val, err_stat, mesh_node=.TRUE.) - IF (err_stat /= CD_C_OK) RETURN - out_c(node) = REAL(val, C_DOUBLE) + ! the node channels, whose codes are the quantity codes + IF (.NOT. line_work_ready(h, nnode)) THEN + CALL set_status(h, CD_C_ALLOC_FAIL, 'CableDyn_GetLineValues: workspace allocation failed', err_stat) + RETURN + END IF + IF (h%use_agg) THEN + CALL CD_Eval_Aggregate_Line_Values(line_id, INT(quantity), h%agg%has_sys, h%agg%sys%fast%system, h%agg%cables, & + h%agg%line_is_cable, h%agg%line_obj_index, out_c, h%line_work, n_done, es, em) + ELSE + CALL CD_Eval_Aggregate_Line_Values(line_id, INT(quantity), .TRUE., h%fast%system, h%no_cables, h%pt_is_cable, & + h%pt_obj, out_c, h%line_work, n_done, es, em) + END IF + ! the first value that failed: its channel gives the status and the reason, as a token would + bad = 0 + DO node = 1, n_done + IF (.NOT. IEEE_IS_FINITE(out_c(node))) THEN + bad = node + EXIT + END IF END DO + IF (bad == 0 .AND. es /= CD_DECKDRV_OK) bad = n_done + 1 + IF (bad > 0) THEN + CALL line_value_failure(h, line_id, quantity, bad, err_stat) + IF (err_stat /= CD_C_OK) RETURN + CALL map_deck_status(es, err_stat) + IF (err_stat == CD_C_OK) err_stat = CD_C_SOLVE_FAIL + CALL store_status(h, err_stat, 'CableDyn_GetLineValues: '//TRIM(em)) + RETURN + END IF CALL store_status(h, CD_C_OK, '') END SUBROUTINE CableDyn_GetLineValues @@ -1864,7 +2147,8 @@ SUBROUTINE CableDyn_SaveState(handle, err_stat) BIND(C, NAME='CableDyn_SaveState !! Save the whole model into the handle's single save slot, replacing any earlier save: !! every line, point, body and rod state with its solver history (viscoelastic, Syrope and !! friction states, the last converged step), the point fluid fields, and on the aggregate - !! route the committed time and the host's rotations (CableDyn_UpdateRotations). The slot + !! route the committed time, the host's rotations (CableDyn_UpdateRotations) and the host's + !! fluid field (CableDyn_UpdateFluidFields). The slot !! holds a deep copy of the model object, independent of the step's own rollback buffers, !! so any number of steps may follow before the restore. !! A successful Init* call discards the slot; a failed InitDeck/InitDeckAt keeps it with the @@ -1891,6 +2175,7 @@ SUBROUTINE CableDyn_SaveState(handle, err_stat) BIND(C, NAME='CableDyn_SaveState END IF CALL copy_aggregate(h%agg, h%saved_agg) h%saved_agg_t = h%agg_t + h%saved_fluid_ready = h%fluid_ready ! the host's rotations are the inputs of the saved instant (CableDyn_UpdateRotations) IF (h%host_rot) THEN h%saved_dcm = h%agg_dcm @@ -1922,6 +2207,7 @@ SUBROUTINE CableDyn_RestoreState(handle, err_stat) BIND(C, NAME='CableDyn_Restor IF (h%use_agg) THEN CALL copy_aggregate(h%saved_agg, h%agg) h%agg_t = h%saved_agg_t + h%fluid_ready = h%saved_fluid_ready IF (h%host_rot) THEN h%agg_dcm = h%saved_dcm h%agg_om = h%saved_om @@ -2193,6 +2479,113 @@ LOGICAL FUNCTION query_ready(handle, h, caller, err_stat) RESULT(ready) ready = .TRUE. END FUNCTION query_ready + SUBROUTINE line_at_index(h, index, line_id, nnode) + !! Deck id and node count of entry `index` (0-based) of the line inventory (ascending deck id, + !! object_inventory's order) without building it; line_id = 0 when there is no such entry. + TYPE(CD_C_Handle), POINTER, INTENT(INOUT) :: h + INTEGER, INTENT(IN) :: index + INTEGER, INTENT(OUT) :: line_id, nnode + + line_id = 0 + nnode = 0 + IF (h%use_agg) THEN + CALL find_line(h%agg%line_is_cable, h%agg%line_obj_index) + ELSE + CALL find_line(h%pt_is_cable, h%pt_obj) + END IF + + CONTAINS + + SUBROUTINE find_line(is_cable, obj_index) + LOGICAL, ALLOCATABLE, INTENT(IN) :: is_cable(:) + INTEGER, ALLOCATABLE, INTENT(IN) :: obj_index(:) + INTEGER :: k, n + IF (index < 0 .OR. .NOT. (ALLOCATED(obj_index) .AND. ALLOCATED(is_cable))) RETURN + n = -1 + DO k = 1, SIZE(obj_index) + IF (obj_index(k) < 1) CYCLE + n = n + 1 + IF (n /= index) CYCLE + line_id = k + nnode = line_node_count(h, is_cable(k), obj_index(k)) + RETURN + END DO + END SUBROUTINE find_line + END SUBROUTINE line_at_index + + INTEGER FUNCTION line_node_count(h, is_cable, obj) RESULT(nnode) + !! Node count of a line of the handle's model: cable or system line obj. + TYPE(CD_C_Handle), POINTER, INTENT(INOUT) :: h + LOGICAL, INTENT(IN) :: is_cable + INTEGER, INTENT(IN) :: obj + INTEGER :: es + CHARACTER(CD_C_MSG_LEN) :: em + + IF (is_cable) THEN + nnode = h%agg%cables(obj)%line%nn + ELSE IF (h%use_agg) THEN + nnode = CD_System_Line_NDOF(h%agg%sys%fast%system, obj, es, em)/3 + ELSE + nnode = CD_System_Line_NDOF(h%fast%system, obj, es, em)/3 + END IF + END FUNCTION line_node_count + + LOGICAL FUNCTION line_work_ready(h, nnode) RESULT(ready) + !! Make the handle's line workspace hold nnode values; it only ever grows. + TYPE(CD_C_Handle), POINTER, INTENT(INOUT) :: h + INTEGER, INTENT(IN) :: nnode + INTEGER :: ios + + ready = .TRUE. + IF (ALLOCATED(h%line_work)) THEN + IF (SIZE(h%line_work) >= nnode) RETURN + DEALLOCATE (h%line_work) + END IF + ALLOCATE (h%line_work(nnode), STAT=ios) + ready = ios == 0 + END FUNCTION line_work_ready + + SUBROUTINE line_value_failure(h, line_id, quantity, value_index, err_stat) + !! Evaluate value value_index (1-based, in the array of CableDyn_GetLineValues) through its + !! channel, which sets the status and the message of a failed or non-finite node value. + TYPE(CD_C_Handle), POINTER, INTENT(INOUT) :: h + INTEGER, INTENT(IN) :: line_id, value_index + INTEGER(C_INT), INTENT(IN) :: quantity + INTEGER(C_INT), INTENT(OUT) :: err_stat + INTEGER :: node, c + REAL(wp) :: val + CHARACTER(CD_DECK_NAMELEN) :: token, stem + CHARACTER(1), PARAMETER :: xyz(3) = ['x', 'y', 'z'] + + node = value_index + c = 1 + IF (quantity == CD_C_LINE_POSITION .OR. quantity == CD_C_LINE_VELOCITY .OR. & + quantity == CD_C_LINE_ACCELERATION) THEN + node = (value_index - 1)/3 + 1 + c = value_index - 3*(node - 1) + END IF + stem = TRIM(int_text(line_id))//'N'//TRIM(int_text(node)) + SELECT CASE (quantity) + CASE (CD_C_LINE_POSITION) + token = 'L'//TRIM(stem)//'p'//xyz(c) + CASE (CD_C_LINE_VELOCITY) + token = 'L'//TRIM(stem)//'v'//xyz(c) + CASE (CD_C_LINE_ACCELERATION) + token = 'L'//TRIM(stem)//'a'//xyz(c) + CASE (CD_C_LINE_TENSION) + token = 'Ten'//TRIM(stem) + CASE (CD_C_LINE_CURVATURE) + token = 'Curv'//TRIM(stem) + CASE (CD_C_LINE_BEND_MOMENT) + token = 'BendMom'//TRIM(stem) + CASE (CD_C_LINE_DECLINATION) + token = 'L'//TRIM(stem)//'Dec' + CASE DEFAULT + token = 'L'//TRIM(stem)//'Azi' + END SELECT + CALL eval_token(h, token, val, err_stat, mesh_node=.TRUE.) + END SUBROUTINE line_value_failure + SUBROUTINE object_inventory(h, kind, ids, nnodes, sub, err_stat) !! The handle's objects of one kind: deck ids, node counts and subtypes. Lines are listed in !! ascending deck id, points in system order, bodies and rods in runtime order. @@ -2249,8 +2642,7 @@ SUBROUTINE object_inventory(h, kind, ids, nnodes, sub, err_stat) SUBROUTINE collect_lines(is_cable, obj_index) LOGICAL, ALLOCATABLE, INTENT(IN) :: is_cable(:) INTEGER, ALLOCATABLE, INTENT(IN) :: obj_index(:) - INTEGER :: k, n, obj, es - CHARACTER(CD_C_MSG_LEN) :: em + INTEGER :: k, n, obj IF (.NOT. (ALLOCATED(obj_index) .AND. ALLOCATED(is_cable))) THEN ALLOCATE (ids(0), nnodes(0), sub(0)) RETURN @@ -2263,15 +2655,8 @@ SUBROUTINE collect_lines(is_cable, obj_index) IF (obj < 1) CYCLE n = n + 1 ids(n) = k - sub(n) = 0 - IF (is_cable(k)) THEN - nnodes(n) = h%agg%cables(obj)%line%nn - sub(n) = 1 - ELSE IF (h%use_agg) THEN - nnodes(n) = CD_System_Line_NDOF(h%agg%sys%fast%system, obj, es, em)/3 - ELSE - nnodes(n) = CD_System_Line_NDOF(h%fast%system, obj, es, em)/3 - END IF + sub(n) = MERGE(1, 0, is_cable(k)) + nnodes(n) = line_node_count(h, is_cable(k), obj) END DO END SUBROUTINE collect_lines diff --git a/src/CableDyn_CAPI.h b/src/CableDyn_CAPI.h index 2ce3bef7..5b0da392 100644 --- a/src/CableDyn_CAPI.h +++ b/src/CableDyn_CAPI.h @@ -45,7 +45,7 @@ #define CABLEDYN_CAPI_VERSION_MAJOR 0 #define CABLEDYN_CAPI_VERSION_MINOR 4 -#define CABLEDYN_CAPI_VERSION_PATCH 0 +#define CABLEDYN_CAPI_VERSION_PATCH 1 #define CABLEDYN_CAPI_ABI_VERSION 1 #ifdef __cplusplus @@ -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,9 +138,9 @@ 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 (3: the host-coupling and host-rotation calls below are present - * too). */ -#define CABLEDYN_CAPI_ABI_MINOR 3 + * 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 #define CD_C_OBJ_POINT 2 @@ -151,6 +162,8 @@ int CableDyn_NLines(void *handle, int *err_stat); #define CD_C_LINE_DECK_NODE 10 /* 1 per mesh node: its deck node J, 0 if added by a * refinement (an earlier library rejects the * code with CD_C_BAD_INPUT) */ +#define CD_C_LINE_SECTION_CURVATURE 11 /* 2 per node, CurvX CurvY interleaved [1/m]; + * finite-EI lines (ABI minor 4) */ int CableDyn_GetAbiMinor(void); int CableDyn_NObjects(void *handle, int kind, int *err_stat); @@ -256,6 +269,34 @@ void CableDyn_UpdateRotations(void *handle, const double *dcm, void CableDyn_GetCoupledMoments(void *handle, double *coupled_moments, int n_coupled_dof, int *err_stat); +/* Host fluid field (ABI minor 4). The host evaluates the ambient fluid and hands + * it over at the nodes of the deck, on any deck with dtM and a seabed. Check + * CableDyn_GetAbiMinor() >= 4 before using these on a library of unknown + * vintage. */ + +/* Before a deck initialization: nonzero makes every following InitDeck, + * InitDeckAt and InitDeckRotating of the handle take the host's field (0, the + * default, keeps the deck's). A steady deck current then sets the static start + * only, and deck waves are refused. */ +void CableDyn_SetHostFluid(void *handle, int enable, int *err_stat); +/* The fluid node count; zero without a host fluid field. */ +int CableDyn_NFluidNodes(void *handle, int *err_stat); +/* The committed node positions, x y z triples in global axes: where the host + * evaluates the field of the coming step. */ +void CableDyn_GetFluidNodePositions(void *handle, double *xyz, int n_node, + int *err_stat); +/* The fluid at every node, held until the next call: velocity and acceleration + * as x y z triples in global axes, the free-surface elevation, and the wave + * dynamic pressure (may be NULL; rods use it for their end caps). Call it once + * with the t = 0 field before the first step, then before each step from t to + * t + dt with the field at t + dt at the positions of + * CableDyn_GetFluidNodePositions. A step before the first call is refused. */ +void CableDyn_UpdateFluidFields(void *handle, const double *fluid_velocity, + const double *fluid_acceleration, + const double *waterline_z, + const double *dynamic_pressure, int n_node, + int *err_stat); + #ifdef __cplusplus } #endif diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index c86ee353..bb3317d5 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -76,7 +76,7 @@ MODULE CableDyn_DeckDriver USE CableDyn_HermiteCable, ONLY: CD_HermiteCable_Curvature, & CD_HermiteCable_Peak_Curvature, CD_HermiteCable_Axial_Resultant, & CD_HermiteCable_Axial_Resultant_Range, CD_HermiteCable_Element, CD_HCABLE_OK, & - CD_HermiteCable_Gauss_Rule + CD_HCABLE_BADINPUT, CD_HermiteCable_Gauss_Rule USE CableDyn_HermiteCableStatic, ONLY: CD_HermiteCable_Static_Solve_AutoMesh, CD_HermiteCable_Static_Solve, & CD_HermiteCable_Prolong_Arc, CD_HermiteCable_Kink_Advice, & CD_HermiteCable_Element_Ratio, CD_HC_OSCILLATION_REL_TOL, & @@ -98,6 +98,8 @@ MODULE CableDyn_DeckDriver CD_HermiteCable_Axial_Damping_Resultant, & 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, & @@ -150,7 +152,8 @@ MODULE CableDyn_DeckDriver CD_Sea_Parse_Train, CD_Sea_Add_Train, CD_Sea_Synthesise, CD_Sea_Kinematics USE CableDyn_RangeOutput, ONLY: CD_RangeSet, CD_RangeLine, CD_Range_Setup, CD_Range_End, CD_Range_Needs_Sample, & CD_Range_Commit_Sample, CD_Range_Clearance, CD_Range_TDP_Evaluate, & - CD_Range_TDP_Reference, CD_Range_Write_Files, CD_Is_TDP_Channel, & + CD_Range_TDP_Reference, CD_Range_Write_Files, CD_Range_Discard_File, & + CD_Is_TDP_Channel, & CD_Parse_TDP_Channel, CD_RANGE_OK, CD_RANGE_BADINPUT, CD_RQ_TENSION, & CD_RQ_CURVATURE, CD_RQ_BEND, CD_RQ_DECLINATION, CD_RQ_CLEARANCE, CD_TDP_N, & CD_RQ_TORQUE, CD_RQ_TWIST @@ -192,19 +195,23 @@ MODULE CableDyn_DeckDriver REAL(wp), PARAMETER :: STATIC_STRAIN_NOTE_DEFAULT = 0.1_wp ! dtM advisor (axial_period_note): the fewest steps of dtM per axial period of a line REAL(wp), PARAMETER :: AXIAL_STEPS_PER_PERIOD = 6.0_wp + ! A point within this share of an element length of a STIFFENERS row end counts as on it. + 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 PUBLIC :: CD_Init_Deck_HermiteCable PUBLIC :: CD_Deck_VIV_Phase_Seed PUBLIC :: CD_Stiffener_Overlay + PUBLIC :: CD_Stiffener_Node_EI PUBLIC :: CD_Init_Deck_Aggregate PUBLIC :: CD_Deck_Query_dtM PUBLIC :: CD_End_Deck_Aggregate PUBLIC :: CD_DeckAggregateType - PUBLIC :: CD_LineInitSummary + PUBLIC :: CD_LineInitSummary, CD_INIT_NOTE_LEN ! Coupled Rigid6 runtime: the aggregate facade owns a CD_Rigid6RuntimeType and marches ! it over the EI=0 system each mooring step (opaque; RigidBodyState/DeckOptions stay private). PUBLIC :: CD_Rigid6RuntimeType @@ -258,7 +265,13 @@ MODULE CableDyn_DeckDriver ! In-process object queries of the C API (CableDyn_CAPI): segment tensions in the .t.out ! convention, and the body/rod inventories the Body/Rod channels resolve on. PUBLIC :: CD_Eval_Aggregate_Segment_Tensions, CD_Rigid6_Inventory, CD_Rod_Inventory, CD_Channel_Token_Parses - ! Range graphs of an aggregate run (the standalone mixed route samples and writes them). + PUBLIC :: CD_Eval_Aggregate_Line_Values + ! The refusal of the LINES flag r on a route that writes no range file. + CHARACTER(*), PARAMETER :: RANGE_FLAG_REFUSED = 'the LINES Outputs flag r (range graph) is written by the '// & + 'standalone driver and a single-turbine OpenFAST run; a FAST.Farm shared '// & + 'mooring or a C API run reports TDP and node channels instead' + ! Range graphs of an aggregate run (the standalone mixed route and the OpenFAST module sample + ! and write them). PUBLIC :: CD_Deck_Range_Sample_Aggregate, CD_Deck_Range_Write ! Diagnostic probe of the multibody march (verification tests): CD_Multibody_Probe_Arm makes ! the next multibody run start body 1 with the given velocity and angular velocity and record @@ -270,6 +283,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 @@ -343,7 +361,10 @@ MODULE CableDyn_DeckDriver INTEGER, PARAMETER :: MOTION_PAST_TMAX = -1 ! appended to the dtM/TMax-required errors of a standalone deck without TMax CHARACTER(*), PARAMETER :: STATIC_CHECK_HINT = & - '; to check a dynamic deck''s static start only, give dtM with TMax = 0' + '; to check a dynamic deck''s static start only, give TMax = 0' + ! The step a standalone TMax = 0 deck without dtM is built with. It marches no step, so no + ! result depends on the value (DeckOptions static_check_clock). + REAL(wp), PARAMETER :: STATIC_CHECK_DT = CD_ONE INTEGER, PARAMETER :: MOTION_POINT_ROWS = 0, MOTION_VESSEL_FILE = 1, MOTION_VESSEL_RAO = 2 ! Largest WaterKin WaveKinMod-1 record (TMax/dtWave samples) reduced to Fourier components. REAL(wp), PARAMETER :: MAX_WAVE_SAMPLES = 1.0e6_wp @@ -654,9 +675,17 @@ MODULE CableDyn_DeckDriver ! irregular seas; wave_eta_mean carries the zero-frequency waterline offset. REAL(wp), ALLOCATABLE :: wave_component_omega(:), wave_component_k(:) REAL(wp), ALLOCATABLE :: wave_component_amplitude(:), wave_component_phase(:) - ! exp(-2 k (wtrdpth + wave_eta_mean)) and the tail_sums of the table (CD_Component_Wave_Kinematics) + ! exp(-2 k d) at the depth d of the wave numbers (wtrdpth, plus wave_eta_mean for a WaterKin + ! record: component_level_shift) and the tail_sums of the table (CD_Component_Wave_Kinematics) REAL(wp), ALLOCATABLE :: wave_component_e2kd(:), wave_component_tail(:, :) + ! a short-crested table (WaveKin 7 rows of different directions): each component's direction + ! cosine and sine, in ascending frequency; unallocated when all share wave_direction + REAL(wp), ALLOCATABLE :: wave_component_cos(:), wave_component_sin(:) REAL(wp) :: wave_eta_mean = CD_ZERO + ! OPTION WaveRefX, WaveRefY: the point the phases of an elevation record or a component file + ! refer to (the record was measured there); the origin by default + REAL(wp) :: wave_ref_x = CD_ZERO, wave_ref_y = CD_ZERO + LOGICAL :: has_wave_ref = .FALSE. ! N-level vertical current profile (ascending z): from the two-point deck ! OPTION row or a MoorDyn WaterKin file's CurrentMod-1 depth table. REAL(wp), ALLOCATABLE :: current_profile_z(:) @@ -680,6 +709,9 @@ MODULE CableDyn_DeckDriver LOGICAL :: has_wtrdpth = .FALSE. LOGICAL :: has_bathymetry_file = .FALSE. LOGICAL :: has_dtm = .FALSE., has_tmax = .FALSE. + ! A static check without dtM: a standalone deck with TMax = 0 and no dtM runs the TMax = 0 + ! route on the nominal STATIC_CHECK_DT, so has_dtm is set; the deck itself gives no step. + LOGICAL :: static_check_clock = .FALSE. LOGICAL :: has_motion_file = .FALSE. ! Source of the prescribed motion when has_motion_file: MOTION_POINT_ROWS (motionFile), ! MOTION_VESSEL_FILE (vesselMotion 6-DOF record) or MOTION_VESSEL_RAO (vesselRAO table @@ -846,6 +878,9 @@ MODULE CableDyn_DeckDriver ! CONNECTIONS Pretwist of those ends; has_roll when some row gives one. LOGICAL :: has_roll = .FALSE. REAL(wp), ALLOCATABLE :: roll(:, :) + ! A static check without dtM (DeckOptions static_check_clock): the grid is the t = 0 row, and + ! a later record row is not used, whatever its time step. + LOGICAL :: start_row_only = .FALSE. END TYPE TYPE :: DriverProgress @@ -1119,6 +1154,12 @@ MODULE CableDyn_DeckDriver TYPE(HydroUpdateWorkspace) :: work END TYPE + ! Length of the initialisation notes of one line, and of a caller's buffer for them + ! (CD_LineInitSummary%note). A line reports each of its notes at most once per end, fewer than + ! 32 in all, and none is longer than 1024 characters, so every note fits and the omission marker + ! of append_note_line is never reached. + INTEGER, PARAMETER :: CD_INIT_NOTE_LEN = 32*1024 + TYPE :: CD_LineInitSummary !! Engineering quantities at the converged static initial condition. The !! fairlead force is the force exerted by the line on public End A and the @@ -1134,8 +1175,10 @@ MODULE CableDyn_DeckDriver ! finite-EI line (compression, mesh refinement, accepted residual floor, a run past its ! 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. - ! It holds a whole cable note (1024) and the two others. - CHARACTER(2048) :: note = '' + 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 @@ -1173,7 +1216,7 @@ MODULE CableDyn_DeckDriver TYPE(CD_SystemType) :: sys TYPE(CD_HFMF_ModuleType), ALLOCATABLE :: cables(:) ! Static-initialisation notes of each finite-EI cable (build_one_hermite_cable). - CHARACTER(1024), ALLOCATABLE :: cable_note(:) + CHARACTER(CD_INIT_NOTE_LEN), ALLOCATABLE :: cable_note(:) ! Deck point ids of each cable's End A and End B (Point

F channels). INTEGER, ALLOCATABLE :: cable_points(:, :) REAL(wp) :: gravity = CD_ZERO, rho_water = CD_ZERO @@ -1230,7 +1273,7 @@ MODULE CableDyn_DeckDriver TYPE(CD_RodRuntimeType) :: rod ! Point3 buoys: stepped as Free points of the EI=0 system, but not placed by the static solve LOGICAL :: has_point3 = .FALSE. - ! Range graphs (LINES flag r, standalone only) and the touchdown references of the + ! Range graphs (LINES flag r) and the touchdown references of the ! TDP channels, keyed by deck line id and fixed at the converged initial state. TYPE(CD_RangeSet) :: ranges ! The deck's own prescribed motion (prescribed_motion: the standalone driver), in the @@ -1347,6 +1390,7 @@ SUBROUTINE CD_Init_Deck_System(deck_path, system, dtM, gravity, rho_water, ErrSt CHARACTER(NAMELEN), ALLOCATABLE :: channels(:) TYPE(CD_BathymetryType) :: bathymetry LOGICAL :: ext_march + INTEGER :: i ErrStat = CD_DECKDRV_OK ErrMsg = '' dtM = CD_ZERO @@ -1361,6 +1405,10 @@ SUBROUTINE CD_Init_Deck_System(deck_path, system, dtM, gravity, rho_water, ErrSt ErrStat, ErrMsg, caller_driven=ext_march, env_gravity=env_gravity, & env_rho_water=env_rho_water, env_wtrdpth=env_wtrdpth) IF (ErrStat /= CD_DECKDRV_OK) RETURN + ! the caller steps the system and writes no file: the flag does not pass silently + IF (ANY([(line_output_has_flag(lines(i)%outputs, 'r'), i=1, SIZE(lines))])) THEN + CALL fail(ErrStat, ErrMsg, RANGE_FLAG_REFUSED); RETURN + END IF IF (opts%waterkin_seastate) THEN CALL fail(ErrStat, ErrMsg, 'CD_Init_Deck_System cannot consume WaveKinMod 2/SEASTATE; use the '// & 'OpenFAST aggregate with its external SeaState field') @@ -1384,7 +1432,7 @@ SUBROUTINE CD_Init_Deck_System(deck_path, system, dtM, gravity, rho_water, ErrSt RETURN END IF END IF - dtM = MERGE(opts%dtM, CD_ZERO, opts%has_dtm) + dtM = MERGE(opts%dtM, CD_ZERO, opts%has_dtm .AND. .NOT. opts%static_check_clock) gravity = opts%g rho_water = opts%rhoW IF (deck_has_finite_ei(types, sections)) THEN @@ -1424,8 +1472,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(:) @@ -3136,8 +3337,18 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, END IF IF (ALLOCATED(deck_map)) CALL MOVE_ALLOC(deck_map, cable%deck_node) cable%azimuth_signed = ln%azi_signed - ! a stiffened line: the outputs report the cable's own share of the bend moment - IF (ALLOCATED(ei_cable)) cable%line%EI_line = ei_cable + ! a stiffened line: the outputs report the cable's own share of the bend moment, and take + ! the stiffness at the node on the final mesh (public node j is internal node nn + 1 - j, and + ! its End B side the internal element before the node) + IF (ALLOCATED(ei_cable)) THEN + cable%line%EI_line = ei_cable + BLOCK + REAL(wp), ALLOCATABLE :: ei_public(:, :) + ALLOCATE (ei_public(2, SIZE(l0) + 1)) + CALL CD_Stiffener_Node_EI(ln%stf, l0(SIZE(l0):1:-1), ei_cable(SIZE(l0):1:-1), ei_public) + cable%line%EI_node = ei_public(2:1:-1, SIZE(l0) + 1:1:-1) + END BLOCK + END IF IF (ANY(ba > CD_ZERO)) THEN CALL CD_HFMF_Set_Axial_Damping(cable, ba, es, em) IF (es /= CD_HFMF_OK) THEN @@ -7375,7 +7586,8 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi ptfm_init, external_fluid, coupled_positions, & n_turbines, turbine_ref_pos, farm_ptfm_init, forbid_deck_ambient, run_tmax, & range_files, host_orientation, static_current, prescribed_motion, & - static_run, deck_current, deck_turbines) + static_run, deck_current, deck_turbines, static_deck_current, & + range_discard_root) !! Parse a MIXED deck ONCE and partition its lines into {EI=0 mooring} and !! {finite-EI cable}, returning the raw parts the OpenFAST aggregate facade wraps: !! the EI=0 mooring SYSTEM built from the mooring subset (over ALL deck points, the @@ -7442,9 +7654,15 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi ! self-drive ambient forcing against that scope. Independent of external_fluid ! (which rejects the same options for the opposite reason: double-counting). LOGICAL, INTENT(IN), OPTIONAL :: forbid_deck_ambient - ! range_files: the caller writes the range graphs of the LINES flag r (the standalone - ! mixed route); a coupled host does not, so the flag fails closed there. + ! range_files: the caller samples and writes the range graphs of the LINES flag r (the + ! standalone mixed route, the single-turbine OpenFAST module); a host that does not (a FAST.Farm + ! shared mooring, the C API) leaves it out, and the flag fails closed there. LOGICAL, INTENT(IN), OPTIONAL :: range_files + ! range_discard_root: the output root of a run that starts here: the range files of an earlier + ! run go as soon as the parsed deck names them, so nothing that fails after this point (the + ! statics, the host's own checks) leaves one to be taken for this run's. A rebuild of a run + ! already under way leaves it out. + CHARACTER(*), INTENT(IN), OPTIONAL :: range_discard_root ! host_orientation: the caller passes each moving point's orientation with every update ! (the OpenFAST and FAST.Farm shells, and the standalone mixed route, which holds the frame ! or turns it with its vesselMotion table), which turns the fairlead frame of a finite-EI @@ -7475,6 +7693,11 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi ! every node before each step (CD_Deck_Current_Sample_System/_Cables). A coupled host keeps ! supplying its own field and passes no deck_current. LOGICAL, INTENT(IN), OPTIONAL :: deck_current + ! static_deck_current (with external_fluid): the deck's steady current (a current OPTION, a + ! WaterKin CurrentMod 1 table or MoorDyn-C Currents 1) sets the static start only, as a + ! host's static_current does, and the dynamics take the host field alone. A library host + ! that evaluates its own field passes it; a current that varies in time is refused. + LOGICAL, INTENT(IN), OPTIONAL :: static_deck_current ! deck_turbines: the caller is the standalone driver, which runs a TURBINES deck by itself: the ! deck's TURBINES rows place every Turbine point at ref_J + PtfmInit_J(p) as the parse of @@ -7496,6 +7719,10 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi TYPE(DeckOptions) :: opts ! opts plus the host's steady current (static_current): the options of the static builders TYPE(DeckOptions) :: opts_static + ! the deck's steady current of static_deck_current, kept for the static builders + LOGICAL :: keep_static_current, kept_has_current, kept_has_profile + REAL(wp) :: kept_velocity(3) + REAL(wp), ALLOCATABLE :: kept_profile_z(:), kept_profile_velocity(:, :) CHARACTER(NAMELEN), ALLOCATABLE :: channels(:) TYPE(CD_BathymetryType) :: bathymetry TYPE(DeckFailure), ALLOCATABLE :: fails(:) @@ -7526,6 +7753,17 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi env_rho_water=env_rho_water, env_wtrdpth=env_wtrdpth, allow_turbine=.NOT. own_turbines, & failures=fails, controls=ctrls, run_tmax=run_tmax, host_coupled_objects=.TRUE., & static_run=static_run) + IF (ErrStat == CD_DECKDRV_OK .AND. PRESENT(range_discard_root)) THEN + DO i = 1, SIZE(lines) + IF (.NOT. line_output_has_flag(lines(i)%outputs, 'r')) CYCLE + CALL CD_Range_Discard_File(range_discard_root, lines(i)%id, ErrStat, ErrMsg) + IF (ErrStat /= CD_RANGE_OK) THEN + ErrStat = CD_DECKDRV_BADINPUT + RETURN + END IF + END DO + ErrStat = CD_DECKDRV_OK + END IF IF (ErrStat == CD_DECKDRV_OK) THEN host_turns = .FALSE. IF (PRESENT(host_orientation)) host_turns = host_orientation @@ -7633,6 +7871,27 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi END IF own_current = .FALSE. IF (PRESENT(deck_current)) own_current = deck_current + keep_static_current = .FALSE. + IF (PRESENT(static_deck_current)) keep_static_current = static_deck_current + keep_static_current = keep_static_current .AND. (opts%has_current .OR. opts%has_current_profile) + IF (keep_static_current) THEN + IF (opts%has_current_grid) THEN + CALL fail(ErrStat, ErrMsg, 'CD_Init_Deck_Aggregate: the deck current varies in time, and a host '// & + 'fluid field replaces it in the dynamics: supply it through the host field and '// & + 'remove it from the deck'); RETURN + END IF + kept_has_current = opts%has_current + kept_has_profile = opts%has_current_profile + kept_velocity = opts%current_velocity + IF (ALLOCATED(opts%current_profile_z)) THEN + kept_profile_z = opts%current_profile_z + kept_profile_velocity = opts%current_profile_velocity + END IF + opts%has_current = .FALSE. + opts%has_current_profile = .FALSE. + opts%mdc_currents = 0 + opts%waterkin_current_mode = -1 + END IF IF (opts%mdc_wave_kin > 0 .AND. own_current) THEN CALL fail(ErrStat, ErrMsg, 'the MoorDyn-C WaveKin '//TRIM(int_to_str(opts%mdc_wave_kin))//' waves are '// & 'not supported for a standalone mixed EI=0/finite-EI deck (it takes a deck current but no '// & @@ -7645,10 +7904,10 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi 'aggregate route (coupled runs take water kinematics from the host)') RETURN END IF - ! The deck's own current (deck_current) is marched by the standalone driver over lines between - ! Fixed/Coupled/Vessel points held or moved by the deck's motion, from the deck pose: no host - ! field, placement or farm, and no body, rod or Connect/Free point (those decks run on the - ! standalone multibody or point-system march, which take the current themselves). + ! The deck's own current (deck_current) is marched by the standalone driver and the C API over + ! lines between Fixed/Coupled/Vessel points: no host field, platform placement or farm, and no + ! body, rod or Connect/Free point (those decks run on the standalone multibody or point-system + ! march, which take the current themselves). IF (own_current .AND. (opts%has_current .OR. opts%has_current_profile)) THEN IF (PRESENT(external_fluid)) THEN IF (external_fluid) THEN @@ -7656,9 +7915,9 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi 'host fluid field (external_fluid) would double-count the current'); RETURN END IF END IF - IF (nturb > 0 .OR. PRESENT(ptfm_init) .OR. PRESENT(coupled_positions) .OR. PRESENT(static_current)) THEN - CALL fail(ErrStat, ErrMsg, 'CD_Init_Deck_Aggregate: a deck-owned current (deck_current) is marched '// & - 'from the deck pose of a standalone deck (no host placement, farm or host current)'); RETURN + IF (nturb > 0 .OR. PRESENT(ptfm_init) .OR. PRESENT(static_current)) THEN + CALL fail(ErrStat, ErrMsg, 'CD_Init_Deck_Aggregate: a deck-owned current (deck_current) takes no '// & + 'platform placement, farm or host current'); RETURN END IF IF (deck_has_dynamic_points(points) .OR. SIZE(bodies) > 0 .OR. SIZE(rods) > 0) THEN CALL fail(ErrStat, ErrMsg, 'CD_Init_Deck_Aggregate: a deck-owned current (deck_current) drives lines '// & @@ -8034,6 +8293,15 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi ! The static builders' options: the deck's, plus the host's steady current when the host ! current drives this deck (a WaterKin CurrentMod 1 file keeps its own profile instead). opts_static = opts + IF (keep_static_current) THEN + opts_static%has_current = kept_has_current + opts_static%has_current_profile = kept_has_profile + opts_static%current_velocity = kept_velocity + IF (ALLOCATED(kept_profile_z)) THEN + opts_static%current_profile_z = kept_profile_z + opts_static%current_profile_velocity = kept_profile_velocity + END IF + END IF IF (PRESENT(static_current)) THEN IF (.NOT. opts%external_fluid) THEN CALL fail(ErrStat, ErrMsg, 'CD_Init_Deck_Aggregate: static_current needs the host fluid field '// & @@ -8266,9 +8534,32 @@ 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 - ! asks for the range graphs -- also warns about coarse sections, as the single-topology driver does. + ! takes the deck's own current -- also warns about coarse sections, as the single-topology driver does. BLOCK INTEGER :: il CHARACTER(1024) :: strain_note @@ -8276,12 +8567,12 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi CALL static_strain_note(agg%init_lines(il)%line_id, agg%init_lines(il)%fair_tension, sections, types, opts, & strain_note) IF (LEN_TRIM(strain_note) > 0) CALL append_note_line(agg%init_lines(il)%note, strain_note) + ! a run that marches no step has no time step to advise on + IF (static_only) CYCLE CALL axial_period_note(agg%init_lines(il)%line_id, dt, sections, types, strain_note) IF (LEN_TRIM(strain_note) > 0) CALL append_note_line(agg%init_lines(il)%note, strain_note) END DO - IF (PRESENT(range_files)) THEN - IF (range_files) CALL warn_coarse_sections(lines, sections) - END IF + IF (own_current) CALL warn_coarse_sections(lines, sections) END BLOCK ! Per moving slot, the driving turbine (0 outside farm mode) -- the SAME @@ -8316,10 +8607,17 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi wants_range = ANY([(line_output_has_flag(lines(i)%outputs, 'r'), i=1, SIZE(lines))]) wants_tdp = ANY([(CD_Is_TDP_Channel(to_lower(channels(i))), i=1, SIZE(channels))]) IF (wants_range .AND. .NOT. files_ok) THEN - CALL fail(ErrStat, ErrMsg, 'the LINES Outputs flag r (range graph) is written by the standalone driver; '// & - 'a coupled run reports TDP and node channels instead') + CALL fail(ErrStat, ErrMsg, RANGE_FLAG_REFUSED) CALL CD_End_Deck_Aggregate(agg); RETURN END IF + ! the run time of a coupled host is known here, not at the deck's own checks; the host's TMax + ! can have passed through single precision + IF (wants_range .AND. opts%has_range_start .AND. PRESENT(run_tmax)) THEN + IF (opts%range_start > run_tmax*(CD_ONE + 1.0e-6_wp) .AND. run_tmax > CD_ZERO) THEN + CALL fail(ErrStat, ErrMsg, 'OPTION RangeStart exceeds TMax: the range window would hold no output time') + CALL CD_End_Deck_Aggregate(agg); RETURN + END IF + END IF IF (wants_range .OR. wants_tdp) THEN IF (opts%has_bathymetry_file) THEN CALL load_bathymetry_file(path_join(deck_dir(deck_path), opts%bathymetry_file), bathymetry, ErrStat, & @@ -8330,10 +8628,10 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi END IF IF (ALLOCATED(agg%cables)) THEN CALL range_setup_aggregate(agg%ranges, lines, channels, opts, agg%has_sys, agg%sys, agg%cables, & - agg%line_is_cable, agg%line_obj_index, ErrStat, ErrMsg, bathymetry) + agg%line_is_cable, agg%line_obj_index, ErrStat, ErrMsg, bathymetry, run_tmax) ELSE CALL range_setup_aggregate(agg%ranges, lines, channels, opts, agg%has_sys, agg%sys, no_cables, & - agg%line_is_cable, agg%line_obj_index, ErrStat, ErrMsg, bathymetry) + agg%line_is_cable, agg%line_obj_index, ErrStat, ErrMsg, bathymetry, run_tmax) END IF IF (opts%has_bathymetry_file) CALL CD_End_Bathymetry(bathymetry) IF (ErrStat /= CD_DECKDRV_OK) THEN @@ -8741,7 +9039,8 @@ SUBROUTINE CD_Deck_Query_dtM(deck_path, dtM, has_dtm, ErrStat, ErrMsg, env_gravi host_coupled_objects=.NOT. scan_standalone) IF (ErrStat /= CD_DECKDRV_OK) RETURN IF (PRESENT(n_bodies_rods)) n_bodies_rods = SIZE(bodies) + SIZE(rods) - has_dtm = opts%has_dtm + ! the deck's own step: the nominal step of a static check is no clock for a caller to march on + has_dtm = opts%has_dtm .AND. .NOT. opts%static_check_clock IF (has_dtm) dtM = opts%dtM IF (PRESENT(has_tmax)) has_tmax = opts%has_tmax IF (PRESENT(tmax)) THEN @@ -9218,32 +9517,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 @@ -9405,7 +9679,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) @@ -11002,6 +11277,186 @@ SUBROUTINE CD_Stiffener_Overlay(stf, l0, diam, ei, mpl) END DO END SUBROUTINE CD_Stiffener_Overlay + SUBROUTINE CD_Stiffener_Node_EI(stf, l0, ei_line, ei_node) + !! The local bending stiffness EI(s) of a line with bend stiffeners (ln%stf columns) at its + !! nodes, in public order (End A first): ei_node(1, j) is the limit of EI(s) at node j from + !! the End A side, on element j - 1, and ei_node(2, j) from the End B side, on element j. + !! Each is the element's line-type stiffness ei_line (no sleeve) plus, where a stiffener row + !! covers the arc next to the node on that side, E pi/64 (OD^4 - ID^4) of that row at the + !! node. The per-element stiffness of CD_Stiffener_Overlay steps at every node of a taper; + !! these limits differ only where the section does: at the bare tip, between stiffener rows + !! of different end diameter, bore or modulus, and between line types of different EI. A + !! node within a billionth of an element length of a row end counts as on it, which absorbs + !! the round-off of summing the element lengths and decides only which row a limit reads. + !! The limit on the missing side of a line end stays 0. + REAL(wp), INTENT(IN) :: stf(:, :), l0(:), ei_line(:) + REAL(wp), INTENT(OUT) :: ei_node(:, :) + REAL(wp) :: arc(SIZE(l0) + 1, 2) + INTEGER :: ne, j, e, side + + ne = SIZE(l0) + ! each node's arc from either end, summed from that end + arc(1, 1) = CD_ZERO + arc(ne + 1, 2) = CD_ZERO + DO j = 1, ne + arc(j + 1, 1) = arc(j, 1) + l0(j) + arc(ne + 1 - j, 2) = arc(ne + 2 - j, 2) + l0(ne + 1 - j) + END DO + ei_node = CD_ZERO + DO j = 1, ne + 1 + DO side = 1, 2 + e = j - 2 + side + IF (e < 1 .OR. e > ne) CYCLE + ei_node(side, j) = ei_line(e) + sleeve_ei_at(stf, arc(j, 1), arc(j, 2), side == 2, STF_ARC_TOL*l0(e)) + END DO + END DO + END SUBROUTINE CD_Stiffener_Node_EI + + PURE FUNCTION sleeve_ei_at(stf, arc_a, arc_b, toward_b, tol) RESULT(ei) + !! The sleeve's share E pi/64 (OD^4 - ID^4) of the bending stiffness at a point arc_a from + !! End A and arc_b from End B, 0 where no stiffener row covers it: the limit from the End B + !! side (toward_b) or the End A side. A point within tol of a row end counts as on it. + REAL(wp), INTENT(IN) :: stf(:, :), arc_a, arc_b, tol + LOGICAL, INTENT(IN) :: toward_b + REAL(wp) :: ei + REAL(wp), PARAMETER :: PI_64 = 0.0490873852123405193_wp ! pi/64 + REAL(wp) :: a, t, od + INTEGER :: k, iend + LOGICAL :: tipward, covers + + ei = CD_ZERO + DO k = 1, SIZE(stf, 2) + iend = NINT(stf(1, k)) + a = MERGE(arc_a, arc_b, iend == 1) + ! does this side of the point look toward the stiffener's tip, or toward its root? + tipward = (iend == 1) .EQV. toward_b + IF (tipward) THEN + covers = a >= stf(2, k) - tol .AND. a < stf(3, k) - tol + ELSE + covers = a > stf(2, k) + tol .AND. a <= stf(3, k) + tol + END IF + IF (.NOT. covers) CYCLE + t = MIN(CD_ONE, MAX(CD_ZERO, (a - stf(2, k))/(stf(3, k) - stf(2, k)))) + od = (CD_ONE - t)*stf(4, k) + t*stf(5, k) + ei = stf(7, k)*PI_64*(od**4 - stf(6, k)**4) + RETURN + END DO + END FUNCTION sleeve_ei_at + + SUBROUTINE stiffened_element_peak(stf, qr, l0, ei_element, ei_line, ei_resolve, arc_a, arc_b, m0, m1, peak, u_peak, & + ErrStat, ErrMsg) + !! Largest curvature of the section along one element under a bend stiffener, + !! (1 + e(u)) |M(u)|/EI(u), the stretch e running linearly between the end nodes. + !! The element's interpolation bends under its one stiffness ei_element, so it gives the + !! section neither its curvature nor, at its ends, its moment to second order: ei_element + !! times the interpolation's curvature is the linear fit of the moment over the element. Its + !! mean is the element's mean moment, and with the recovered moments m0 and m1 of the end nodes + !! (u = 0 at End B's side, as the internal element runs) it fixes the quadratic + !! M(u) = (1 - u) m0 + u m1 + 6 u (1 - u) (mean - (m0 + m1)/2). EI(u) is ei_line, the line + !! type's, plus the sleeve; arc_a and arc_b are the arcs of the element's End A side from End + !! A and of its End B side from End B. Where EI(u) is below ei_resolve the recovered moment + !! cannot give the curvature, as at a node (node_values): the section takes the + !! interpolation's curvature times ei_element/EI(u) there. The field is smooth between the row + !! ends inside the element: each such piece is sampled, reading EI(u) from inside the piece up + !! to its ends, and its best sample refined by golden section. + REAL(wp), INTENT(IN) :: stf(:, :), qr(12), l0, ei_element, ei_line, ei_resolve, arc_a, arc_b, m0(3), m1(3) + REAL(wp), INTENT(OUT) :: peak, u_peak + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + INTEGER, PARAMETER :: NSAMPLE = 128, NGOLDEN = 40 + REAL(wp), PARAMETER :: GOLDEN = 0.6180339887498949_wp + REAL(wp) :: knot(2*SIZE(stf, 2) + 2), u, ua, ub, x1, x2, g1, g2, f1, f2, f, best, u_best, kappa, kv(3), bubble(3) + INTEGER :: nk, k, c, i, p + + peak = CD_ZERO + u_peak = CD_ZERO + CALL CD_HermiteCable_Curvature(qr, l0, 0.5_wp, kappa, ErrStat, ErrMsg, kvec=kv) + IF (ErrStat /= CD_HCABLE_OK) RETURN + ! the section moment is EI kappa n/|r'|, as the recovered ones are; the curvature above has + ! checked that the tangent r' at the midpoint does not vanish + bubble = ei_element*kv/NORM2(1.5_wp*(qr(7:9) - qr(1:3))/l0 - 0.25_wp*(qr(4:6) + qr(10:12))) - 0.5_wp*(m0 + m1) + IF (.NOT. ALL(IEEE_IS_FINITE(bubble))) THEN + ErrStat = CD_HCABLE_BADINPUT; ErrMsg = 'stiffened_element_peak: the element moments are not finite'; RETURN + END IF + knot(1) = CD_ZERO + nk = 1 + DO k = 1, SIZE(stf, 2) + DO c = 2, 3 + u = MERGE(CD_ONE - (stf(c, k) - arc_a)/l0, (stf(c, k) - arc_b)/l0, NINT(stf(1, k)) == 1) + IF (.NOT. (u > STF_ARC_TOL .AND. u < CD_ONE - STF_ARC_TOL)) CYCLE + ! in ascending order + i = nk + DO WHILE (knot(i) > u) + knot(i + 1) = knot(i) + i = i - 1 + END DO + knot(i + 1) = u + nk = nk + 1 + END DO + END DO + nk = nk + 1 + knot(nk) = CD_ONE + DO p = 1, nk - 1 + ua = knot(p) + ub = knot(p + 1) + IF (ub - ua <= STF_ARC_TOL) CYCLE + best = -CD_ONE + u_best = ua + DO i = 0, NSAMPLE + u = ua + (ub - ua)*REAL(i, wp)/REAL(NSAMPLE, wp) + f = section_curvature(u, 2*i > NSAMPLE) + IF (ErrStat /= CD_HCABLE_OK) RETURN + IF (f > best) THEN + best = f + u_best = u + END IF + END DO + IF (best > peak) THEN + peak = best + u_peak = u_best + END IF + x1 = MAX(ua, u_best - (ub - ua)/REAL(NSAMPLE, wp)) + x2 = MIN(ub, u_best + (ub - ua)/REAL(NSAMPLE, wp)) + DO i = 1, NGOLDEN + g1 = x2 - GOLDEN*(x2 - x1) + g2 = x1 + GOLDEN*(x2 - x1) + f1 = section_curvature(g1, 2.0_wp*g1 > ua + ub) + f2 = section_curvature(g2, 2.0_wp*g2 > ua + ub) + IF (ErrStat /= CD_HCABLE_OK) RETURN + IF (f1 > peak) THEN + peak = f1 + u_peak = g1 + END IF + IF (f2 > peak) THEN + peak = f2 + u_peak = g2 + END IF + IF (f1 > f2) THEN + x2 = g2 + ELSE + x1 = g1 + END IF + END DO + END DO + + CONTAINS + + REAL(wp) FUNCTION section_curvature(uu, from_b) + REAL(wp), INTENT(IN) :: uu + LOGICAL, INTENT(IN) :: from_b ! the piece lies on the End B side of uu (u runs from End B) + REAL(wp) :: ei + ei = ei_line + sleeve_ei_at(stf, arc_a + (CD_ONE - uu)*l0, arc_b + uu*l0, from_b, STF_ARC_TOL*l0) + IF (.NOT. ei > CD_ZERO) ei = ei_element + IF (ei >= ei_resolve) THEN + section_curvature = ((CD_ONE - uu)*NORM2(qr(4:6)) + uu*NORM2(qr(10:12)))* & + NORM2((CD_ONE - uu)*m0 + uu*m1 + 6.0_wp*uu*(CD_ONE - uu)*bubble)/ei + ELSE + CALL CD_HermiteCable_Curvature(qr, l0, uu, section_curvature, ErrStat, ErrMsg) + section_curvature = section_curvature*(ei_element/ei) + END IF + END FUNCTION section_curvature + END SUBROUTINE stiffened_element_peak + SUBROUTINE remap_elem_by_reference_arc(a, l0_parent, l0_child) !! Map a piecewise-constant per-element field from a parent reference mesh to !! any nested child mesh. Child midpoints select the parent, so interfaces are @@ -12147,7 +12602,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 @@ -12161,9 +12617,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 @@ -12934,6 +13461,15 @@ SUBROUTINE parse_deck(path, types, bodies, rod_types, rods, points, lines, secti CALL normalize_stock_endpoint_order(points, lines, sections, types) tors_marched = ext_march IF (PRESENT(static_run)) tors_marched = ext_march .AND. .NOT. static_run + ! A static check marches no step, so it needs no dtM: on the routes no caller marches + ! (.NOT. tors_marched: the standalone ones, the mixed aggregate's static_run among them) + ! TMax = 0 alone asks for it. A host's clock is its own. + IF ((.NOT. tors_marched) .AND. opts%has_tmax .AND. .NOT. opts%has_dtm .AND. opts%tmax >= CD_ZERO .AND. & + .NOT. opts%tmax > CD_ZERO) THEN + opts%has_dtm = .TRUE. + opts%dtM = STATIC_CHECK_DT + opts%static_check_clock = .TRUE. + END IF CALL resolve_one_end_torsion(types, bodies, rods, points, lines, sections, opts, tors_marched) CALL finalize_and_validate(types, bodies, rod_types, rods, points, lines, sections, opts, channels, ext_march, & static_finite_ok, ErrStat, ErrMsg, channel_lines) @@ -13647,9 +14183,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 @@ -13765,8 +14302,12 @@ SUBROUTINE read_moordyn_c_kinematics(dir, opts, ErrStat, ErrMsg) !! the mean-level offset, as MoorDyn-C does. Waves travel along +x. !! - WaveKin 7, wave_frequencies.txt (`omega Re Im [beta]` rows, the first at omega = 0): !! each row is the component |c| sin(omega t - k d + arg c) of MoorDyn-C's SUM_COMPONENTS - !! mode, d = x cos(beta) + y sin(beta); the omega = 0 row is the mean-level offset. The rows - !! must share one direction beta. + !! mode, d = x cos(beta) + y sin(beta) with the row's own direction beta; the omega = 0 row + !! is the mean-level offset and its beta has no effect. Rows that share one direction are + !! a long-crested table in file order; rows that differ a short-crested table in ascending + !! frequency, the order the tail bound of the sums needs. + !! The mean level of either file is added to the elevation alone, which the stretching then + !! maps, and the wave numbers stay those of WtrDpth, as in MoorDyn-C (component_level_shift). !! - Currents 1, current_profile.txt (`z ux uy uz` rows after its header rows): the steady !! profile, linear in z and held beyond its ends, as MoorDyn-C's current grid. !! - Currents 2, current_profile_dynamic.txt (constructDynamicCurrentGrid of MoorDyn-C's @@ -13786,9 +14327,11 @@ SUBROUTINE read_moordyn_c_kinematics(dir, opts, ErrStat, ErrMsg) TYPE(DeckOptions), INTENT(INOUT) :: opts INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg - REAL(wp), ALLOCATABLE :: tab(:, :), eta(:), comp_w(:), comp_a(:), comp_p(:) + REAL(wp), ALLOCATABLE :: tab(:, :), eta(:), comp_w(:), comp_a(:), comp_p(:), comp_b(:) REAL(wp) :: dt, tl, frac, ca, sa, angle, scale, beta, mean_level, arg INTEGER :: n, nt, j, k, nc, bracket + INTEGER, ALLOCATABLE :: order(:) + LOGICAL :: one_direction INTEGER(KIND=8) :: kj CHARACTER(32) :: fname ! the longest row of a MoorDyn-C current table (a time and 3 nz velocities per row) @@ -13838,9 +14381,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 @@ -13896,15 +14440,15 @@ SUBROUTINE read_moordyn_c_kinematics(dir, opts, ErrStat, ErrMsg) IF (ABS(tab(1, 1)) > CD_ZERO) THEN CALL fail(ErrStat, ErrMsg, 'WaveKin 7: the first wave_frequencies.txt row must be at omega = 0'); RETURN END IF + ! the omega = 0 row has no direction of its own: the first component's stands for the table beta = tab(4, 1) + IF (ABS(tab(4, 2) - beta) > 1.0e-12_wp) beta = tab(4, 2) + one_direction = .TRUE. DO j = 1, n IF (ABS(tab(4, j)) > TWO_PI) THEN CALL fail(ErrStat, ErrMsg, 'WaveKin 7: wave_frequencies.txt directions are radians within +-2 pi'); RETURN END IF - IF (ABS(tab(4, j) - beta) > 1.0e-12_wp) THEN - CALL fail(ErrStat, ErrMsg, 'WaveKin 7: wave_frequencies.txt rows with different directions '// & - '(short-crested seas) are not supported; give every row one direction'); RETURN - END IF + IF (j > 1 .AND. ABS(tab(4, j) - beta) > 1.0e-12_wp) one_direction = .FALSE. IF (j > 1) THEN IF (.NOT. tab(1, j) > CD_ZERO) THEN CALL fail(ErrStat, ErrMsg, 'WaveKin 7: wave_frequencies.txt frequencies after the first row '// & @@ -13917,6 +14461,14 @@ SUBROUTINE read_moordyn_c_kinematics(dir, opts, ErrStat, ErrMsg) comp_w = tab(1, 2:n) comp_a = SQRT(tab(2, 2:n)**2 + tab(3, 2:n)**2) comp_p = 0.25_wp*TWO_PI - ATAN2(tab(3, 2:n), tab(2, 2:n)) + IF (.NOT. one_direction) THEN + ALLOCATE (order(n - 1)) + CALL sort_index(comp_w, order) + comp_w = comp_w(order) + comp_a = comp_a(order) + comp_p = comp_p(order) + comp_b = tab(4, order + 1) + END IF CALL install_components() IF (ErrStat /= CD_DECKDRV_OK) RETURN END SELECT @@ -13965,11 +14517,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 @@ -14341,7 +14894,7 @@ END FUNCTION real_to_str SUBROUTINE install_components() !! The component waves (omega, amplitude, phase in CableDyn's cos(k d - omega t + phase) - !! convention), their direction beta [rad] and the mean level. + !! convention), their direction beta [rad], or each their own comp_b, and the mean level. IF (.NOT. (ALL(IEEE_IS_FINITE(comp_w)) .AND. ALL(IEEE_IS_FINITE(comp_a)) .AND. & ALL(IEEE_IS_FINITE(comp_p)) .AND. IEEE_IS_FINITE(mean_level))) THEN CALL fail(ErrStat, ErrMsg, 'WaveKin '//TRIM(int_to_str(opts%mdc_wave_kin))//': '//TRIM(fname)// & @@ -14363,6 +14916,11 @@ SUBROUTINE install_components() opts%wave_component_phase = comp_p opts%wave_eta_mean = mean_level opts%wave_direction = beta*45.0_wp/ATAN(CD_ONE) + IF (ALLOCATED(opts%wave_component_cos)) DEALLOCATE (opts%wave_component_cos, opts%wave_component_sin) + IF (ALLOCATED(comp_b)) THEN + opts%wave_component_cos = COS(comp_b) + opts%wave_component_sin = SIN(comp_b) + END IF opts%wave_kind = WAVE_COMPONENTS opts%has_wave = .TRUE. END SUBROUTINE install_components @@ -16583,6 +17141,12 @@ SUBROUTINE apply_option(line, opts, ErrStat, ErrMsg) ELSE CALL rd(val, opts%wv_hi_cutoff, ErrStat, ErrMsg) END IF + CASE ('waverefx') + CALL rd(val, opts%wave_ref_x, ErrStat, ErrMsg) + opts%has_wave_ref = .TRUE. + CASE ('waverefy') + CALL rd(val, opts%wave_ref_y, ErrStat, ErrMsg) + opts%has_wave_ref = .TRUE. CASE ('ramptime', 'ramp_time', 'tramp') CALL rd(val, opts%ramp_time, ErrStat, ErrMsg) CASE ('maxstrain', 'max_strain') @@ -17087,7 +17651,7 @@ SUBROUTINE check_option_row_value(kw, val, ErrStat, ErrMsg) rule = 'non-negative' CASE ('aslaidtension', 'as_laid_tension') rule = 'non-negative' - CASE ('layazimuth', 'lay_azimuth') + CASE ('layazimuth', 'lay_azimuth', 'waverefx', 'waverefy') rule = 'finite' CASE ('rhoinf', 'rho_inf') rule = 'unit' @@ -17547,13 +18111,16 @@ SUBROUTINE finalize_checks(types, bodies, rod_types, rods, points, lines, sectio CALL fail(ErrStat, ErrMsg, 'OPTION RangeStart needs a line with the LINES Outputs flag r (range graph)') RETURN END IF - IF (.NOT. opts%has_tmax) THEN - CALL fail(ErrStat, ErrMsg, 'OPTION RangeStart needs a dynamic deck (TMax)'); RETURN - END IF - ! at TMax = 0 (a dynamic deck's static start) the range graphs hold the t = 0 row - IF (opts%range_start > opts%tmax .AND. opts%tmax > CD_ZERO) THEN - CALL fail(ErrStat, ErrMsg, 'OPTION RangeStart exceeds TMax: the range window would hold no output time') - RETURN + ! a coupled host owns the run time: CD_Init_Deck_Aggregate holds the window against it + IF (.NOT. caller_driven) THEN + IF (.NOT. opts%has_tmax) THEN + CALL fail(ErrStat, ErrMsg, 'OPTION RangeStart needs a dynamic deck (TMax)'); RETURN + END IF + ! at TMax = 0 (a dynamic deck's static start) the range graphs hold the t = 0 row + IF (opts%range_start > opts%tmax .AND. opts%tmax > CD_ZERO) THEN + CALL fail(ErrStat, ErrMsg, 'OPTION RangeStart exceeds TMax: the range window would hold no output time') + RETURN + END IF END IF END IF @@ -17718,6 +18285,15 @@ SUBROUTINE finalize_checks(types, bodies, rod_types, rods, points, lines, sectio END IF END IF END IF + IF (opts%has_wave_ref) THEN + IF (ABS(opts%wave_ref_x) > MAX_COORDINATE .OR. ABS(opts%wave_ref_y) > MAX_COORDINATE) THEN + CALL fail(ErrStat, ErrMsg, 'WaveRefX and WaveRefY must be at most 1e6 m in magnitude'); RETURN + END IF + IF (.NOT. (opts%has_wave .AND. opts%wave_kind == WAVE_COMPONENTS)) THEN + CALL fail(ErrStat, ErrMsg, 'WaveRefX and WaveRefY place the reference point of an elevation record or a '// & + 'component file (WaterKin WaveKinMod 1, WaveKin 3 or 7); this deck has none'); RETURN + END IF + END IF IF (opts%has_wave) THEN IF (.NOT. opts%has_dtm) THEN CALL fail(ErrStat, ErrMsg, 'deck waves require dtM and TMax'); RETURN @@ -21896,7 +22472,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)) @@ -26586,8 +27164,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)) @@ -31622,9 +32200,10 @@ SUBROUTINE write_hermite_static_element_profile(out_root, lines, cables, ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg INTEGER :: unit, ios, li, public_element, internal_element, ne, es - REAL(wp) :: qr(12), l0, arc_start, arc_end, curvature_peak, u_curvature_internal, & + REAL(wp) :: qr(12), l0, ei_element, arc_start, arc_end, curvature_peak, u_curvature_internal, & u_curvature_public, arc_peak, moment_peak, minimum_resultant, minimum_u_internal, & minimum_u_public, maximum_resultant, maximum_u_internal, maximum_u_public + REAL(wp), ALLOCATABLE :: moments(:, :) CHARACTER(1024) :: em CHARACTER(LEN(out_root) + 20) :: path CHARACTER(1) :: tab @@ -31650,12 +32229,32 @@ SUBROUTINE write_hermite_static_element_profile(out_root, lines, cables, ErrStat DO li = 1, SIZE(lines) ne = cables(li)%line%ne arc_start = CD_ZERO + IF (ALLOCATED(moments)) DEALLOCATE (moments) + IF (ALLOCATED(lines(li)%stf)) THEN + ALLOCATE (moments(3, ne + 1)) + CALL CD_HermiteCable_Dyn_Node_Recovery(cables(li)%line, es, em, moment_vector=moments) + IF (es /= CD_HCDYN_OK) THEN + CLOSE (unit, STATUS='DELETE') + CALL fail_solve(ErrStat, ErrMsg, 'continuous curvature extraction failed: '//TRIM(em)); RETURN + END IF + END IF DO public_element = 1, ne internal_element = ne - public_element + 1 l0 = cables(li)%line%l0(internal_element) qr(1:6) = cables(li)%line%q(6*(internal_element - 1) + 1:6*(internal_element - 1) + 6) qr(7:12) = cables(li)%line%q(6*internal_element + 1:6*internal_element + 6) - CALL CD_HermiteCable_Peak_Curvature(qr, l0, curvature_peak, u_curvature_internal, es, em) + ! an element under a bend stiffener: the section's curvature, not the element's + ei_element = cables(li)%line%EI(internal_element) + IF (ALLOCATED(lines(li)%stf) .AND. ei_element > CD_ZERO .AND. & + ABS(ei_element - cables(li)%line%EI_line(internal_element)) > CD_ZERO) THEN + CALL stiffened_element_peak(lines(li)%stf, qr, l0, ei_element, cables(li)%line%EI_line(internal_element), & + cables(li)%line%ei_resolve, arc_start, & + SUM(cables(li)%line%l0(1:internal_element - 1)), & + moments(:, internal_element), moments(:, internal_element + 1), & + curvature_peak, u_curvature_internal, es, em) + ELSE + CALL CD_HermiteCable_Peak_Curvature(qr, l0, curvature_peak, u_curvature_internal, es, em) + END IF IF (es /= CD_HCABLE_OK) THEN CLOSE (unit, STATUS='DELETE') CALL fail_solve(ErrStat, ErrMsg, 'continuous curvature extraction failed: '//TRIM(em)); RETURN @@ -32155,7 +32754,7 @@ SUBROUTINE deck_wave_kinematics(opts, x, y, z, time, eta, velocity, acceleration INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg REAL(wp), INTENT(OUT), OPTIONAL :: pdyn - REAL(wp) :: ramp, ramp_rate + REAL(wp) :: ramp, ramp_rate, shift IF (opts%has_wave .AND. .NOT. opts%wave_cache_ready) THEN CALL fail(ErrStat, ErrMsg, 'deck wave kinematics called before wave precompute') @@ -32191,24 +32790,29 @@ SUBROUTINE deck_wave_kinematics(opts, x, y, z, time, eta, velocity, acceleration CALL CD_Stream_Kinematics(opts%stream, x, y, z, time, ramp, eta, velocity, acceleration, ErrStat, ErrMsg, & pdyn) CASE (WAVE_COMPONENTS) + ! the mean of a WaterKin record raises the still-water level (shift); that of a MoorDyn-C + ! file is added to the surface alone (eta_offset), the wave numbers staying at WtrDpth + shift = component_level_shift(opts) IF (ramp < CD_ONE) THEN - CALL CD_Component_Wave_Kinematics(x, y, z - opts%wave_eta_mean, time, & - opts%wtrdpth + opts%wave_eta_mean, opts%wave_direction, .TRUE., & + CALL CD_Component_Wave_Kinematics(x - opts%wave_ref_x, y - opts%wave_ref_y, z - shift, time, & + opts%wtrdpth + shift, opts%wave_direction, .TRUE., & opts%wave_component_omega, opts%wave_component_k, & opts%wave_component_amplitude, opts%wave_component_phase, & eta, velocity, acceleration, ErrStat, ErrMsg, inputs_validated=.TRUE., & amplitude_scale=ramp, pdyn=pdyn, exp_m2kd=opts%wave_component_e2kd, & - tail_sums=opts%wave_component_tail) + tail_sums=opts%wave_component_tail, comp_cos=opts%wave_component_cos, & + comp_sin=opts%wave_component_sin, eta_offset=opts%wave_eta_mean - shift) ELSE - CALL CD_Component_Wave_Kinematics(x, y, z - opts%wave_eta_mean, time, & - opts%wtrdpth + opts%wave_eta_mean, opts%wave_direction, .TRUE., & + CALL CD_Component_Wave_Kinematics(x - opts%wave_ref_x, y - opts%wave_ref_y, z - shift, time, & + opts%wtrdpth + shift, opts%wave_direction, .TRUE., & opts%wave_component_omega, opts%wave_component_k, & opts%wave_component_amplitude, opts%wave_component_phase, & eta, velocity, acceleration, ErrStat, ErrMsg, inputs_validated=.TRUE., & pdyn=pdyn, exp_m2kd=opts%wave_component_e2kd, & - tail_sums=opts%wave_component_tail) + tail_sums=opts%wave_component_tail, comp_cos=opts%wave_component_cos, & + comp_sin=opts%wave_component_sin, eta_offset=opts%wave_eta_mean - shift) END IF - eta = eta + opts%wave_eta_mean + eta = eta + shift CASE DEFAULT eta = CD_ZERO velocity = CD_ZERO @@ -32220,6 +32824,15 @@ SUBROUTINE deck_wave_kinematics(opts, x, y, z, time, eta, velocity, acceleration acceleration = acceleration + (ramp_rate/ramp)*velocity END SUBROUTINE deck_wave_kinematics + PURE REAL(wp) FUNCTION component_level_shift(opts) RESULT(shift) + !! The rise of the still-water level under a component table: the mean of a WaterKin + !! WaveKinMod 1 record, so its wave numbers are those of WtrDpth plus the mean; zero for a + !! MoorDyn-C kinematics file (WaveKin 3, 7), whose mean MoorDyn-C adds to the elevation alone. + TYPE(DeckOptions), INTENT(IN) :: opts + shift = opts%wave_eta_mean + IF (opts%mdc_wave_kin > 0) shift = CD_ZERO + END FUNCTION component_level_shift + PURE SUBROUTINE deck_wave_ramp(ramp_time, time, ramp, ramp_rate) !! Half-cosine start-up ramp r(t) = (1 - cos(pi*t/T))/2 on [0, T], 1 afterwards, and !! its rate dr/dt; T <= 0 disables the ramp (r = 1). r and dr/dt are continuous, so @@ -32595,12 +33208,12 @@ SUBROUTINE precompute_deck_wave(opts, ErrStat, ErrMsg) END IF DO k = 1, SIZE(opts%wave_component_omega) CALL CD_Solve_Dispersion_Wavenumber(opts%wave_component_omega(k), & - opts%wtrdpth + opts%wave_eta_mean, opts%g, & + opts%wtrdpth + component_level_shift(opts), opts%g, & opts%wave_component_k(k), ErrStat, ErrMsg) IF (ErrStat /= CD_DECKDRV_OK) EXIT END DO IF (ErrStat == CD_DECKDRV_OK) THEN - opts%wave_component_e2kd = EXP(-2.0_wp*(opts%wave_component_k*(opts%wtrdpth + opts%wave_eta_mean))) + opts%wave_component_e2kd = EXP(-2.0_wp*(opts%wave_component_k*(opts%wtrdpth + component_level_shift(opts)))) CALL set_component_tail(opts%wave_component_omega, opts%wave_component_k, opts%wave_component_amplitude, & opts%wave_component_tail) END IF @@ -34444,6 +35057,7 @@ SUBROUTINE read_motion_file(path, points, opts, motion, ErrStat, ErrMsg, include IF (PRESENT(include_body_points)) accept_body_points = include_body_points read_roll = .FALSE. IF (PRESENT(with_roll)) read_roll = with_roll + motion%start_row_only = opts%static_check_clock CALL grid_step_count(opts%tmax, opts%dtM, nstep, grid_ok) IF (.NOT. grid_ok .OR. nstep < 0) THEN CALL fail(ErrStat, ErrMsg, 'motionFile needs finite dtM > 0 and TMax >= 0 with a representable step count') @@ -35056,6 +35670,10 @@ SUBROUTINE synthesize_vessel_rao(path, opts, motion, rref, vref, aref, ErrStat, CASE (WAVE_COMPONENTS) ALLOCATE (om, SOURCE=opts%wave_component_omega); ALLOCATE (kk, SOURCE=opts%wave_component_k) ALLOCATE (amp, SOURCE=opts%wave_component_amplitude); ALLOCATE (phs, SOURCE=opts%wave_component_phase) + IF (ALLOCATED(opts%wave_component_cos)) THEN + ALLOCATE (dir(SIZE(om))) + dir = ATAN2(opts%wave_component_sin, opts%wave_component_cos)*R2D + END IF CASE (WAVE_SEA) nc = opts%sea%n_comp ALLOCATE (om, SOURCE=opts%sea%omega(1:nc)); ALLOCATE (kk, SOURCE=opts%sea%k(1:nc)) @@ -35081,7 +35699,7 @@ SUBROUTINE synthesize_vessel_rao(path, opts, motion, rref, vref, aref, ErrStat, DO i = 1, nc cb = COS(dir(i)*QUARTER_PI/45.0_wp) sb = SIN(dir(i)*QUARTER_PI/45.0_wp) - eps(i) = kk(i)*(opts%vessel_ref(1)*cb + opts%vessel_ref(2)*sb) + phs(i) + eps(i) = kk(i)*((opts%vessel_ref(1) - opts%wave_ref_x)*cb + (opts%vessel_ref(2) - opts%wave_ref_y)*sb) + phs(i) IF (.NOT. (amp(i) > CD_ZERO)) CYCLE CALL CD_Vessel_RAO_Eval(rao, TWO_PI/om(i), dir(i), cre(:, i), cim(:, i), clamped, es, em) IF (es /= CD_VESSEL_OK) THEN @@ -36022,6 +36640,19 @@ PURE REAL(wp) FUNCTION line_geom_value(kind, deck_node, nnode, q_flat, stride, s v = line_geom_from_r(kind, r, nnode, deck_node, signed) END FUNCTION line_geom_value + PURE SUBROUTINE line_geom_values(kind, nnode, stride, q, signed, values) + !! line_geom_value at every deck node of a line. q(:, n) is internal node n's state, its first + !! three entries the position; the deck-ordered positions are read in place, not copied. + INTEGER, INTENT(IN) :: kind, nnode, stride + REAL(wp), INTENT(IN) :: q(stride, nnode) + LOGICAL, INTENT(IN) :: signed + REAL(wp), INTENT(OUT) :: values(nnode) + INTEGER :: j + DO j = 1, nnode + values(j) = line_geom_from_r(kind, q(1:3, nnode:1:-1), nnode, j, signed) + END DO + END SUBROUTINE line_geom_values + PURE REAL(wp) FUNCTION line_geom_from_r(kind, r, nnode, deck_node, signed) RESULT(v) !! Geometric channel value (kind: 5 curvature, 7 declination, 8 azimuth) from the !! deck-ordered node positions r(3, nnode). Factored out of line_geom_value so a caller @@ -36029,7 +36660,7 @@ PURE REAL(wp) FUNCTION line_geom_from_r(kind, r, nnode, deck_node, signed) RESUL !! rather than rebuilding it inside each per-node call -- O(nnode) instead of O(nnode^2). !! signed: the line's azimuth range (DeckLine%azi_signed). INTEGER, INTENT(IN) :: kind, nnode, deck_node - REAL(wp), INTENT(IN) :: r(3, nnode) + REAL(wp), INTENT(IN) :: r(:, :) ! 3 x nnode, deck order LOGICAL, INTENT(IN), OPTIONAL :: signed v = CD_ZERO SELECT CASE (kind) @@ -36257,10 +36888,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 @@ -36277,6 +36911,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 @@ -36293,6 +36929,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)') & @@ -36340,6 +36977,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 @@ -36365,10 +37010,12 @@ SUBROUTINE range_status(es, em, ErrStat, ErrMsg) ErrMsg = 'CableDyn_DeckDriver: '//TRIM(em) END SUBROUTINE range_status - SUBROUTINE range_setup(rset, lines, channels, opts, nnodes, by_id, ErrStat, ErrMsg, bathymetry) + SUBROUTINE range_setup(rset, lines, channels, opts, nnodes, by_id, ErrStat, ErrMsg, bathymetry, run_tmax) !! Size the range set of a run from the LINES flag r and the TDP channels. by_id: !! rset%lines(k) is deck line id k (the aggregate line map) and nnodes is indexed by !! id; otherwise rset%lines(i) is lines(i) and nnodes follows the deck line order. + !! run_tmax: the run time of the aggregate's caller, which replaces a deck TMax (a coupled + !! deck's TMax is the standalone one). TYPE(CD_RangeSet), INTENT(INOUT) :: rset TYPE(DeckLine), INTENT(IN) :: lines(:) CHARACTER(*), INTENT(IN) :: channels(:) @@ -36378,6 +37025,7 @@ SUBROUTINE range_setup(rset, lines, channels, opts, nnodes, by_id, ErrStat, ErrM INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg TYPE(CD_BathymetryType), INTENT(IN), OPTIONAL :: bathymetry + REAL(wp), INTENT(IN), OPTIONAL :: run_tmax INTEGER, ALLOCATABLE :: ids(:) LOGICAL, ALLOCATABLE :: want_r(:), want_t(:) INTEGER :: i, k, n, line_id, comp, es @@ -36407,7 +37055,11 @@ SUBROUTINE range_setup(rset, lines, channels, opts, nnodes, by_id, ErrStat, ErrM END DO ! TMax = 0 (a static start check) puts the t = 0 row in the range graphs whatever RangeStart says t_start = opts%range_start - IF (opts%has_tmax) t_start = MIN(t_start, opts%tmax) + IF (PRESENT(run_tmax)) THEN + t_start = MIN(t_start, run_tmax) + ELSE IF (opts%has_tmax) THEN + t_start = MIN(t_start, opts%tmax) + END IF CALL CD_Range_Setup(rset, ids, nnodes, want_r, want_t, t_start, opts%has_wtrdpth, -opts%wtrdpth, & es, em, bathymetry=bathymetry) CALL range_status(es, em, ErrStat, ErrMsg) @@ -36634,7 +37286,7 @@ SUBROUTINE range_sample_cable(cable, rl, ErrStat, ErrMsg) SUBROUTINE range_sample_cable_element(ErrStat, ErrMsg) !! OutputRecovery element: end-force tension at the ends and the length-weighted - !! element-mean axial force inside, the larger one-sided exact curvature and EI * curvature. + !! element-mean axial force inside, the larger one-sided element curvature and EI * curvature. INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg ErrStat = CD_DECKDRV_OK @@ -36720,7 +37372,7 @@ SUBROUTINE aggregate_line_positions(system, cables, line_is_cable, line_obj_inde END SUBROUTINE aggregate_line_positions SUBROUTINE range_setup_aggregate(rset, lines, channels, opts, has_sys, system, cables, line_is_cable, & - line_obj_index, ErrStat, ErrMsg, bathymetry) + line_obj_index, ErrStat, ErrMsg, bathymetry, run_tmax) !! Range set of an aggregate (EI=0 system + Hermite cables), keyed by deck line id, with !! the touchdown references fixed at the current (initial) state. TYPE(CD_RangeSet), INTENT(INOUT) :: rset @@ -36735,6 +37387,7 @@ SUBROUTINE range_setup_aggregate(rset, lines, channels, opts, has_sys, system, c INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg TYPE(CD_BathymetryType), INTENT(IN), OPTIONAL :: bathymetry + REAL(wp), INTENT(IN), OPTIONAL :: run_tmax INTEGER, ALLOCATABLE :: nnodes(:) INTEGER :: k, obj, es CHARACTER(1024) :: em @@ -36755,7 +37408,7 @@ SUBROUTINE range_setup_aggregate(rset, lines, channels, opts, has_sys, system, c END IF END IF END DO - CALL range_setup(rset, lines, channels, opts, nnodes, .TRUE., ErrStat, ErrMsg, bathymetry) + CALL range_setup(rset, lines, channels, opts, nnodes, .TRUE., ErrStat, ErrMsg, bathymetry, run_tmax) IF (ErrStat /= CD_DECKDRV_OK .OR. .NOT. rset%active) RETURN DO k = 1, SIZE(rset%lines) IF (.NOT. rset%lines(k)%want_tdp) CYCLE @@ -37122,6 +37775,177 @@ SUBROUTINE CD_Eval_Aggregate_Segment_Tensions(line_id, has_sys, system, cables, END BLOCK END SUBROUTINE CD_Eval_Aggregate_Segment_Tensions + SUBROUTINE CD_Eval_Aggregate_Line_Values(line_id, kind, has_sys, system, cables, line_is_cable, line_obj_index, & + values, work, n_done, ErrStat, ErrMsg) + !! One line-node channel at every node of the final mesh of deck line line_id, End A first, + !! with no token per node. kind is the channel's code (parse_line_node_channel): 1, 2 and 4 + !! give three values per node, xyz interleaved, and 3, 5, 6, 7 and 8 one. Every value is bit + !! for bit the one CD_Eval_Aggregate_Channel returns for that node with mesh_node = .TRUE.: + !! the two routes share the per-node rules (cable_recovered_value, system_node_tension, + !! line_geom_from_r, cable_global_component, cable_global_azimuth). work holds at least one + !! value per element. On a failure n_done counts the values written before it, in order, so a + !! caller can name the first bad value's channel. + INTEGER, INTENT(IN) :: line_id, kind + LOGICAL, INTENT(IN) :: has_sys + TYPE(CD_SystemType), INTENT(IN) :: system + TYPE(CD_HFMF_ModuleType), ALLOCATABLE, INTENT(INOUT) :: cables(:) + LOGICAL, INTENT(IN) :: line_is_cable(:) + INTEGER, INTENT(IN) :: line_obj_index(:) + REAL(wp), INTENT(OUT) :: values(:) + REAL(wp), INTENT(INOUT) :: work(:) + INTEGER, INTENT(OUT) :: n_done, ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + INTEGER :: obj + + ErrStat = CD_DECKDRV_OK + ErrMsg = '' + n_done = 0 + IF (kind < 1 .OR. kind > 8) THEN + CALL fail(ErrStat, ErrMsg, 'line values: not a line-node channel code'); RETURN + END IF + IF (line_id < 1 .OR. line_id > SIZE(line_obj_index)) THEN + CALL fail(ErrStat, ErrMsg, 'line values: unknown line id'); RETURN + END IF + obj = line_obj_index(line_id) + IF (obj < 1) THEN + CALL fail(ErrStat, ErrMsg, 'line values: line id maps to no built line'); RETURN + END IF + IF (line_is_cable(line_id)) THEN + IF (.NOT. ALLOCATED(cables)) THEN + CALL fail(ErrStat, ErrMsg, 'line values: line maps to a cable but none are built'); RETURN + END IF + IF (obj > SIZE(cables)) THEN + CALL fail(ErrStat, ErrMsg, 'line values: cable index out of range'); RETURN + END IF + CALL cable_line_values(cables(obj), kind, values, n_done, ErrStat, ErrMsg) + ELSE + IF (.NOT. has_sys) THEN + CALL fail(ErrStat, ErrMsg, 'line values: line maps to the EI=0 system but no system is present'); RETURN + END IF + CALL system_line_values(system, obj, kind, values, work, n_done, ErrStat, ErrMsg) + END IF + END SUBROUTINE CD_Eval_Aggregate_Line_Values + + SUBROUTINE system_line_values(system, li, kind, values, work, n_done, ErrStat, ErrMsg) + !! CD_Eval_Aggregate_Line_Values on EI=0 system line li (eval_system_line_channel at every node). + TYPE(CD_SystemType), INTENT(IN) :: system + INTEGER, INTENT(IN) :: li, kind + REAL(wp), INTENT(OUT) :: values(:) + REAL(wp), INTENT(INOUT) :: work(:) + INTEGER, INTENT(OUT) :: n_done, ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + INTEGER :: ndof, nnode, node, es + CHARACTER(1024) :: em + + ErrStat = CD_DECKDRV_OK + ErrMsg = '' + n_done = 0 + ndof = CD_System_Line_NDOF(system, li, es, em) + IF (es /= CD_SYSTEM_OK) THEN + CALL fail_solve(ErrStat, ErrMsg, 'line values: '//TRIM(em)); RETURN + END IF + nnode = ndof/3 + IF (SIZE(values) /= MERGE(ndof, nnode, kind == 1 .OR. kind == 2 .OR. kind == 4) .OR. SIZE(work) < nnode - 1) THEN + CALL fail(ErrStat, ErrMsg, 'line values: output size does not match the line'); RETURN + END IF + SELECT CASE (kind) + CASE (1) ! position + CALL node_vectors_reversed(system%lines(li)%q, nnode, values) + CASE (2) ! velocity + CALL node_vectors_reversed(system%lines(li)%v, nnode, values) + CASE (4) ! acceleration + CALL node_vectors_reversed(system%lines(li)%a, nnode, values) + CASE (3) ! nodal tension + CALL CD_Get_System_Line_Tension(system, li, work(1:nnode - 1), es, em) + IF (es /= CD_SYSTEM_OK) THEN + CALL fail_solve(ErrStat, ErrMsg, 'line values: '//TRIM(em)); RETURN + END IF + DO node = 1, nnode + CALL system_node_tension(system%lines(li), work(1:nnode - 1), deck_to_internal_node(node, nnode - 1), nnode, & + values(node), es, em) + IF (es /= CD_DECKDRV_OK) THEN + CALL fail_solve(ErrStat, ErrMsg, 'line values: '//TRIM(em)); RETURN + END IF + n_done = node + END DO + CASE DEFAULT ! curvature (5) / bend moment (6, EI=0 => 0) / declination (7) / azimuth (8) + CALL line_geom_values(kind, nnode, 3, system%lines(li)%q, system%lines(li)%azimuth_signed, values) + END SELECT + n_done = SIZE(values) + END SUBROUTINE system_line_values + + PURE SUBROUTINE node_vectors_reversed(state, nnode, values) + !! The 3-vectors of an EI=0 line's nodes (internal order) in public order, xyz interleaved. + INTEGER, INTENT(IN) :: nnode + REAL(wp), INTENT(IN) :: state(3, nnode) + REAL(wp), INTENT(OUT) :: values(3, nnode) + values = state(:, nnode:1:-1) + END SUBROUTINE node_vectors_reversed + + SUBROUTINE cable_line_values(cable, kind, values, n_done, ErrStat, ErrMsg) + !! CD_Eval_Aggregate_Line_Values on a finite-EI cable (eval_cable_channel at every node of the + !! final mesh). The tension, curvature and bend moment of the nodes share one element + !! evaluation through the cache of CD_HermiteCable_Dyn_Node_Values. + TYPE(CD_HFMF_ModuleType), INTENT(INOUT) :: cable + INTEGER, INTENT(IN) :: kind + REAL(wp), INTENT(OUT) :: values(:) + INTEGER, INTENT(OUT) :: n_done, ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + INTEGER :: nnode, nelem, node + + ErrStat = CD_DECKDRV_OK + ErrMsg = '' + n_done = 0 + IF (.NOT. cable_state_ready(cable)) THEN + CALL fail(ErrStat, ErrMsg, 'line values: cable state not available'); RETURN + END IF + nelem = cable%line%ne + nnode = cable%line%nn + IF (SIZE(values) /= MERGE(3*nnode, nnode, kind == 1 .OR. kind == 2 .OR. kind == 4)) THEN + CALL fail(ErrStat, ErrMsg, 'line values: output size does not match the line'); RETURN + END IF + SELECT CASE (kind) + CASE (1) ! position + CALL cable_global_vectors(cable, cable%line%q, values) + CASE (2) ! velocity + CALL cable_global_vectors(cable, cable%line%v, values) + CASE (4) ! acceleration + CALL cable_global_vectors(cable, cable%line%a, values) + CASE (3, 5, 6) ! nodal tension, curvature, bend moment + DO node = 1, nnode + CALL cable_recovered_value(cable, kind, deck_to_internal_node(node, nelem), 'line values', values(node), & + ErrStat, ErrMsg) + IF (ErrStat /= CD_DECKDRV_OK) RETURN + n_done = node + END DO + CASE DEFAULT ! declination (7), azimuth (8) + CALL line_geom_values(kind, nnode, 6, cable%line%q, cable%azimuth_signed, values) + IF (kind == 8) THEN + DO node = 1, nnode + values(node) = cable_global_azimuth(cable, values(node)) + END DO + END IF + END SELECT + n_done = SIZE(values) + END SUBROUTINE cable_line_values + + PURE SUBROUTINE cable_global_vectors(cable, state, values) + !! A cable's nodal vector state (internal order, stride 6) in the deck's global frame and + !! public order, xyz interleaved. + TYPE(CD_HFMF_ModuleType), INTENT(IN) :: cable + REAL(wp), CONTIGUOUS, INTENT(IN) :: state(:) + REAL(wp), INTENT(OUT) :: values(:) + INTEGER :: nnode, node, c, base + + nnode = cable%line%nn + DO node = 1, nnode + base = 6*(nnode - node) + DO c = 1, 3 + values(3*(node - 1) + c) = cable_global_component(cable, state(base + 1:base + 3), c) + END DO + END DO + END SUBROUTINE cable_global_vectors + SUBROUTINE CD_Rigid6_Inventory(rt, body_ids, rod_ids, rod_nsegs) !! Deck ids of the Rigid6 bodies the runtime owns (runtime order), and of the rods fixed or !! pinned to them (body order, then attachment order) with their NumSegs: the objects the @@ -37235,6 +38059,22 @@ SUBROUTINE line_model_end_tension(model, inode, nnode, val, ErrStat, ErrMsg) END IF END SUBROUTINE line_model_end_tension + SUBROUTINE system_node_tension(model, tension, inode, nnode, val, ErrStat, ErrMsg) + !! Tension at node inode (internal order) of an EI=0 line from its element tensions: the mean + !! of the two adjacent elements, and at a line end the force on the end's point. + TYPE(CD_ModelType), INTENT(IN) :: model + REAL(wp), INTENT(IN) :: tension(:) + INTEGER, INTENT(IN) :: inode, nnode + REAL(wp), INTENT(OUT) :: val + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + + ErrStat = CD_DECKDRV_OK + ErrMsg = '' + val = nodal_tension_value(tension, inode) + IF (inode == 1 .OR. inode == nnode) CALL line_model_end_tension(model, inode, nnode, val, ErrStat, ErrMsg) + END SUBROUTINE system_node_tension + SUBROUTINE eval_system_line_channel(system, li, ch, val, ErrStat, ErrMsg) !! Evaluate one channel for EI=0 system line `li` (internal system line index). Mirrors the !! standalone driver's system_channel_value but takes the resolved line index directly and @@ -37312,12 +38152,9 @@ SUBROUTINE eval_system_line_channel(system, li, ch, val, ErrStat, ErrMsg) IF (es /= CD_SYSTEM_OK) THEN CALL sys_solvefail(ch, em, ErrStat, ErrMsg); RETURN END IF - val = nodal_tension_value(tension, inode) - IF (inode == 1 .OR. inode == nnode) THEN - CALL line_model_end_tension(system%lines(li), inode, nnode, val, es, em) - IF (es /= CD_DECKDRV_OK) THEN - CALL sys_solvefail(ch, em, ErrStat, ErrMsg); RETURN - END IF + CALL system_node_tension(system%lines(li), tension, inode, nnode, val, es, em) + IF (es /= CD_DECKDRV_OK) THEN + CALL sys_solvefail(ch, em, ErrStat, ErrMsg); RETURN END IF END BLOCK RETURN @@ -37381,7 +38218,7 @@ SUBROUTINE eval_cable_channel(cable, ch, val, ErrStat, ErrMsg, mesh_node) lo = to_lower(ch) nelem = cable%line%ne nnode = cable%line%nn - IF (nelem < 1 .OR. nnode /= nelem + 1 .OR. SIZE(cable%line%q) < 6*nnode) THEN + IF (.NOT. cable_state_ready(cable)) THEN CALL fail(ErrStat, ErrMsg, 'OUTPUT "'//TRIM(ch)//'": cable state not available'); RETURN END IF IF (starts_with(lo, 'fairten')) THEN @@ -37452,47 +38289,14 @@ SUBROUTINE eval_cable_channel(cable, ch, val, ErrStat, ErrMsg, mesh_node) END IF inode = deck_to_internal_node(node, nelem) SELECT CASE (kind) - CASE (3) ! nodal tension - BLOCK - IF (inode == 1 .OR. inode == nnode) THEN - ! a line end: the same support-reaction tension as FairTen/AnchTen - CALL cable_end_tension(cable, inode, val, ErrStat, ErrMsg) - ELSE IF (cable%line%output_element_recovery) THEN - ! OutputRecovery element: the length-weighted average of the two adjacent - ! element-mean axial forces EA (|r'| - 1) - val = (cable%line%l0(inode - 1)*cable_element_mean_tension(cable, inode - 1) + & - cable%line%l0(inode)*cable_element_mean_tension(cable, inode))/ & - (cable%line%l0(inode - 1) + cable%line%l0(inode)) - ELSE - ! effective tension from nodal equilibrium (CD_HermiteCable_Dyn_Node_Recovery) - CALL cable_node_values(cable, inode, val, sec_curv, sec_bend, ErrStat, ErrMsg) - IF (ErrStat /= CD_DECKDRV_OK) RETURN - END IF - END BLOCK + CASE (3, 5, 6) ! nodal tension, curvature, bend moment + CALL cable_recovered_value(cable, kind, inode, ch, val, ErrStat, ErrMsg) CASE (1) ! position (cable-local state rotated to the deck's global frame) val = cable_global_component(cable, cable%line%q(6*(inode - 1) + 1:6*(inode - 1) + 3), comp) CASE (2) ! velocity val = cable_global_component(cable, cable%line%v(6*(inode - 1) + 1:6*(inode - 1) + 3), comp) CASE (4) ! acceleration val = cable_global_component(cable, cable%line%a(6*(inode - 1) + 1:6*(inode - 1) + 3), comp) - CASE (5) ! curvature: recovered bend moment / EI, or the larger exact one-sided trace - IF (.NOT. cable%line%output_element_recovery) THEN - CALL cable_node_values(cable, inode, sec_ten, val, sec_bend, ErrStat, ErrMsg) - RETURN - END IF - CALL cable_nodal_curvature(cable, inode, val, es, em) - IF (es /= CD_HCABLE_OK) THEN - CALL fail_solve(ErrStat, ErrMsg, 'OUTPUT "'//TRIM(ch)//'": '//TRIM(em)); RETURN - END IF - CASE (6) ! bend moment: recovered from nodal equilibrium, or EI times the larger exact trace - IF (.NOT. cable%line%output_element_recovery) THEN - CALL cable_node_values(cable, inode, sec_ten, sec_curv, val, ErrStat, ErrMsg) - RETURN - END IF - CALL cable_nodal_bend_moment(cable, inode, val, es, em) - IF (es /= CD_HCABLE_OK) THEN - CALL fail_solve(ErrStat, ErrMsg, 'OUTPUT "'//TRIM(ch)//'": '//TRIM(em)); RETURN - END IF CASE (12) ! the bend stiffener's share of the section moment (zero outside a stiffener) IF (.NOT. cable%line%output_element_recovery) THEN CALL cable_node_values(cable, inode, sec_ten, sec_curv, sec_bend, ErrStat, ErrMsg, sleeve_moment=val) @@ -37558,16 +38362,80 @@ SUBROUTINE eval_cable_channel(cable, ch, val, ErrStat, ErrMsg, mesh_node) END IF END BLOCK CASE (8) ! azimuth: local-frame azimuth plus the cable's frame heading, wrapped - val = line_geom_value(kind, node, nnode, cable%line%q(1:6*nnode), 6, cable%azimuth_signed) - IF (.NOT. cable_frame_is_identity(cable)) THEN - val = wrap_azimuth_deg(val + ATAN2(cable%frame_s, cable%frame_c)*45.0_wp/QUARTER_PI, & - cable%azimuth_signed) - END IF + val = cable_global_azimuth(cable, line_geom_value(kind, node, nnode, cable%line%q(1:6*nnode), 6, & + cable%azimuth_signed)) CASE DEFAULT ! declination (7) -- rotation-invariant geometric scalar val = line_geom_value(kind, node, nnode, cable%line%q(1:6*nnode), 6) END SELECT END SUBROUTINE eval_cable_channel + PURE LOGICAL FUNCTION cable_state_ready(cable) RESULT(ready) + !! Whether a finite-EI cable holds the state its channels read. + TYPE(CD_HFMF_ModuleType), INTENT(IN) :: cable + ready = .FALSE. + IF (cable%line%ne < 1 .OR. cable%line%nn /= cable%line%ne + 1) RETURN + ready = SIZE(cable%line%q) >= 6*cable%line%nn + END FUNCTION cable_state_ready + + SUBROUTINE cable_recovered_value(cable, kind, inode, label, val, ErrStat, ErrMsg) + !! The tension (kind 3), curvature (5) or bend moment (6) of a finite-EI cable at internal node + !! inode, under the line's OutputRecovery. label names the value in a failure message. + TYPE(CD_HFMF_ModuleType), INTENT(INOUT) :: cable + INTEGER, INTENT(IN) :: kind, inode + CHARACTER(*), INTENT(IN) :: label + REAL(wp), INTENT(OUT) :: val + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + REAL(wp) :: sec_ten, sec_curv, sec_bend + INTEGER :: es + CHARACTER(1024) :: em + + ErrStat = CD_DECKDRV_OK + ErrMsg = '' + val = CD_ZERO + SELECT CASE (kind) + CASE (3) ! nodal tension + IF (inode == 1 .OR. inode == cable%line%nn) THEN + ! a line end: the same support-reaction tension as FairTen/AnchTen + CALL cable_end_tension(cable, inode, val, ErrStat, ErrMsg) + ELSE IF (cable%line%output_element_recovery) THEN + ! OutputRecovery element: the length-weighted average of the two adjacent + ! element-mean axial forces EA (|r'| - 1) + val = (cable%line%l0(inode - 1)*cable_element_mean_tension(cable, inode - 1) + & + cable%line%l0(inode)*cable_element_mean_tension(cable, inode))/ & + (cable%line%l0(inode - 1) + cable%line%l0(inode)) + ELSE + ! effective tension from nodal equilibrium (CD_HermiteCable_Dyn_Node_Recovery) + CALL cable_node_values(cable, inode, val, sec_curv, sec_bend, ErrStat, ErrMsg) + END IF + CASE (5) ! curvature: recovered bend moment / EI, or the larger exact one-sided trace + IF (.NOT. cable%line%output_element_recovery) THEN + CALL cable_node_values(cable, inode, sec_ten, val, sec_bend, ErrStat, ErrMsg) + RETURN + END IF + CALL cable_nodal_curvature(cable, inode, val, es, em) + IF (es /= CD_HCABLE_OK) CALL fail_solve(ErrStat, ErrMsg, 'OUTPUT "'//TRIM(label)//'": '//TRIM(em)) + CASE (6) ! bend moment: recovered from nodal equilibrium, or EI times the larger exact trace + IF (.NOT. cable%line%output_element_recovery) THEN + CALL cable_node_values(cable, inode, sec_ten, sec_curv, val, ErrStat, ErrMsg) + RETURN + END IF + CALL cable_nodal_bend_moment(cable, inode, val, es, em) + IF (es /= CD_HCABLE_OK) CALL fail_solve(ErrStat, ErrMsg, 'OUTPUT "'//TRIM(label)//'": '//TRIM(em)) + END SELECT + END SUBROUTINE cable_recovered_value + + PURE REAL(wp) FUNCTION cable_global_azimuth(cable, azimuth) RESULT(val) + !! A cable-local azimuth [deg] in the deck's global frame: plus the cable's frame heading, + !! wrapped. Identity heading reads it straight through, bit-for-bit. + TYPE(CD_HFMF_ModuleType), INTENT(IN) :: cable + REAL(wp), INTENT(IN) :: azimuth + val = azimuth + IF (.NOT. cable_frame_is_identity(cable)) THEN + val = wrap_azimuth_deg(val + ATAN2(cable%frame_s, cable%frame_c)*45.0_wp/QUARTER_PI, cable%azimuth_signed) + END IF + END FUNCTION cable_global_azimuth + PURE FUNCTION cable_frame_is_identity(cable) RESULT(is_id) !! Exact identity-heading test. Order comparisons preserve the bit-for-bit !! identity fast path without tripping -Wcompare-reals. @@ -37795,9 +38663,9 @@ SUBROUTINE cable_segment_tensions(cable, tension, ErrStat, ErrMsg) END SUBROUTINE cable_segment_tensions SUBROUTINE cable_nodal_curvature(cable, inode, curvature, ErrStat, ErrMsg) - !! Exact one-sided Hermite curvature traces at an internal line node. Curvature - !! need not be continuous across a material interface because the interpolation - !! is C1, so the public nodal value conservatively retains the larger trace. + !! One-sided element curvatures at an internal line node (cable_element_curvature). + !! Curvature need not be continuous across a material interface because the + !! interpolation is C1, so the public nodal value conservatively retains the larger one. TYPE(CD_HFMF_ModuleType), INTENT(IN) :: cable INTEGER, INTENT(IN) :: inode REAL(wp), INTENT(OUT) :: curvature @@ -37824,10 +38692,10 @@ SUBROUTINE cable_nodal_curvature(cable, inode, curvature, ErrStat, ErrMsg) END SUBROUTINE cable_nodal_curvature SUBROUTINE cable_nodal_bend_moment(cable, inode, bend_moment, ErrStat, ErrMsg, sleeve_moment) - !! Magnitudes of the two one-sided EI*kappa traces at a node. Taking their - !! maximum avoids inventing an averaged EI at a stepped section interface. bend_moment is the - !! cable's own share EI_line kappa; sleeve_moment (optional) the share of a bend stiffener, - !! (EI - EI_line) kappa. + !! Magnitudes of the two one-sided EI*kappa values at a node (cable_element_curvature). Taking + !! their maximum avoids inventing an averaged EI at a stepped section interface. bend_moment is + !! the cable's own share EI_line kappa; sleeve_moment (optional) the share of a bend stiffener, + !! (EI - EI_line) kappa, with the stiffness EI of the section at the node. TYPE(CD_HFMF_ModuleType), INTENT(IN) :: cable INTEGER, INTENT(IN) :: inode REAL(wp), INTENT(OUT) :: bend_moment @@ -37847,33 +38715,35 @@ SUBROUTINE cable_nodal_bend_moment(cable, inode, bend_moment, ErrStat, ErrMsg, s CALL cable_element_curvature(cable, inode - 1, CD_ONE, curvature, ErrStat, ErrMsg) IF (ErrStat /= CD_HCABLE_OK) RETURN bend_moment = MAX(bend_moment, cable%line%EI_line(inode - 1)*curvature) - IF (PRESENT(sleeve_moment)) sleeve_moment = MAX(sleeve_moment, & - (cable%line%EI(inode - 1) - cable%line%EI_line(inode - 1))*curvature) + IF (PRESENT(sleeve_moment)) & + sleeve_moment = MAX(sleeve_moment, (CD_HermiteCable_Dyn_Side_EI(cable%line, inode - 1, inode) - & + cable%line%EI_line(inode - 1))*curvature) END IF IF (inode <= cable%line%ne) THEN CALL cable_element_curvature(cable, inode, CD_ZERO, curvature, ErrStat, ErrMsg) IF (ErrStat /= CD_HCABLE_OK) RETURN bend_moment = MAX(bend_moment, cable%line%EI_line(inode)*curvature) - IF (PRESENT(sleeve_moment)) sleeve_moment = MAX(sleeve_moment, & - (cable%line%EI(inode) - cable%line%EI_line(inode))*curvature) + IF (PRESENT(sleeve_moment)) & + sleeve_moment = MAX(sleeve_moment, (CD_HermiteCable_Dyn_Side_EI(cable%line, inode, inode) - & + cable%line%EI_line(inode))*curvature) END IF END SUBROUTINE cable_nodal_bend_moment SUBROUTINE cable_element_curvature(cable, e, xi, curvature, ErrStat, ErrMsg) + !! The curvature of the section at an end of element e (xi 0 or 1, node e or e + 1) from the + !! element: its exact trace there, or under a bend stiffener the section's curvature + !! (CD_HermiteCable_Dyn_Side_Curvature). TYPE(CD_HFMF_ModuleType), INTENT(IN) :: cable INTEGER, INTENT(IN) :: e REAL(wp), INTENT(IN) :: xi REAL(wp), INTENT(OUT) :: curvature INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg - REAL(wp) :: qr(12) IF (e < 1 .OR. e > cable%line%ne) THEN ErrStat = 1; ErrMsg = 'cable_element_curvature: element index out of range'; curvature = CD_ZERO; RETURN END IF - qr(1:6) = cable%line%q(6*(e - 1) + 1:6*(e - 1) + 6) - qr(7:12) = cable%line%q(6*e + 1:6*e + 6) - CALL CD_HermiteCable_Curvature(qr, cable%line%l0(e), xi, curvature, ErrStat, ErrMsg) + CALL CD_HermiteCable_Dyn_Side_Curvature(cable%line, e, e + NINT(xi), curvature, ErrStat, ErrMsg) END SUBROUTINE cable_element_curvature FUNCTION CD_Channel_Unit(ch) RESULT(unit) @@ -40280,6 +41150,10 @@ INTEGER FUNCTION motion_grid_index(t, dt, motion, tol) RESULT(it) TYPE(MotionTable), INTENT(IN) :: motion INTEGER :: k it = 0 + IF (motion%start_row_only .AND. t > tol) THEN + it = MOTION_PAST_TMAX + RETURN + END IF IF (.NOT. ABS(t/dt) < MAX_GRID_STEPS) RETURN k = NINT(t/dt) IF (k < 0) RETURN diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index 6b16ccbe..c711a155 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 @@ -141,6 +142,7 @@ MODULE CableDyn_HermiteCableDynamic PUBLIC :: CD_HermiteCable_Dyn_Section_Curvature PUBLIC :: CD_HermiteCable_Dyn_Reaction PUBLIC :: CD_HermiteCable_Dyn_Curvature_Exact + PUBLIC :: CD_HermiteCable_Dyn_Side_EI, CD_HermiteCable_Dyn_Side_Curvature PUBLIC :: CD_HermiteCable_Dyn_Set_Output_Recovery PUBLIC :: CD_HermiteCable_Dyn_Node_Recovery PUBLIC :: CD_HermiteCable_Dyn_Node_Values @@ -238,6 +240,12 @@ MODULE CableDyn_HermiteCableDynamic ! bend stiffener (STIFFENERS), and equal to EI on every element without one. The bend ! moment outputs report the cable's share EI_line kappa and the sleeve's the rest. REAL(wp), ALLOCATABLE :: EI_line(:) + ! On a line with STIFFENERS, the local bending stiffness EI(s) at each node, one value per + ! side: EI_node(1, j) the limit from element j - 1, EI_node(2, j) from element j, the same + ! number where EI(s) is continuous. A one-per-element EI steps at every node of a taper + ! without the section doing so, so the node outputs read these (node_values). Unallocated on + ! a line without a stiffener, whose node outputs read EI per element. + REAL(wp), ALLOCATABLE :: EI_node(:, :) ! Optional axial Kelvin-Voigt damping. BA is the axial constitutive dashpot ! coefficient [N s] after resolving the deck's direct-BA / negative-zeta convention. ! It is integrated over reference arc length with the same Hermite kinematics and @@ -335,6 +343,10 @@ MODULE CableDyn_HermiteCableDynamic REAL(wp) :: snap_fc_t = 0.0_wp, snap_fc_d0(3, 2) = 0.0_wp LOGICAL :: snap_fc_valid = .FALSE. INTEGER :: snap_fc_clears = 0 + ! fc_clears when the first step after the snapshot began: what happens to the cache after + ! that (the setters of an output probe) is no part of the path a restored step must repeat + LOGICAL :: snap_stepped = .FALSE. + INTEGER :: snap_step_clears = 0 ! Generalised-alpha blend: .TRUE. blends the forces (the default), .FALSE. evaluates the ! forces at the blended configuration q_alpha = (1-af) q_{n+1} + af q_n. LOGICAL :: force_blend = CD_HC_FORCE_BLEND_DEFAULT @@ -1926,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: @@ -3874,6 +3914,10 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r ! lengths the committed state was reached with (active line control: l0_c): reused from ! the previous step's converged evaluation when its key matches the committed state ! exactly, evaluated here otherwise (one residual assembly). + IF (.NOT. model%snap_stepped) THEN + model%snap_stepped = .TRUE. + model%snap_step_clears = model%fc_clears + END IF fc_hit = .FALSE. IF (model%force_blend) fc_hit = force_cache_matches() ! Cross-step factor reuse needs the step to continue bitwise from the state the carried @@ -5923,15 +5967,15 @@ END SUBROUTINE CD_HermiteCable_Dyn_Momentum SUBROUTINE CD_HermiteCable_Dyn_Curvature(model, curv_out, ErrStat, ErrMsg) !! Per-node curvature at the current state, the definition of the CurvN channels: the !! bend moment recovered from nodal equilibrium over EI (CD_HermiteCable_Dyn_Node_Recovery), - !! or, under CD_HermiteCable_Dyn_Set_Output_Recovery(.TRUE.), the exact one-sided traces of - !! CD_HermiteCable_Dyn_Curvature_Exact. + !! or, under CD_HermiteCable_Dyn_Set_Output_Recovery(.TRUE.), the larger of the one-sided + !! values of CD_HermiteCable_Dyn_Side_Curvature. TYPE(CD_HermiteCableDynType), INTENT(IN) :: model REAL(wp), INTENT(OUT) :: curv_out(:) INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg ErrStat = CD_HCDYN_OK; ErrMsg = '' IF (model%output_element_recovery) THEN - CALL CD_HermiteCable_Dyn_Curvature_Exact(model, curv_out, ErrStat, ErrMsg) + CALL CD_HermiteCable_Dyn_Curvature_Exact(model, curv_out, ErrStat, ErrMsg, section=.TRUE.) RETURN END IF IF (.NOT. model%initialized) THEN @@ -5943,14 +5987,18 @@ SUBROUTINE CD_HermiteCable_Dyn_Curvature(model, curv_out, ErrStat, ErrMsg) CALL CD_HermiteCable_Dyn_Node_Recovery(model, ErrStat, ErrMsg, curvature=curv_out) END SUBROUTINE CD_HermiteCable_Dyn_Curvature - SUBROUTINE CD_HermiteCable_Dyn_Curvature_Exact(model, curv_out, ErrStat, ErrMsg) + SUBROUTINE CD_HermiteCable_Dyn_Curvature_Exact(model, curv_out, ErrStat, ErrMsg, section) !! Per-node geometric curvature of the cubic-Hermite centreline at the current state: the !! exact curvature of the element interpolation at the node, the larger of the two - !! one-sided traces at an interior node (the curvature is discontinuous there). + !! one-sided traces at an interior node (the curvature is discontinuous there). With section + !! .TRUE. the one-sided values are CD_HermiteCable_Dyn_Side_Curvature's, which differ from the + !! traces only under a bend stiffener. TYPE(CD_HermiteCableDynType), INTENT(IN) :: model REAL(wp), INTENT(OUT) :: curv_out(:) INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg + LOGICAL, INTENT(IN), OPTIONAL :: section + LOGICAL :: to_section INTEGER :: a, es REAL(wp) :: qe(12), kL, kR CHARACTER(1024) :: em2 @@ -5961,22 +6009,32 @@ SUBROUTINE CD_HermiteCable_Dyn_Curvature_Exact(model, curv_out, ErrStat, ErrMsg) IF (SIZE(curv_out) /= model%nn) THEN ErrStat = CD_HCDYN_BADINPUT; ErrMsg = 'CD_HermiteCable_Dyn_Curvature_Exact: curv_out must be nn'; RETURN END IF + to_section = .FALSE. + IF (PRESENT(section)) to_section = section DO a = 1, model%nn curv_out(a) = CD_ZERO ! A degenerate nodal tangent makes the curvature undefined; propagate that failure rather than ! silently reporting zero curvature -- a fatigue assessment reads this channel, so a hidden ! zero at a degenerate state would understate the peak sag-bend curvature. IF (a <= model%ne) THEN - CALL gather_elem(model, a, qe) - CALL CD_HermiteCable_Curvature(qe, model%l0(a), CD_ZERO, kR, es, em2) + IF (to_section) THEN + CALL CD_HermiteCable_Dyn_Side_Curvature(model, a, a, kR, es, em2) + ELSE + CALL gather_elem(model, a, qe) + CALL CD_HermiteCable_Curvature(qe, model%l0(a), CD_ZERO, kR, es, em2) + END IF IF (es /= CD_HCABLE_OK) THEN ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = 'CD_HermiteCable_Dyn_Curvature_Exact: '//TRIM(em2); RETURN END IF curv_out(a) = kR END IF IF (a >= 2) THEN - CALL gather_elem(model, a - 1, qe) - CALL CD_HermiteCable_Curvature(qe, model%l0(a - 1), CD_ONE, kL, es, em2) + IF (to_section) THEN + CALL CD_HermiteCable_Dyn_Side_Curvature(model, a - 1, a, kL, es, em2) + ELSE + CALL gather_elem(model, a - 1, qe) + CALL CD_HermiteCable_Curvature(qe, model%l0(a - 1), CD_ONE, kL, es, em2) + END IF IF (es /= CD_HCABLE_OK) THEN ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = 'CD_HermiteCable_Dyn_Curvature_Exact: '//TRIM(em2); RETURN END IF @@ -5985,13 +6043,86 @@ SUBROUTINE CD_HermiteCable_Dyn_Curvature_Exact(model, curv_out, ErrStat, ErrMsg) END DO END SUBROUTINE CD_HermiteCable_Dyn_Curvature_Exact + PURE FUNCTION CD_HermiteCable_Dyn_Side_EI(model, e, node) RESULT(ei) + !! The bending stiffness of the section at node (e or e + 1), on the side of its element e: + !! on a stiffened line the limit of EI(s) there (EI_node), else the element's EI. An element + !! without bending stiffness keeps its zero. + TYPE(CD_HermiteCableDynType), INTENT(IN) :: model + INTEGER, INTENT(IN) :: e, node + REAL(wp) :: ei + ei = model%EI(e) + IF (.NOT. ALLOCATED(model%EI_node) .OR. .NOT. ei > CD_ZERO) RETURN + IF (model%EI_node(MERGE(2, 1, node == e), node) > CD_ZERO) ei = model%EI_node(MERGE(2, 1, node == e), node) + END FUNCTION CD_HermiteCable_Dyn_Side_EI + + PURE FUNCTION trace_scale(model, e, node) RESULT(scale) + !! What turns the curvature of element e's interpolation at node (e or e + 1) into the + !! curvature of the section there: the element bends under one stiffness, so its curvature is + !! the moment over that EI, and the section takes the same moment over its own stiffness. + !! Exactly 1 wherever the two agree, as on every element without a bend stiffener. + TYPE(CD_HermiteCableDynType), INTENT(IN) :: model + INTEGER, INTENT(IN) :: e, node + REAL(wp) :: scale + scale = CD_ONE + IF (ALLOCATED(model%EI_node) .AND. model%EI(e) > CD_ZERO) & + scale = model%EI(e)/CD_HermiteCable_Dyn_Side_EI(model, e, node) + END FUNCTION trace_scale + + SUBROUTINE CD_HermiteCable_Dyn_Side_Curvature(model, e, node, curvature, ErrStat, ErrMsg, kvec) + !! The one-sided curvature of element output recovery at node (e or e + 1), on the side of + !! element e: the exact trace of the element's interpolation. Under a bend stiffener that + !! trace is the moment over the element's one stiffness, which is no section's: the element + !! then reports what the section has there, the recovered moment over EI(s), as the + !! equilibrium recovery does, and its scaled trace on a section too soft to recover the + !! moment. kvec, when asked for, is the curvature vector kappa n. Read-only. + TYPE(CD_HermiteCableDynType), INTENT(IN) :: model + INTEGER, INTENT(IN) :: e, node + REAL(wp), INTENT(OUT) :: curvature + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + REAL(wp), INTENT(OUT), OPTIONAL :: kvec(3) + REAL(wp) :: qe(12), g(12), fm(3), mm(3), fp(3), mp(3), mv(3), kv(3), ei, ten, kap, bend + LOGICAL :: sleeved + + ErrStat = CD_HCDYN_OK; ErrMsg = '' + sleeved = .FALSE. + IF (ALLOCATED(model%EI_node)) sleeved = model%EI(e) > CD_ZERO .AND. ABS(model%EI_line(e) - model%EI(e)) > CD_ZERO + ei = CD_HermiteCable_Dyn_Side_EI(model, e, node) + IF (.NOT. sleeved .OR. ei < model%ei_resolve) THEN + CALL gather_elem(model, e, qe) + CALL CD_HermiteCable_Curvature(qe, model%l0(e), REAL(node - e, wp), curvature, ErrStat, ErrMsg, kvec=kv) + IF (ErrStat /= CD_HCABLE_OK) RETURN + IF (sleeved) THEN + curvature = curvature*trace_scale(model, e, node) + kv = kv*trace_scale(model, e, node) + END IF + IF (PRESENT(kvec)) kvec = kv + RETURN + END IF + fm = CD_ZERO; mm = CD_ZERO; fp = CD_ZERO; mp = CD_ZERO + IF (node > 1) THEN + CALL element_end_residual(model, node - 1, g, ErrStat, ErrMsg) + IF (ErrStat /= CD_HCDYN_OK) RETURN + fm = g(7:9); mm = g(10:12) + END IF + IF (node < model%nn) THEN + CALL element_end_residual(model, node, g, ErrStat, ErrMsg) + IF (ErrStat /= CD_HCDYN_OK) RETURN + fp = -g(1:3); mp = -g(4:6) + END IF + CALL node_values(model, node, fm, mm, fp, mp, ten, kap, bend, ErrStat, ErrMsg, moment_vector=mv) + IF (ErrStat /= CD_HCDYN_OK) RETURN + curvature = node_stretch(model, node)*NORM2(mv)/ei + IF (PRESENT(kvec)) kvec = node_stretch(model, node)*mv/ei + END SUBROUTINE CD_HermiteCable_Dyn_Side_Curvature + SUBROUTINE CD_HermiteCable_Dyn_Section_Curvature(model, ref_a, kx, ky, ErrStat, ErrMsg, frame) !! The two components of the nodal curvature vector on the cross-section axes (the channels !! CurvXN, CurvYN), in internal node order (node nn is End A). The curvature !! vector kappa n points to the centre of curvature and its length is the curvature of !! CD_HermiteCable_Dyn_Curvature: the recovered section moment over EI on the side that - !! gives it (CD_HermiteCable_Dyn_Node_Recovery), or the larger exact one-sided trace under - !! element output recovery. + !! gives it (CD_HermiteCable_Dyn_Node_Recovery), or the larger one-sided element curvature + !! under element output recovery (CD_HermiteCable_Dyn_Side_Curvature). !! !! The section axes: d1 at End A is the reference normal -- the End A torsion normal on a !! line restrained in torsion, else ref_a (solve frame) -- made normal to the End A tangent. @@ -6029,13 +6160,12 @@ SUBROUTINE CD_HermiteCable_Dyn_Section_Curvature(model, ref_a, kx, ky, ErrStat, frame = CD_ZERO END IF IF (model%output_element_recovery) THEN - ! the larger exact one-sided trace and its vector (CD_HermiteCable_Dyn_Curvature_Exact) + ! the larger one-sided element curvature and its vector, in section form DO a = 1, model%nn kvec(:, a) = CD_ZERO kl = -CD_ONE IF (a <= model%ne) THEN - CALL gather_elem(model, a, qe) - CALL CD_HermiteCable_Curvature(qe, model%l0(a), CD_ZERO, kr, es, em2, kvec=kv) + CALL CD_HermiteCable_Dyn_Side_Curvature(model, a, a, kr, es, em2, kvec=kv) IF (es /= CD_HCABLE_OK) THEN ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = 'CD_HermiteCable_Dyn_Section_Curvature: '//TRIM(em2); RETURN END IF @@ -6043,8 +6173,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Section_Curvature(model, ref_a, kx, ky, ErrStat, kl = kr END IF IF (a >= 2) THEN - CALL gather_elem(model, a - 1, qe) - CALL CD_HermiteCable_Curvature(qe, model%l0(a - 1), CD_ONE, kr, es, em2, kvec=kv) + CALL CD_HermiteCable_Dyn_Side_Curvature(model, a - 1, a, kr, es, em2, kvec=kv) IF (es /= CD_HCABLE_OK) THEN ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = 'CD_HermiteCable_Dyn_Section_Curvature: '//TRIM(em2); RETURN END IF @@ -6570,7 +6699,7 @@ END SUBROUTINE cached_end_residual END SUBROUTINE CD_HermiteCable_Dyn_Node_Values SUBROUTINE CD_HermiteCable_Dyn_Node_Recovery(model, ErrStat, ErrMsg, tension, curvature, bend_moment, & - segment_tension, curvature_vector) + segment_tension, curvature_vector, moment_vector) !! Effective tension, curvature and bend moment at every node (internal order) recovered !! from nodal equilibrium, in one pass over the elements. !! @@ -6587,8 +6716,12 @@ SUBROUTINE CD_HermiteCable_Dyn_Node_Recovery(model, ErrStat, ErrMsg, tension, cu !! oscillation of the stiff-EA axial strain. !! !! tension(j) = t_j . F_j (t_j the unit nodal tangent; a line end reports the end force - !! magnitude of CD_HermiteCable_Dyn_End_Force), the section moment M_j = |m_j - (m_j . t_j) t_j|, - !! curvature(j) = M_j/EI, the larger of the two sides at a section junction, and + !! magnitude of CD_HermiteCable_Dyn_End_Force), the section moment + !! M_j = (1 + e_j) |m_j - (m_j . t_j) t_j|, 1 + e_j = |r'_j| the stretch at the node (m_j is the + !! conjugate of the tangent r', the section moment r' x m_j), + !! curvature(j) = (1 + e_j) M_j/EI, the geometric curvature of the stretched line, the larger + !! of the two sides at a section junction (under a bend + !! stiffener EI is the stiffness at the node, not the element's; see node_values), and !! bend_moment(j) the cable's own share EI_line curvature (M_j itself where no bend stiffener !! sleeves the element; see node_values) (the !! exact geometric trace on an element without bending stiffness, or with EI below ei_resolve, @@ -6597,8 +6730,10 @@ SUBROUTINE CD_HermiteCable_Dyn_Node_Recovery(model, ErrStat, ErrMsg, tension, cu !! mean of the axial components of element e's own two end forces. curvature_vector(:, j) !! is the curvature vector kappa n of node j in the solve frame, toward the centre of !! curvature, of length curvature(j): the section moment vector over EI on the side that - !! gives curvature(j), or the exact trace's vector. Every output is optional (node arrays nn, - !! curvature_vector (3, nn), segment_tension ne); the routine allocates nothing. + !! gives curvature(j), or the exact trace's vector. moment_vector(:, j) is the section moment + !! turned into the plane of the curvature, along kappa n, of length M_j. Every output is + !! optional (node arrays nn, curvature_vector and moment_vector (3, nn), segment_tension ne); + !! the routine allocates nothing. !! !! The condensed torque of a line restrained in torsion is not part of g_e: it adds a shear !! normal to the tangent and does no work on bending rotations, so tension and bend moment @@ -6609,7 +6744,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Node_Recovery(model, ErrStat, ErrMsg, tension, cu INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg REAL(wp), INTENT(OUT), OPTIONAL :: tension(:), curvature(:), bend_moment(:), segment_tension(:) - REAL(wp), INTENT(OUT), OPTIONAL :: curvature_vector(:, :) + REAL(wp), INTENT(OUT), OPTIONAL :: curvature_vector(:, :), moment_vector(:, :) REAL(wp) :: g(12), fm(3), mm(3), fp(3), mp(3), ta(3), tb(3) INTEGER :: e, nn LOGICAL :: want_nodes @@ -6630,7 +6765,14 @@ SUBROUTINE CD_HermiteCable_Dyn_Node_Recovery(model, ErrStat, ErrMsg, tension, cu ErrMsg = 'CD_HermiteCable_Dyn_Node_Recovery: curvature_vector must be (3, nn)'; RETURN END IF END IF - want_nodes = PRESENT(tension) .OR. PRESENT(curvature) .OR. PRESENT(bend_moment) .OR. PRESENT(curvature_vector) + IF (PRESENT(moment_vector)) THEN + IF (SIZE(moment_vector, 1) /= 3 .OR. SIZE(moment_vector, 2) /= nn) THEN + ErrStat = CD_HCDYN_BADINPUT + ErrMsg = 'CD_HermiteCable_Dyn_Node_Recovery: moment_vector must be (3, nn)'; RETURN + END IF + END IF + want_nodes = PRESENT(tension) .OR. PRESENT(curvature) .OR. PRESENT(bend_moment) .OR. & + PRESENT(curvature_vector) .OR. PRESENT(moment_vector) fm = CD_ZERO; mm = CD_ZERO DO e = 1, model%ne CALL element_end_residual(model, e, g, ErrStat, ErrMsg) @@ -6654,9 +6796,11 @@ SUBROUTINE CD_HermiteCable_Dyn_Node_Recovery(model, ErrStat, ErrMsg, tension, cu SUBROUTINE store_node(node) INTEGER, INTENT(IN) :: node - REAL(wp) :: t_n, k_n, b_n, kv(3) - CALL node_values(model, node, fm, mm, fp, mp, t_n, k_n, b_n, ErrStat, ErrMsg, curvature_vector=kv) + REAL(wp) :: t_n, k_n, b_n, kv(3), mv(3) + CALL node_values(model, node, fm, mm, fp, mp, t_n, k_n, b_n, ErrStat, ErrMsg, curvature_vector=kv, & + moment_vector=mv) IF (PRESENT(curvature_vector)) curvature_vector(:, node) = kv + IF (PRESENT(moment_vector)) moment_vector(:, node) = mv IF (PRESENT(tension)) tension(node) = t_n IF (PRESENT(curvature)) curvature(node) = k_n IF (PRESENT(bend_moment)) bend_moment(node) = b_n @@ -7000,7 +7144,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Twist_Unpack(model, buf, ErrStat, ErrMsg, check_o END SUBROUTINE CD_HermiteCable_Dyn_Twist_Unpack SUBROUTINE node_values(model, node, fm, mm, fp, mp, tension, curvature, bend_moment, ErrStat, ErrMsg, & - sleeve_moment, curvature_vector) + sleeve_moment, curvature_vector, moment_vector) !! Node values from the one-sided section force and moment before (fm, mm) and after !! (fp, mp) the node; a line end uses its only side (see CD_HermiteCable_Dyn_Node_Recovery). !! bend_moment is the cable's own share of the section moment, EI_line kappa on the side of @@ -7008,14 +7152,29 @@ SUBROUTINE node_values(model, node, fm, mm, fp, mp, tension, curvature, bend_mom !! sleeve_moment the rest, the share a stiffener sleeve carries. curvature_vector is the !! curvature vector kappa n (solve frame, toward the centre of curvature) of the side that !! gives curvature: the normal part of the section moment over EI, or the exact trace's. + !! + !! The energy EI kappa^2/2 per unstretched length, with kappa the curvature of the stretched + !! line, gives m = EI kappa n/|r'|^2 as the conjugate of the tangent r'. Rotating the whole + !! line leaves the energy unchanged, so the moment that balances the forces is r' x m, of + !! length M = |r'| |m_perp| = EI kappa/|r'|, and kappa = |r'| M/EI. + !! + !! The EI of a side is the element's, or on a stiffened line the limit of EI(s) at the node + !! from that side (EI_node): the element's compliance average stands for its whole length + !! and, under a taper, steps at every node although the section does not, so the softer side + !! would overstate the curvature by about h |EI'|/(2 EI). The two limits differ only at a + !! real step of the section (the bare tip, a junction of two line types, a step between + !! stiffener rows), where the larger-side rule still applies. The exact trace of a side too + !! soft to recover takes the same stiffness (trace_scale). moment_vector is the section + !! moment turned into the plane of the curvature, along kappa n. TYPE(CD_HermiteCableDynType), INTENT(IN) :: model INTEGER, INTENT(IN) :: node REAL(wp), INTENT(IN) :: fm(3), mm(3), fp(3), mp(3) REAL(wp), INTENT(OUT) :: tension, curvature, bend_moment INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg - REAL(wp), INTENT(OUT), OPTIONAL :: sleeve_moment, curvature_vector(3) - REAL(wp) :: t(3), f(3), m(3), fend(3), qe(12), trace, section_moment, cable_moment, m_perp(3), kv(3) + REAL(wp), INTENT(OUT), OPTIONAL :: sleeve_moment, curvature_vector(3), moment_vector(3) + REAL(wp) :: t(3), f(3), m(3), fend(3), qe(12), trace, section_moment, cable_moment, m_perp(3), kv(3), ei_side + REAL(wp) :: stretch INTEGER :: e, side CHARACTER(200) :: em2 @@ -7035,22 +7194,25 @@ SUBROUTINE node_values(model, node, fm, mm, fp, mp, tension, curvature, bend_mom ELSE tension = DOT_PRODUCT(t, f) + 0.5_wp*(coupling_tension(model, node - 1) + coupling_tension(model, node)) END IF - m_perp = m - DOT_PRODUCT(m, t)*t + stretch = node_stretch(model, node) + m_perp = stretch*(m - DOT_PRODUCT(m, t)*t) section_moment = NORM2(m_perp) + IF (PRESENT(moment_vector)) moment_vector = m_perp curvature = CD_ZERO cable_moment = CD_ZERO IF (PRESENT(curvature_vector)) curvature_vector = CD_ZERO DO side = 0, 1 e = node - 1 + side IF (e < 1 .OR. e > model%ne) CYCLE - IF (model%EI(e) > CD_ZERO .AND. model%EI(e) >= model%ei_resolve) THEN - IF (PRESENT(curvature_vector) .AND. section_moment/model%EI(e) > curvature) & - curvature_vector = m_perp/model%EI(e) - curvature = MAX(curvature, section_moment/model%EI(e)) - IF (ABS(model%EI_line(e) - model%EI(e)) <= CD_ZERO) THEN + ei_side = CD_HermiteCable_Dyn_Side_EI(model, e, node) + IF (model%EI(e) > CD_ZERO .AND. ei_side >= model%ei_resolve) THEN + IF (PRESENT(curvature_vector) .AND. stretch*section_moment/ei_side > curvature) & + curvature_vector = stretch*m_perp/ei_side + curvature = MAX(curvature, stretch*section_moment/ei_side) + IF (ABS(model%EI_line(e) - ei_side) <= CD_ZERO) THEN cable_moment = MAX(cable_moment, section_moment) ELSE - cable_moment = MAX(cable_moment, model%EI_line(e)*(section_moment/model%EI(e))) + cable_moment = MAX(cable_moment, model%EI_line(e)*(section_moment/ei_side)) END IF ELSE CALL gather_elem(model, e, qe) @@ -7058,12 +7220,13 @@ SUBROUTINE node_values(model, node, fm, mm, fp, mp, tension, curvature, bend_mom IF (ErrStat /= CD_HCABLE_OK) THEN ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = 'CD_HermiteCable_Dyn_Node_Recovery: '//TRIM(em2); RETURN END IF - IF (PRESENT(curvature_vector) .AND. trace > curvature) curvature_vector = kv + trace = trace*trace_scale(model, e, node) + IF (PRESENT(curvature_vector) .AND. trace > curvature) curvature_vector = kv*trace_scale(model, e, node) curvature = MAX(curvature, trace) IF (ABS(model%EI_line(e) - model%EI(e)) <= CD_ZERO) THEN cable_moment = MAX(cable_moment, section_moment) ELSE - cable_moment = MAX(cable_moment, model%EI_line(e)*trace) + cable_moment = MAX(cable_moment, model%EI_line(e)*trace/stretch) END IF END IF END DO @@ -7071,6 +7234,15 @@ SUBROUTINE node_values(model, node, fm, mm, fp, mp, tension, curvature, bend_mom IF (PRESENT(sleeve_moment)) sleeve_moment = MAX(CD_ZERO, section_moment - cable_moment) END SUBROUTINE node_values + PURE FUNCTION node_stretch(model, node) RESULT(stretch) + !! The stretch |r'| = 1 + e of a node, the length of its material tangent; 1 when degenerate. + TYPE(CD_HermiteCableDynType), INTENT(IN) :: model + INTEGER, INTENT(IN) :: node + REAL(wp) :: stretch + stretch = NORM2(model%q(6*(node - 1) + 4:6*(node - 1) + 6)) + IF (.NOT. stretch > CD_ZERO) stretch = CD_ONE + END FUNCTION node_stretch + PURE FUNCTION unit_tangent(model, node) RESULT(t) !! Unit material tangent of a node (internal arc direction); zero when degenerate. TYPE(CD_HermiteCableDynType), INTENT(IN) :: model @@ -7946,6 +8118,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Snapshot(model, ErrStat, ErrMsg) ! the cached committed force goes with the state it describes (see fc_clears) model%snap_fc_valid = model%fc_valid .AND. ALLOCATED(model%fc_R) model%snap_fc_clears = model%fc_clears + model%snap_stepped = .FALSE. IF (model%snap_fc_valid) THEN model%snap_fc_R = model%fc_R model%snap_fc_q = model%fc_q @@ -8036,11 +8209,12 @@ SUBROUTINE CD_HermiteCable_Dyn_Restore(model, ErrStat, ErrMsg) model%max_step_rotation = model%snap_max_step_rotation model%recovery_event_count = model%snap_recovery_event_count model%recovery_max_substeps_used = model%snap_recovery_max_substeps_used - ! The snapshot's cached committed force comes back with its state unless a setter has - ! invalidated the cache since the snapshot, so the next step takes the path it took after - ! the snapshot (the cache differs from a fresh evaluation in the last bits where a - ! friction anchor slid). Otherwise the next step evaluates it afresh. - IF (model%snap_fc_valid .AND. model%snap_fc_clears == model%fc_clears) THEN + ! The snapshot's cached committed force comes back with its state unless a setter + ! invalidated the cache between the snapshot and the step that followed it, so the next + ! step takes the path that step took (the cache differs from a fresh evaluation in the last + ! bits where a friction anchor slid). Otherwise the next step evaluates it afresh. + IF (.NOT. model%snap_stepped) model%snap_step_clears = model%fc_clears + IF (model%snap_fc_valid .AND. model%snap_fc_clears == model%snap_step_clears) THEN IF (model%tw_active) model%tw_fcR = model%snap_tw_fcR model%fc_R = model%snap_fc_R model%fc_q = model%snap_fc_q @@ -8075,6 +8249,7 @@ SUBROUTINE CD_HermiteCable_Dyn_End(model) IF (ALLOCATED(model%EA)) DEALLOCATE (model%EA) IF (ALLOCATED(model%EI)) DEALLOCATE (model%EI) IF (ALLOCATED(model%EI_line)) DEALLOCATE (model%EI_line) + IF (ALLOCATED(model%EI_node)) DEALLOCATE (model%EI_node) IF (ALLOCATED(model%rho_a)) DEALLOCATE (model%rho_a) IF (ALLOCATED(model%w)) DEALLOCATE (model%w) IF (ALLOCATED(model%BA)) DEALLOCATE (model%BA) diff --git a/src/CableDyn_Hydro.f90 b/src/CableDyn_Hydro.f90 index a8965123..5cde0efc 100644 --- a/src/CableDyn_Hydro.f90 +++ b/src/CableDyn_Hydro.f90 @@ -14,7 +14,7 @@ MODULE CableDyn_Hydro !! * Dean & Dalrymple (1991), linear Airy wave theory. !! * Wheeler (1970), vertical stretching through the splash zone. USE CableDyn_Precision, ONLY: wp, CD_ZERO, CD_ONE, CD_All_Finite, CD_Is_Finite - USE CableDyn_WaveMath, ONLY: CD_Wave_SinCos, CD_Wave_Exp + USE CableDyn_WaveMath, ONLY: CD_Wave_SinCos, CD_Wave_Exp, CD_Wave_Sum_Lanes, NLANE => CD_WAVE_LANES USE, INTRINSIC :: ISO_FORTRAN_ENV, ONLY: INT64 IMPLICIT NONE PRIVATE @@ -2239,7 +2239,8 @@ END SUBROUTINE CD_JONSWAP_Wave_Kinematics_Precomputed SUBROUTINE CD_Component_Wave_Kinematics(x, y, z, t, depth, direction_deg, stretch, & omega_comp, k_comp, amplitude, phase, & eta, velocity, acceleration, ErrStat, ErrMsg, inputs_validated, & - amplitude_scale, pdyn, exp_m2kd, tail_sums) + amplitude_scale, pdyn, exp_m2kd, tail_sums, comp_cos, comp_sin, & + eta_offset) !! Evaluate linear irregular-wave kinematics from a CALLER-SUPPLIED component table: !! the general (arbitrary length, explicit per-component phase) form of the fixed-size !! deterministic-phase JONSWAP evaluator above, sharing its conventions exactly -- @@ -2257,14 +2258,17 @@ SUBROUTINE CD_Component_Wave_Kinematics(x, y, z, t, depth, direction_deg, stretc !! own an immutable component table may skip repeating its O(n) metadata scan. The default !! remains full validation for public/direct calls. `amplitude_scale` multiplies every !! amplitude (a start-up ramp) without the caller forming a scaled copy of the table; the - !! result equals passing amplitude_scale*amplitude. + !! result equals passing amplitude_scale*amplitude to round-off. !! Each component's phase sine and cosine (one argument reduction for both) and its depth - !! exponential come from the block kernels of CableDyn_WaveMath into fixed-size local - !! buffers, and the sums then run in component order. Components beyond the buffer length - !! are evaluated block by block with the same expressions, so the result does not depend on - !! the buffer. exp_m2kd is the caller's table of EXP(-2 k_comp(i) depth) for this - !! depth (constant for a fixed table): it replaces the per-call exponential with the same - !! value, so the result is bit-identical. + !! exponential come from the block kernels of CableDyn_WaveMath. The phase factors of the + !! first NBUF components are kept for the velocity sums; components beyond that length are + !! evaluated block by block with the same expressions. The sums run in the interleaved lanes + !! of CableDyn_WaveMath, in an order fixed by the source, so the result does not depend on + !! the vector width or the number of threads. The direction cosines and the amplitude scale + !! multiply the finished sums. + !! exp_m2kd is the caller's table of EXP(-2 k_comp(i) depth) for this depth (constant for a + !! fixed table): it replaces the per-call exponential with the same value, so the result is + !! bit-identical. !! tail_sums(:, i) holds the sums over components j >= i of a_j omega_j, a_j omega_j^2 and !! a_j omega_j^2/k_j, for a table whose k_comp does not decrease. On -depth <= z_eval <= 0 !! both depth ratios fall as k grows, so the cosh ratio of the last summed component times @@ -2272,6 +2276,13 @@ SUBROUTINE CD_Component_Wave_Kinematics(x, y, z, t, depth, direction_deg, stretc !! once that bound is below half an ulp of the magnitude already summed: a deep point drops !! the short components that have decayed to nothing there, and the result changes at !! round-off only. The elevation always sums every component. + !! comp_cos and comp_sin, given together, hold the cosine and sine of each component's own + !! direction, in place of the one direction_deg of a long-crested table: a short-crested + !! table. The bound of tail_sums holds for any direction, since it bounds the magnitude. + !! eta_offset is a steady level added to the surface elevation the stretching maps and eta + !! returns, the components' wave numbers staying those of depth: the zero-frequency row of a + !! MoorDyn-C kinematics file, which MoorDyn-C adds to the elevation alone. It is not scaled + !! by amplitude_scale. REAL(wp), INTENT(IN) :: x, y, z, t, depth, direction_deg LOGICAL, INTENT(IN) :: stretch REAL(wp), INTENT(IN) :: omega_comp(:), k_comp(:), amplitude(:), phase(:) @@ -2283,18 +2294,11 @@ SUBROUTINE CD_Component_Wave_Kinematics(x, y, z, t, depth, direction_deg, stretc ! the linear dynamic pressure per unit density (sum over the components of ! (omega^2/k) a cosh(k(z+h))/sinh(kh) cos(theta)), as in CD_Airy_Wave_Kinematics_Precomputed REAL(wp), INTENT(OUT), OPTIONAL :: pdyn - REAL(wp), INTENT(IN), OPTIONAL :: exp_m2kd(:), tail_sums(:, :) - INTEGER :: i, n, nb, n_small, i0, j, nk - REAL(wp) :: amp - REAL(wp) :: cb, sb, x_along, z_eval - ! the elevation keeps the cosines of the first NBUF components; the velocity sums run in - ! blocks of NBLK (NBUF a multiple of NBLK, so a block lies wholly inside or past the buffer) - INTEGER, PARAMETER :: NBUF = 1024, NBLK = 32 - REAL(wp), PARAMETER :: HALF_ULP = 0.5_wp*EPSILON(CD_ONE) - REAL(wp) :: scl, e_neg, denom - REAL(wp) :: cth(NBUF), sth(NBUF), thb(NBUF), th(NBLK), bc(NBLK), bs(NBLK), ch(NBLK), sh(NBLK) - REAL(wp) :: mag(3), rest(3) - LOGICAL :: trust_inputs, at_bed, use_tail, small(NBLK) + REAL(wp), INTENT(IN), OPTIONAL :: exp_m2kd(:), tail_sums(:, :), comp_cos(:), comp_sin(:) + REAL(wp), INTENT(IN), OPTIONAL :: eta_offset + INTEGER :: n + REAL(wp) :: cb, sb, scl, offset + LOGICAL :: trust_inputs eta = CD_ZERO velocity = CD_ZERO @@ -2321,6 +2325,22 @@ SUBROUTINE CD_Component_Wave_Kinematics(x, y, z, t, depth, direction_deg, stretc RETURN END IF END IF + IF (PRESENT(comp_cos) .NEQV. PRESENT(comp_sin)) THEN + CALL fail(ErrStat, ErrMsg, 'comp_cos and comp_sin must be given together') + RETURN + END IF + IF (PRESENT(comp_cos)) THEN + IF (SIZE(comp_cos) /= n .OR. SIZE(comp_sin) /= n) THEN + CALL fail(ErrStat, ErrMsg, 'comp_cos and comp_sin must match the component-wave arrays') + RETURN + END IF + IF (.NOT. trust_inputs) THEN + IF (.NOT. (CD_All_Finite(comp_cos) .AND. CD_All_Finite(comp_sin))) THEN + CALL fail(ErrStat, ErrMsg, 'comp_cos and comp_sin must be finite') + RETURN + END IF + END IF + END IF ! Fortran does not require short-circuit evaluation: keep the O(n) scans inside a ! separate branch so the setup-validated hot path portably skips them at -O0 too. IF (.NOT. trust_inputs) THEN @@ -2342,6 +2362,12 @@ SUBROUTINE CD_Component_Wave_Kinematics(x, y, z, t, depth, direction_deg, stretc CALL fail(ErrStat, ErrMsg, 'point, time, and direction must be finite') RETURN END IF + offset = CD_ZERO + IF (PRESENT(eta_offset)) offset = eta_offset + IF (.NOT. CD_Is_Finite(offset)) THEN + CALL fail(ErrStat, ErrMsg, 'eta_offset must be finite') + RETURN + END IF ! Preserve the regular-wave reduction as an executable identity, independent of ! compiler reassociation choices in the general summation loop. Besides making the ! documented one-component contract portable, this avoids loop setup in a common @@ -2350,7 +2376,8 @@ SUBROUTINE CD_Component_Wave_Kinematics(x, y, z, t, depth, direction_deg, stretc ! a unit scale is exact, so the unscaled table is reproduced bit for bit scl = CD_ONE IF (PRESENT(amplitude_scale)) scl = amplitude_scale - IF (n == 1 .AND. ABS(phase(1)) <= CD_ZERO .AND. scl*amplitude(1) > CD_ZERO) THEN + IF (n == 1 .AND. ABS(phase(1)) <= CD_ZERO .AND. scl*amplitude(1) > CD_ZERO .AND. .NOT. PRESENT(comp_cos) & + .AND. ABS(offset) <= CD_ZERO) THEN CALL CD_Airy_Wave_Kinematics_Precomputed(x, y, z, t, 2.0_wp*(scl*amplitude(1)), omega_comp(1), k_comp(1), & depth, direction_deg, stretch, eta, velocity, acceleration, & ErrStat, ErrMsg, pdyn) @@ -2358,27 +2385,68 @@ SUBROUTINE CD_Component_Wave_Kinematics(x, y, z, t, depth, direction_deg, stretc END IF cb = COS(direction_deg*DEG2RAD) sb = SIN(direction_deg*DEG2RAD) + CALL component_sums(n, x, y, z, t, depth, stretch, scl, offset, cb, sb, omega_comp, k_comp, amplitude, phase, & + eta, velocity, acceleration, ErrStat, ErrMsg, pdyn, exp_m2kd, tail_sums, comp_cos, comp_sin) + END SUBROUTINE CD_Component_Wave_Kinematics + + SUBROUTINE component_sums(n, x, y, z, t, depth, stretch, scl, offset, cb, sb, omega_comp, k_comp, amplitude, phase, & + eta, velocity, acceleration, ErrStat, ErrMsg, pdyn, exp_m2kd, tail_sums, comp_cos, comp_sin) + !! The sums of CD_Component_Wave_Kinematics, on explicit-shape tables: their blocks pass to + !! the block kernels without a copy, and the loops run at unit stride. A long-crested table + !! forms each phase from the one distance along its direction and sums the horizontal + !! velocity and acceleration once, for cb and sb to multiply; a short-crested one (comp_cos, + !! comp_sin) forms each phase from the component's own direction and sums x and y apart. + !! offset (eta_offset) is added to the summed elevation before the stretching. + INTEGER, INTENT(IN) :: n + REAL(wp), INTENT(IN) :: x, y, z, t, depth, scl, offset, cb, sb + LOGICAL, INTENT(IN) :: stretch + REAL(wp), INTENT(IN) :: omega_comp(n), k_comp(n), amplitude(n), phase(n) + REAL(wp), INTENT(INOUT) :: eta, velocity(3), acceleration(3) + INTEGER, INTENT(INOUT) :: ErrStat + CHARACTER(*), INTENT(INOUT) :: ErrMsg + REAL(wp), INTENT(INOUT), OPTIONAL :: pdyn + REAL(wp), INTENT(IN), OPTIONAL :: exp_m2kd(n), tail_sums(3, n), comp_cos(n), comp_sin(n) + INTEGER :: nb, n_small, i0, i1, j, nk, m, lanes + REAL(wp) :: z_eval, x_along + ! the velocity sums run in blocks of NLANE and the elevation in chunks of NCHUNK (NBUF a + ! multiple of NCHUNK and NCHUNK of NLANE, so a block lies wholly inside or past the buffer) + INTEGER, PARAMETER :: NBUF = 4096, NCHUNK = 512 + REAL(wp), PARAMETER :: HALF_ULP = 0.5_wp*EPSILON(CD_ONE) + REAL(wp) :: denom, e2, first_ratio + REAL(wp) :: cth(NBUF), sth(NBUF), th(NCHUNK), bc(NCHUNK), bs(NCHUNK), ch(NLANE), sh(NLANE), e_bed(NLANE) + REAL(wp) :: lane_eta(NLANE), lane_vc(NLANE), lane_vs(NLANE), lane_as(NLANE), lane_ac(NLANE) + REAL(wp) :: lane_p(NLANE), lane_m(NLANE, 3), lane_vy(NLANE), lane_ay(NLANE), sum_vc, sum_vs, sum_as, sum_ac + REAL(wp) :: mag(3), rest(3) + LOGICAL :: at_bed, use_tail, want_p, spread + + spread = PRESENT(comp_cos) x_along = x*cb + y*sb nb = MIN(n, NBUF) + lanes = MIN(n, NLANE) ! the elevation sums every component (Wheeler stretching needs the total eta); the phase ! sines of the first NBUF components are kept for the velocity sums - DO i = 1, nb - thb(i) = k_comp(i)*x_along - omega_comp(i)*t + phase(i) - END DO - CALL CD_Wave_SinCos(nb, thb, sth, cth) - DO i = 1, nb - eta = eta + (scl*amplitude(i))*cth(i) - END DO - DO i0 = nb + 1, n, NBLK - nk = MIN(n - i0 + 1, NBLK) - DO j = 1, nk - th(j) = k_comp(i0 + j - 1)*x_along - omega_comp(i0 + j - 1)*t + phase(i0 + j - 1) - END DO - CALL CD_Wave_SinCos(nk, th, bs, bc) - DO j = 1, nk - eta = eta + (scl*amplitude(i0 + j - 1))*bc(j) - END DO + lane_eta(1:lanes) = CD_ZERO + DO i0 = 1, n, NCHUNK + nk = MIN(n - i0 + 1, NCHUNK) + i1 = i0 + nk - 1 + IF (spread) THEN + DO j = i0, i1 + th(j - i0 + 1) = k_comp(j)*(x*comp_cos(j) + y*comp_sin(j)) - omega_comp(j)*t + phase(j) + END DO + ELSE + DO j = 1, nk + th(j) = k_comp(i0 + j - 1)*x_along - omega_comp(i0 + j - 1)*t + phase(i0 + j - 1) + END DO + END IF + IF (i0 <= nb) THEN + CALL CD_Wave_SinCos(nk, th, sth(i0), cth(i0)) + CALL add_elevation_lanes(nk, amplitude(i0:i1), cth(i0), lane_eta) + ELSE + CALL CD_Wave_SinCos(nk, th, bs, bc) + CALL add_elevation_lanes(nk, amplitude(i0:i1), bc, lane_eta) + END IF END DO + eta = scl*CD_Wave_Sum_Lanes(lane_eta, lanes) + offset IF (stretch) THEN IF (z > eta) RETURN @@ -2398,24 +2466,27 @@ SUBROUTINE CD_Component_Wave_Kinematics(x, y, z, t, depth, direction_deg, stretc at_bed = ABS(z_eval + depth) <= 8.0_wp*EPSILON(CD_ONE)*MAX(CD_ONE, depth, ABS(z_eval)) use_tail = .FALSE. IF (PRESENT(tail_sums)) use_tail = z_eval <= CD_ZERO .AND. z_eval >= -depth - mag = CD_ZERO - DO i0 = 1, n, NBLK - nk = MIN(n - i0 + 1, NBLK) - IF (i0 <= nb) THEN - bc(1:nk) = cth(i0:i0 + nk - 1) - bs(1:nk) = sth(i0:i0 + nk - 1) - ELSE - DO j = 1, nk - th(j) = k_comp(i0 + j - 1)*x_along - omega_comp(i0 + j - 1)*t + phase(i0 + j - 1) - END DO - CALL CD_Wave_SinCos(nk, th, bs, bc) - END IF + want_p = PRESENT(pdyn) + lane_vc(1:lanes) = CD_ZERO + lane_vs(1:lanes) = CD_ZERO + lane_as(1:lanes) = CD_ZERO + lane_ac(1:lanes) = CD_ZERO + lane_p(1:lanes) = CD_ZERO + lane_m(1:lanes, :) = CD_ZERO + IF (spread) THEN + lane_vy(1:lanes) = CD_ZERO + lane_ay(1:lanes) = CD_ZERO + END IF + first_ratio = CD_ZERO + DO i0 = 1, n, NLANE + nk = MIN(n - i0 + 1, NLANE) + i1 = i0 + nk - 1 DO j = 1, nk th(j) = k_comp(i0 + j - 1)*z_eval END DO CALL CD_Wave_Exp(nk, th, ch) IF (PRESENT(exp_m2kd)) THEN - sh(1:nk) = exp_m2kd(i0:i0 + nk - 1) + sh(1:nk) = exp_m2kd(i0:i1) ELSE ! the intrinsic, as the caller's table exp_m2kd is built, so both give the same bits DO j = 1, nk @@ -2429,55 +2500,179 @@ SUBROUTINE CD_Component_Wave_Kinematics(x, y, z, t, depth, direction_deg, stretc sh(j) = CD_ZERO END DO ELSE - ! an underflowed exponential (exp(-2kd) of a short component in deep water) takes - ! profile_ratios' stable form; those components are redone one at a time below, the - ! rest stay branch-free here - small(1:nk) = ch(1:nk) <= TINY(CD_ONE) .OR. sh(1:nk) <= TINY(CD_ONE) - n_small = COUNT(small(1:nk)) + ! (e1 +- e2/e1)/(1 - e2) with e1 = exp(k z) and e2 = exp(-2 k depth) on one division; a + ! block in which e1^2 or e2 leaves the normal range takes the stable form of + ! profile_ratios, with the lower exponential of its own + n_small = 0 DO j = 1, nk - e_neg = sh(j)/MAX(ch(j), TINY(CD_ONE)) - denom = CD_ONE - sh(j) - sh(j) = (ch(j) - e_neg)/denom - ch(j) = (ch(j) + e_neg)/denom + n_small = n_small + MERGE(0, 1, ch(j)*ch(j) > TINY(CD_ONE) .AND. ch(j)*ch(j) < HUGE(CD_ONE) .AND. & + sh(j) > TINY(CD_ONE)) END DO - IF (n_small > 0) THEN + IF (n_small == 0) THEN + DO j = 1, nk + e2 = sh(j) + denom = CD_ONE/(ch(j)*(CD_ONE - e2)) + sh(j) = (ch(j)*ch(j) - e2)*denom + ch(j) = (ch(j)*ch(j) + e2)*denom + END DO + ELSE DO j = 1, nk - IF (small(j)) CALL profile_ratios(k_comp(i0 + j - 1), depth, z_eval, ch(j), sh(j)) + th(j) = -(k_comp(i0 + j - 1)*z_eval + 2.0_wp*(k_comp(i0 + j - 1)*depth)) + END DO + CALL CD_Wave_Exp(nk, th, e_bed) + DO j = 1, nk + denom = CD_ONE - sh(j) + sh(j) = (ch(j) - e_bed(j))/denom + ch(j) = (ch(j) + e_bed(j))/denom END DO END IF END IF - DO j = 1, nk - i = i0 + j - 1 - amp = (scl*amplitude(i))*omega_comp(i) - velocity(1) = velocity(1) + amp*ch(j)*bc(j)*cb - velocity(2) = velocity(2) + amp*ch(j)*bc(j)*sb - velocity(3) = velocity(3) + amp*sh(j)*bs(j) - amp = (scl*amplitude(i))*omega_comp(i)*omega_comp(i) - acceleration(1) = acceleration(1) + amp*ch(j)*bs(j)*cb - acceleration(2) = acceleration(2) + amp*ch(j)*bs(j)*sb - acceleration(3) = acceleration(3) - amp*sh(j)*bc(j) - END DO - IF (PRESENT(pdyn)) THEN - DO j = 1, nk - i = i0 + j - 1 - pdyn = pdyn + (scl*amplitude(i))*omega_comp(i)*omega_comp(i)/k_comp(i)*ch(j)*bc(j) - END DO + IF (i0 > nb) THEN + IF (spread) THEN + DO j = i0, i1 + th(j - i0 + 1) = k_comp(j)*(x*comp_cos(j) + y*comp_sin(j)) - omega_comp(j)*t + phase(j) + END DO + ELSE + DO j = 1, nk + th(j) = k_comp(i0 + j - 1)*x_along - omega_comp(i0 + j - 1)*t + phase(i0 + j - 1) + END DO + END IF + CALL CD_Wave_SinCos(nk, th, bs, bc) END IF + IF (spread) THEN + IF (i0 <= nb) THEN + CALL add_spread_lanes(nk, amplitude(i0:i1), omega_comp(i0:i1), comp_cos(i0:i1), comp_sin(i0:i1), ch, sh, & + cth(i0), sth(i0), lane_vc, lane_vy, lane_vs, lane_as, lane_ay, lane_ac, lane_m(1, 1), & + lane_m(1, 2)) + ELSE + CALL add_spread_lanes(nk, amplitude(i0:i1), omega_comp(i0:i1), comp_cos(i0:i1), comp_sin(i0:i1), ch, sh, & + bc, bs, lane_vc, lane_vy, lane_vs, lane_as, lane_ay, lane_ac, lane_m(1, 1), & + lane_m(1, 2)) + END IF + ELSE IF (i0 <= nb) THEN + CALL add_velocity_lanes(nk, amplitude(i0:i1), omega_comp(i0:i1), ch, sh, cth(i0), sth(i0), lane_vc, & + lane_vs, lane_as, lane_ac, lane_m(1, 1), lane_m(1, 2)) + ELSE + CALL add_velocity_lanes(nk, amplitude(i0:i1), omega_comp(i0:i1), ch, sh, bc, bs, lane_vc, lane_vs, lane_as, & + lane_ac, lane_m(1, 1), lane_m(1, 2)) + END IF + IF (want_p) THEN + IF (i0 <= nb) THEN + CALL add_pressure_lanes(nk, amplitude(i0:i1), omega_comp(i0:i1), k_comp(i0:i1), ch, cth(i0), lane_p, & + lane_m(1, 3)) + ELSE + CALL add_pressure_lanes(nk, amplitude(i0:i1), omega_comp(i0:i1), k_comp(i0:i1), ch, bc, lane_p, lane_m(1, 3)) + END IF + END IF + IF (i0 == 1) first_ratio = ch(1) IF (use_tail) THEN - DO j = 1, nk - i = i0 + j - 1 - mag(1) = mag(1) + amplitude(i)*omega_comp(i)*ch(j) - mag(2) = mag(2) + amplitude(i)*omega_comp(i)*omega_comp(i)*ch(j) - mag(3) = mag(3) + amplitude(i)*omega_comp(i)*omega_comp(i)/k_comp(i)*ch(j) - END DO IF (i0 + nk <= n) THEN rest = ch(nk)*tail_sums(:, i0 + nk) - IF (rest(1) <= HALF_ULP*mag(1) .AND. rest(2) <= HALF_ULP*mag(2) .AND. & - (.NOT. PRESENT(pdyn) .OR. rest(3) <= HALF_ULP*mag(3))) EXIT + ! the ratios fall as k grows, so first_ratio*tail_sums(:, 1) bounds the magnitude: the + ! lanes are added up only once the test below can pass + IF (rest(1) <= HALF_ULP*first_ratio*tail_sums(1, 1) .AND. & + rest(2) <= HALF_ULP*first_ratio*tail_sums(2, 1)) THEN + DO m = 1, 3 + mag(m) = CD_Wave_Sum_Lanes(lane_m(:, m), lanes) + END DO + IF (rest(1) <= HALF_ULP*mag(1) .AND. rest(2) <= HALF_ULP*mag(2) .AND. & + (.NOT. want_p .OR. rest(3) <= HALF_ULP*mag(3))) EXIT + END IF END IF END IF END DO - END SUBROUTINE CD_Component_Wave_Kinematics + sum_vc = scl*CD_Wave_Sum_Lanes(lane_vc, lanes) + sum_vs = scl*CD_Wave_Sum_Lanes(lane_vs, lanes) + sum_as = scl*CD_Wave_Sum_Lanes(lane_as, lanes) + sum_ac = scl*CD_Wave_Sum_Lanes(lane_ac, lanes) + IF (spread) THEN + velocity = [sum_vc, scl*CD_Wave_Sum_Lanes(lane_vy, lanes), sum_vs] + acceleration = [sum_as, scl*CD_Wave_Sum_Lanes(lane_ay, lanes), -sum_ac] + ELSE + velocity = [sum_vc*cb, sum_vc*sb, sum_vs] + acceleration = [sum_as*cb, sum_as*sb, -sum_ac] + END IF + IF (want_p) pdyn = scl*CD_Wave_Sum_Lanes(lane_p, lanes) + END SUBROUTINE component_sums + + PURE SUBROUTINE add_elevation_lanes(n, amplitude, c, lane) + !! lane(l) += a c over components l, l + NLANE, ... of the n given, for the elevation of + !! CD_Component_Wave_Kinematics. Explicit-shape, so that the loops vectorise. + INTEGER, INTENT(IN) :: n + REAL(wp), INTENT(IN) :: amplitude(n), c(n) + REAL(wp), INTENT(INOUT) :: lane(NLANE) + INTEGER :: i0, j + DO i0 = 0, n - 1, NLANE + DO j = 1, MIN(NLANE, n - i0) + lane(j) = lane(j) + amplitude(i0 + j)*c(i0 + j) + END DO + END DO + END SUBROUTINE add_elevation_lanes + + PURE SUBROUTINE add_velocity_lanes(n, amplitude, omega, ch, sh, c, s, lane_vc, lane_vs, lane_as, lane_ac, & + lane_mv, lane_ma) + !! One block (n <= NLANE) of the velocity and acceleration sums of + !! CD_Component_Wave_Kinematics, and of the magnitudes a omega ch and a omega^2 ch. + INTEGER, INTENT(IN) :: n + REAL(wp), INTENT(IN) :: amplitude(n), omega(n), ch(n), sh(n), c(n), s(n) + REAL(wp), INTENT(INOUT) :: lane_vc(n), lane_vs(n), lane_as(n), lane_ac(n), lane_mv(n), lane_ma(n) + INTEGER :: j + REAL(wp) :: wv, wa, gv, ga + DO j = 1, n + wv = amplitude(j)*omega(j) + wa = wv*omega(j) + gv = wv*ch(j) + ga = wa*ch(j) + lane_vc(j) = lane_vc(j) + gv*c(j) + lane_vs(j) = lane_vs(j) + (wv*sh(j))*s(j) + lane_as(j) = lane_as(j) + ga*s(j) + lane_ac(j) = lane_ac(j) + (wa*sh(j))*c(j) + lane_mv(j) = lane_mv(j) + gv + lane_ma(j) = lane_ma(j) + ga + END DO + END SUBROUTINE add_velocity_lanes + + PURE SUBROUTINE add_spread_lanes(n, amplitude, omega, cosb, sinb, ch, sh, c, s, lane_vx, lane_vy, lane_vs, lane_ax, & + lane_ay, lane_ac, lane_mv, lane_ma) + !! add_velocity_lanes for components each with its own direction: the horizontal sums go to + !! x and y apart. + INTEGER, INTENT(IN) :: n + REAL(wp), INTENT(IN) :: amplitude(n), omega(n), cosb(n), sinb(n), ch(n), sh(n), c(n), s(n) + REAL(wp), INTENT(INOUT) :: lane_vx(n), lane_vy(n), lane_vs(n), lane_ax(n), lane_ay(n), lane_ac(n), lane_mv(n), & + lane_ma(n) + INTEGER :: j + REAL(wp) :: wv, wa, gv, ga, uh + DO j = 1, n + wv = amplitude(j)*omega(j) + wa = wv*omega(j) + gv = wv*ch(j) + ga = wa*ch(j) + uh = gv*c(j) + lane_vx(j) = lane_vx(j) + uh*cosb(j) + lane_vy(j) = lane_vy(j) + uh*sinb(j) + lane_vs(j) = lane_vs(j) + (wv*sh(j))*s(j) + uh = ga*s(j) + lane_ax(j) = lane_ax(j) + uh*cosb(j) + lane_ay(j) = lane_ay(j) + uh*sinb(j) + lane_ac(j) = lane_ac(j) + (wa*sh(j))*c(j) + lane_mv(j) = lane_mv(j) + gv + lane_ma(j) = lane_ma(j) + ga + END DO + END SUBROUTINE add_spread_lanes + + PURE SUBROUTINE add_pressure_lanes(n, amplitude, omega, k, ch, c, lane_p, lane_mp) + !! One block (n <= NLANE) of the pressure sum of CD_Component_Wave_Kinematics. + INTEGER, INTENT(IN) :: n + REAL(wp), INTENT(IN) :: amplitude(n), omega(n), k(n), ch(n), c(n) + REAL(wp), INTENT(INOUT) :: lane_p(n), lane_mp(n) + INTEGER :: j + REAL(wp) :: gp + DO j = 1, n + gp = amplitude(j)*omega(j)*omega(j)/k(j)*ch(j) + lane_p(j) = lane_p(j) + gp*c(j) + lane_mp(j) = lane_mp(j) + gp + END DO + END SUBROUTINE add_pressure_lanes SUBROUTINE assemble_drag_force_only(q, v, elem_conn, l0, fluid_velocity, waterline_z, & rho, diameter, cdn, cdt, force, ErrStat, ErrMsg) diff --git a/src/CableDyn_Model.f90 b/src/CableDyn_Model.f90 index 39d4f3f7..683b18b2 100644 --- a/src/CableDyn_Model.f90 +++ b/src/CableDyn_Model.f90 @@ -6925,6 +6925,7 @@ SUBROUTINE commit_model_copy(from, to) to%initialized = from%initialized to%tension_only = from%tension_only + to%azimuth_signed = from%azimuth_signed to%n_dof = from%n_dof to%n_elem = from%n_elem to%has_seabed = from%has_seabed diff --git a/src/CableDyn_OpenFAST_Aggregate.f90 b/src/CableDyn_OpenFAST_Aggregate.f90 index be68b088..3fb0d595 100644 --- a/src/CableDyn_OpenFAST_Aggregate.f90 +++ b/src/CableDyn_OpenFAST_Aggregate.f90 @@ -63,7 +63,7 @@ MODULE CableDyn_OpenFAST_Aggregate USE CableDyn_HermiteCableDynamic, ONLY: CD_HermiteCable_Dyn_Profile_Enabled, CD_HermiteCableControlSaveType, & CD_HermiteCable_Dyn_Save_Control, CD_HermiteCable_Dyn_Restore_Control, & CD_HCDYN_OK - USE CableDyn_Model, ONLY: CD_ModelControlSaveType + USE CableDyn_Model, ONLY: CD_ModelControlSaveType, CD_Recompute_Model_Acceleration, CD_MODEL_OK USE CableDyn_DeckDriver, ONLY: CD_DeckAggregateType, CD_Init_Deck_Aggregate, CD_End_Deck_Aggregate, & CD_DECKDRV_OK, CD_DECKDRV_BADINPUT, CD_Eval_Aggregate_Channel, & CD_Cable_Deck_Node, & @@ -91,7 +91,8 @@ MODULE CableDyn_OpenFAST_Aggregate CD_DeckCurrentRuntimeType, CD_Deck_Current_Active, & CD_Deck_Current_Sample_System, CD_Deck_Current_Sample_Cables, & CD_Deck_Current_End - USE CableDyn_RangeOutput, ONLY: CD_RangeSet, CD_Range_End + USE CableDyn_RangeOutput, ONLY: CD_RangeSet, CD_Range_End, CD_Range_Has_Graph, CD_Range_Mirror_Size, & + CD_Range_Get_State, CD_Range_Set_State, CD_Range_Discard_Files, CD_RANGE_OK USE CableDyn_System, ONLY: CD_Get_System_Substep_Counts, CD_System_NLines, CD_System_Line_NDOF, & CD_System_Fallback_Count, CD_System_Fallback_Reset, & CD_Get_System_Line_State, & @@ -139,8 +140,11 @@ MODULE CableDyn_OpenFAST_Aggregate PUBLIC :: CD_AGG_ChannelHeader PUBLIC :: CD_AGG_EvalChannel PUBLIC :: CD_AGG_WriteStaticProfile - ! Range graphs of the LINES flag r on the standalone mixed route (not a coupled-host surface) - PUBLIC :: CD_AGG_Range_Sample, CD_AGG_Range_Write + ! Range graphs of the LINES flag r: the standalone mixed route and the single-turbine OpenFAST + ! module sample and write them; the accumulated state rides a host's checkpoint + PUBLIC :: CD_AGG_Reopen_Start + PUBLIC :: CD_AGG_Range_Sample, CD_AGG_Range_Write, CD_AGG_Range_Discard, CD_AGG_Range_Graphs, & + CD_AGG_Range_MirrorSize, CD_AGG_Range_Get_State, CD_AGG_Range_Set_State PUBLIC :: CD_AGG_GetInitMetadata, CD_AGG_GetInitLine ! Status space mirrors CD_FMF_* (and the CD_HFMF_* / CD_DECKDRV_* subsets) so the facade @@ -263,10 +267,11 @@ MODULE CableDyn_OpenFAST_Aggregate ! branch (mutually exclusive with has_rigid6); its snapshot buffer joins the aggregate snapshot. LOGICAL :: has_rod = .FALSE. TYPE(CD_RodRuntimeType) :: rod - ! Range graphs (standalone mixed route) and the touchdown references of the TDP + ! Range graphs (standalone mixed route, OpenFAST module) and the touchdown references of the TDP ! channels, keyed by deck line id. The references are fixed at the converged initial ! state (a checkpoint rebuild re-derives the same state), so they carry no step state. TYPE(CD_RangeSet) :: ranges + REAL(wp), ALLOCATABLE :: snap_range(:) ! the accumulated range graphs at the last CD_AGG_Snapshot ! Coupled/Vessel and CoupledPinned rods: the trailing host nodes of the moving surface, and ! the full EI=0 coupled-block scratch (3, n_system_coupled_dof/3) their RodA/B points are ! overlaid on. @@ -292,7 +297,7 @@ SUBROUTINE CD_AGG_Init_From_Deck(self, deck_path, dt, ErrStat, ErrMsg, env_gravi ptfm_init, external_fluid, coupled_positions, & n_turbines, turbine_ref_pos, farm_ptfm_init, forbid_deck_ambient, run_tmax, & range_files, host_orientation, static_current, prescribed_motion, static_run, & - deck_current, deck_turbines) + deck_current, deck_turbines, static_deck_current, range_discard_root) !! Build the aggregate from ONE mixed deck: partition its lines into {EI=0 mooring} !! and {finite-EI cable}, wrap the mooring subset into a CD_FMF, and own one CD_HFMF !! per cable. Same signature philosophy as CD_FMF_Init_From_Deck (host environment @@ -326,8 +331,10 @@ SUBROUTINE CD_AGG_Init_From_Deck(self, deck_path, dt, ErrStat, ErrMsg, env_gravi ! forbid_deck_ambient: the caller evaluates no deck waves (and, without deck_current, no ! deck current), so reject a deck that declares them (see CD_Init_Deck_Aggregate). LOGICAL, INTENT(IN), OPTIONAL :: forbid_deck_ambient - ! range_files: the caller writes the range graphs of the LINES flag r (CD_AGG_Range_*) + ! range_files: the caller samples and writes the range graphs of the LINES flag r (CD_AGG_Range_*) LOGICAL, INTENT(IN), OPTIONAL :: range_files + ! range_discard_root: the output root of a run that starts here (see CD_Init_Deck_Aggregate) + CHARACTER(*), INTENT(IN), OPTIONAL :: range_discard_root ! host_orientation: the caller passes the moving-point orientations with every update, ! which a finite-EI line restrained in torsion requires (see CD_Init_Deck_Aggregate) LOGICAL, INTENT(IN), OPTIONAL :: host_orientation @@ -343,6 +350,9 @@ SUBROUTINE CD_AGG_Init_From_Deck(self, deck_path, dt, ErrStat, ErrMsg, env_gravi ! deck_current: the caller marches the deck's own current (the standalone driver; see ! CD_Init_Deck_Aggregate): every step samples it at the nodes, at its t_committed LOGICAL, INTENT(IN), OPTIONAL :: deck_current + ! static_deck_current (with external_fluid): the deck's steady current sets the static start + ! only and the dynamics take the host field alone (see CD_Init_Deck_Aggregate) + LOGICAL, INTENT(IN), OPTIONAL :: static_deck_current ! deck_turbines: the standalone driver runs a TURBINES deck, whose TURBINES rows place the ! Turbine points and whose per-turbine records move them (see CD_Init_Deck_Aggregate) LOGICAL, INTENT(IN), OPTIONAL :: deck_turbines @@ -387,7 +397,8 @@ SUBROUTINE CD_AGG_Init_From_Deck(self, deck_path, dt, ErrStat, ErrMsg, env_gravi range_files=range_files, host_orientation=host_orientation, & static_current=static_current, prescribed_motion=prescribed_motion, & static_run=static_run, deck_current=deck_current, & - deck_turbines=deck_turbines) + deck_turbines=deck_turbines, static_deck_current=static_deck_current, & + range_discard_root=range_discard_root) IF (PRESENT(external_fluid)) THEN candidate%built_extfluid = external_fluid END IF @@ -1693,6 +1704,11 @@ SUBROUTINE CD_AGG_Snapshot(self, ErrStat, ErrMsg) ! a rewind to a state before the first step takes the start accelerations open again (new ! fluid fields re-solve them) self%snap_objects_committed = self%objects_committed + ! the range graphs as of the step start: a rewind takes back the sample of the abandoned step + IF (CD_Range_Has_Graph(self%ranges)) THEN + IF (.NOT. ALLOCATED(self%snap_range)) ALLOCATE (self%snap_range(CD_Range_Mirror_Size(self%ranges))) + CALL CD_Range_Get_State(self%ranges, self%snap_range) + END IF ErrStat = CD_AGG_OK ErrMsg = '' END SUBROUTINE CD_AGG_Snapshot @@ -1728,6 +1744,14 @@ SUBROUTINE CD_AGG_Restore(self, ErrStat, ErrMsg) IF (self%has_rigid6) CALL CD_Rigid6_Restore(self%rigid6) IF (self%has_rod) CALL CD_Rod_Restore(self%rod) self%objects_committed = self%snap_objects_committed + IF (ALLOCATED(self%snap_range)) THEN + CALL CD_Range_Set_State(self%ranges, self%snap_range, es, em) + IF (es /= CD_RANGE_OK) THEN + ErrStat = CD_AGG_BADINPUT + ErrMsg = 'CableDyn_OpenFAST_Aggregate: '//TRIM(em) + RETURN + END IF + END IF ErrStat = CD_AGG_OK ErrMsg = '' END SUBROUTINE CD_AGG_Restore @@ -1938,7 +1962,7 @@ SUBROUTINE CD_AGG_GetFluidNodePositions(self, xyz, ErrStat, ErrMsg) END SUBROUTINE CD_AGG_GetFluidNodePositions SUBROUTINE CD_AGG_SetFluidFields(self, fluid_velocity, fluid_acceleration, waterline_z, ErrStat, ErrMsg, & - dynamic_pressure) + dynamic_pressure, initial_condition) !! Prescribe the ambient fluid kinematics at every fluid sampling node for the NEXT !! advance: velocity + acceleration shaped (3, nfluid), the local free-surface !! elevation waterline_z shaped (nfluid) (drives the wetting of drag / FK / added @@ -1947,6 +1971,9 @@ SUBROUTINE CD_AGG_SetFluidFields(self, fluid_velocity, fluid_acceleration, water !! drag configuration, so the model layer alone cannot catch them). !! dynamic_pressure (optional, Pa, shaped (nfluid)): the wave dynamic pressure at the nodes; !! the rod blocks use it for the end-cap pressure (other blocks ignore it). + !! initial_condition (optional): the field at t = 0 of a host that starts its march in it: + !! the EI = 0 lines re-derive their start acceleration, as a cable's first field does and + !! as the standalone routes do at their t = 0 sample. !! Before the first committed step (and before a state mirror is restored) the integrated !! bodies and rods re-solve their start accelerations in the new fields: the accelerations !! solved at Init saw the zero fields of the build, and the first step starts from them. @@ -1955,9 +1982,13 @@ SUBROUTINE CD_AGG_SetFluidFields(self, fluid_velocity, fluid_acceleration, water INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg REAL(wp), INTENT(IN), OPTIONAL :: dynamic_pressure(:) + LOGICAL, INTENT(IN), OPTIONAL :: initial_condition INTEGER :: li, o1, o2, i, es + LOGICAL :: at_start CHARACTER(1024) :: em IF (.NOT. require_initialized(self, ErrStat, ErrMsg)) RETURN + at_start = .FALSE. + IF (PRESENT(initial_condition)) at_start = initial_condition ! Uniform contract for BOTH line families: host fields are only accepted on a ! build that declared them. Cables always carry a drag/added-mass configuration, ! so without this gate a plain cable-only build would silently accept held fields @@ -2013,6 +2044,14 @@ SUBROUTINE CD_AGG_SetFluidFields(self, fluid_velocity, fluid_acceleration, water ErrMsg = 'CableDyn_OpenFAST_Aggregate: fluid-field update: '//TRIM(em) RETURN END IF + IF (at_start) THEN + CALL CD_Recompute_Model_Acceleration(self%sys%fast%system%lines(li), es, em) + IF (es /= CD_MODEL_OK) THEN + ErrStat = CD_AGG_BADINPUT + ErrMsg = 'CableDyn_OpenFAST_Aggregate: start acceleration in the fluid field: '//TRIM(em) + RETURN + END IF + END IF END DO ! finite-EI cables: the held-field mode (frozen over the step; the drag adds the ! nodal velocity, the Froude-Krylov forcing takes the nodal acceleration, the @@ -2468,6 +2507,7 @@ SUBROUTINE CD_AGG_End(self, ErrStat, ErrMsg) self%has_rod = .FALSE. self%has_point3 = .FALSE. CALL CD_Range_End(self%ranges) + IF (ALLOCATED(self%snap_range)) DEALLOCATE (self%snap_range) self%nhost_rod = 0 self%nhost_body = 0 IF (ALLOCATED(self%hf_mask)) DEALLOCATE (self%hf_mask) @@ -2633,8 +2673,8 @@ SUBROUTINE CD_AGG_EvalChannel(self, i, value, ErrStat, ErrMsg) END SUBROUTINE CD_AGG_EvalChannel SUBROUTINE CD_AGG_Range_Sample(self, time, ErrStat, ErrMsg) - !! Accumulate the range graphs (LINES flag r) at a committed output time. The standalone - !! mixed route calls it once per written row; a coupled host never does. + !! Accumulate the range graphs (LINES flag r) at a committed output time: once per written + !! row on the standalone mixed route, once per committed mooring step in the OpenFAST module. TYPE(CD_AGG_ModuleType), INTENT(INOUT) :: self REAL(wp), INTENT(IN) :: time INTEGER, INTENT(OUT) :: ErrStat @@ -2671,6 +2711,69 @@ SUBROUTINE CD_AGG_Range_Write(self, root, ErrStat, ErrMsg) CALL map_deck_status(es, em, ErrStat, ErrMsg) END SUBROUTINE CD_AGG_Range_Write + SUBROUTINE CD_AGG_Range_Discard(self, root, ErrStat, ErrMsg) + !! Remove the .Line.range.out files an earlier run left (CD_Range_Discard_Files). + TYPE(CD_AGG_ModuleType), INTENT(IN) :: self + CHARACTER(*), INTENT(IN) :: root + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + INTEGER :: es + CHARACTER(1024) :: em + + IF (.NOT. require_initialized(self, ErrStat, ErrMsg)) RETURN + CALL CD_Range_Discard_Files(self%ranges, root, es, em) + IF (es /= CD_RANGE_OK) THEN + ErrStat = CD_AGG_BADINPUT + ErrMsg = 'CableDyn_OpenFAST_Aggregate: '//TRIM(em) + END IF + END SUBROUTINE CD_AGG_Range_Discard + + LOGICAL FUNCTION CD_AGG_Range_Graphs(self) RESULT(yes) + !! True when a line of the deck carries the LINES flag r; touchdown channels alone need no + !! sampling, they are evaluated from the state. + TYPE(CD_AGG_ModuleType), INTENT(IN) :: self + yes = CD_Range_Has_Graph(self%ranges) + END FUNCTION CD_AGG_Range_Graphs + + INTEGER FUNCTION CD_AGG_Range_MirrorSize(self) RESULT(n) + !! Reals of the accumulated range graphs (checkpoint pack surface; 0 without a LINES flag r). + TYPE(CD_AGG_ModuleType), INTENT(IN) :: self + n = CD_Range_Mirror_Size(self%ranges) + END FUNCTION CD_AGG_Range_MirrorSize + + SUBROUTINE CD_AGG_Range_Get_State(self, state, ErrStat, ErrMsg) + !! The accumulated range graphs, CD_AGG_Range_MirrorSize reals (checkpoint pack surface). + TYPE(CD_AGG_ModuleType), INTENT(IN) :: self + REAL(wp), INTENT(OUT) :: state(:) + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + + IF (.NOT. require_initialized(self, ErrStat, ErrMsg)) RETURN + IF (SIZE(state) /= CD_Range_Mirror_Size(self%ranges)) THEN + ErrStat = CD_AGG_BADINPUT + ErrMsg = 'CableDyn_OpenFAST_Aggregate: range-graph query needs CD_AGG_Range_MirrorSize slots' + RETURN + END IF + CALL CD_Range_Get_State(self%ranges, state) + END SUBROUTINE CD_AGG_Range_Get_State + + SUBROUTINE CD_AGG_Range_Set_State(self, state, ErrStat, ErrMsg) + !! Reload the accumulated range graphs of CD_AGG_Range_Get_State (checkpoint restore surface). + TYPE(CD_AGG_ModuleType), INTENT(INOUT) :: self + REAL(wp), INTENT(IN) :: state(:) + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + INTEGER :: es + CHARACTER(1024) :: em + + IF (.NOT. require_initialized(self, ErrStat, ErrMsg)) RETURN + CALL CD_Range_Set_State(self%ranges, state, es, em) + IF (es /= CD_RANGE_OK) THEN + ErrStat = CD_AGG_BADINPUT + ErrMsg = 'CableDyn_OpenFAST_Aggregate: '//TRIM(em) + END IF + END SUBROUTINE CD_AGG_Range_Set_State + SUBROUTINE CD_AGG_WriteStaticProfile(self, path, ErrStat, ErrMsg) !! Write the converged coupled initialization as an OrcaFlex-statics-style range table. !! This file is independent of the deck OUTPUTS selection: every line and every node is @@ -3231,6 +3334,15 @@ SUBROUTINE CD_AGG_Get_Rod_States(self, states, ErrStat, ErrMsg) IF (self%has_rod) CALL CD_Rod_Get_States(self%rod, states, ErrStat, ErrMsg) END SUBROUTINE CD_AGG_Get_Rod_States + SUBROUTINE CD_AGG_Reopen_Start(self) + !! Back to the start of the first step after a restored state mirror, which marks the object + !! accelerations committed: a state from before the first commit keeps its start open, as the + !! run it continues does, so the fluid fields of the first step re-solve them + !! (objects_committed). Until then they stay those of the mirror. + TYPE(CD_AGG_ModuleType), INTENT(INOUT) :: self + self%objects_committed = .FALSE. + END SUBROUTINE CD_AGG_Reopen_Start + SUBROUTINE CD_AGG_Set_Rod_States(self, states, ErrStat, ErrMsg) !! Restore the rod state and immediately scatter its end-point kinematics !! into the EI=0 system used by restart-time output and the next advance. diff --git a/src/CableDyn_RangeOutput.f90 b/src/CableDyn_RangeOutput.f90 index 7680cfa5..6b7210ec 100644 --- a/src/CableDyn_RangeOutput.f90 +++ b/src/CableDyn_RangeOutput.f90 @@ -27,7 +27,7 @@ MODULE CableDyn_RangeOutput !! length the deformed chord length from the grounded end to the TDP (0 while no node !! beyond the grounded end node is grounded). USE CableDyn_Precision, ONLY: wp, CD_ZERO, CD_ONE - USE CableDyn_PathIO, ONLY: CD_Open_Write + USE CableDyn_PathIO, ONLY: CD_Open_Write, CD_Path_Openable USE CableDyn_Bathymetry, ONLY: CD_BathymetryType, CD_Bathymetry_Floor, CD_Bathymetry_Is_Initialized, & CD_BATHY_OK USE CableDyn_SeabedContact, ONLY: CD_SEABED_CONTACT_BLEND @@ -38,7 +38,8 @@ MODULE CableDyn_RangeOutput PUBLIC :: CD_RangeLine, CD_RangeSet PUBLIC :: CD_Range_Setup, CD_Range_End, CD_Range_Needs_Sample, CD_Range_Commit_Sample PUBLIC :: CD_Range_Clearance, CD_Range_TDP_Evaluate, CD_Range_TDP_Reference - PUBLIC :: CD_Range_Write_Files + PUBLIC :: CD_Range_Write_Files, CD_Range_Discard_Files, CD_Range_Discard_File + PUBLIC :: CD_Range_Has_Graph, CD_Range_Mirror_Size, CD_Range_Get_State, CD_Range_Set_State PUBLIC :: CD_Is_TDP_Channel, CD_Parse_TDP_Channel INTEGER, PARAMETER, PUBLIC :: CD_RANGE_OK = 0, CD_RANGE_BADINPUT = 1, CD_RANGE_SOLVEFAIL = 2 @@ -50,6 +51,9 @@ MODULE CableDyn_RangeOutput INTEGER, PARAMETER, PUBLIC :: CD_TDP_S = 1, CD_TDP_X = 2, CD_TDP_Y = 3, CD_TDP_Z = 4, CD_TDP_LAY = 5, & CD_TDP_EXC = 6, CD_TDP_GRD = 7, CD_TDP_N = 7 + ! Leading reals of a line in CD_Range_Get_State: sample count, t_first, t_last, arc-ready flag. + INTEGER, PARAMETER :: RANGE_MIRROR_HEAD = 4 + TYPE :: CD_RangeLine INTEGER :: line_id = 0 INTEGER :: nn = 0 @@ -196,6 +200,96 @@ PURE LOGICAL FUNCTION CD_Range_Needs_Sample(set, il) RESULT(yes) yes = set%lines(il)%want_range .OR. set%lines(il)%want_tdp END FUNCTION CD_Range_Needs_Sample + PURE LOGICAL FUNCTION CD_Range_Has_Graph(set) RESULT(yes) + !! True when some line of the set accumulates a range graph (LINES flag r), not only + !! touchdown channels. + TYPE(CD_RangeSet), INTENT(IN) :: set + INTEGER :: il + yes = .FALSE. + IF (.NOT. set%active) RETURN + DO il = 1, SIZE(set%lines) + IF (set%lines(il)%want_range) yes = .TRUE. + END DO + END FUNCTION CD_Range_Has_Graph + + PURE INTEGER FUNCTION CD_Range_Mirror_Size(set) RESULT(n) + !! Reals of the accumulated range graphs (CD_Range_Get_State): per line with a range file, + !! the sample count, the first and last sample times, the arc-ready flag, the arc stations + !! and the three envelopes. + TYPE(CD_RangeSet), INTENT(IN) :: set + INTEGER :: il + n = 0 + IF (.NOT. set%active) RETURN + DO il = 1, SIZE(set%lines) + IF (set%lines(il)%want_range) n = n + RANGE_MIRROR_HEAD + (1 + 3*CD_RQ_N)*set%lines(il)%nn + END DO + END FUNCTION CD_Range_Mirror_Size + + PURE SUBROUTINE CD_Range_Get_State(set, state) + !! Copy the accumulated range graphs into state (CD_Range_Mirror_Size reals, every one + !! double precision whatever the host's output precision). The touchdown references and + !! the sample scratch are not part of it: a route rebuilds them. + TYPE(CD_RangeSet), INTENT(IN) :: set + REAL(wp), INTENT(OUT) :: state(:) + INTEGER :: il, j, k, nn + + k = 0 + IF (.NOT. set%active) RETURN + DO il = 1, SIZE(set%lines) + IF (.NOT. set%lines(il)%want_range) CYCLE + nn = set%lines(il)%nn + state(k + 1) = REAL(set%lines(il)%nsample, wp) + state(k + 2) = set%lines(il)%t_first + state(k + 3) = set%lines(il)%t_last + state(k + 4) = MERGE(CD_ONE, CD_ZERO, set%lines(il)%arc_ready) + k = k + RANGE_MIRROR_HEAD + state(k + 1:k + nn) = set%lines(il)%arc + k = k + nn + DO j = 1, nn + state(k + 1:k + CD_RQ_N) = set%lines(il)%vmin(:, j) + state(k + CD_RQ_N + 1:k + 2*CD_RQ_N) = set%lines(il)%vmax(:, j) + state(k + 2*CD_RQ_N + 1:k + 3*CD_RQ_N) = set%lines(il)%vsum(:, j) + k = k + 3*CD_RQ_N + END DO + END DO + END SUBROUTINE CD_Range_Get_State + + SUBROUTINE CD_Range_Set_State(set, state, ErrStat, ErrMsg) + !! Reload the accumulated range graphs of CD_Range_Get_State, of a set sized alike. + TYPE(CD_RangeSet), INTENT(INOUT) :: set + REAL(wp), INTENT(IN) :: state(:) + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + INTEGER :: il, j, k, nn + + ErrStat = CD_RANGE_OK + ErrMsg = '' + IF (SIZE(state) /= CD_Range_Mirror_Size(set)) THEN + ErrStat = CD_RANGE_BADINPUT + ErrMsg = 'CableDyn_RangeOutput: the saved range graphs do not match the lines of this run' + RETURN + END IF + k = 0 + IF (.NOT. set%active) RETURN + DO il = 1, SIZE(set%lines) + IF (.NOT. set%lines(il)%want_range) CYCLE + nn = set%lines(il)%nn + set%lines(il)%nsample = NINT(state(k + 1)) + set%lines(il)%t_first = state(k + 2) + set%lines(il)%t_last = state(k + 3) + set%lines(il)%arc_ready = state(k + 4) > CD_ZERO + k = k + RANGE_MIRROR_HEAD + set%lines(il)%arc = state(k + 1:k + nn) + k = k + nn + DO j = 1, nn + set%lines(il)%vmin(:, j) = state(k + 1:k + CD_RQ_N) + set%lines(il)%vmax(:, j) = state(k + CD_RQ_N + 1:k + 2*CD_RQ_N) + set%lines(il)%vsum(:, j) = state(k + 2*CD_RQ_N + 1:k + 3*CD_RQ_N) + k = k + 3*CD_RQ_N + END DO + END DO + END SUBROUTINE CD_Range_Set_State + SUBROUTINE CD_Range_Clearance(set, r, clearance, ErrStat, ErrMsg) !! Vertical centreline clearance above the seabed, z - z_floor(x, y), per node. TYPE(CD_RangeSet), INTENT(IN) :: set @@ -560,14 +654,63 @@ SUBROUTINE CD_Range_Write_Files(set, out_root, ErrStat, ErrMsg) WRITE (unit, '(A)', IOSTAT=ios) '' IF (ios /= 0) GOTO 910 END DO + FLUSH (unit, IOSTAT=ios) + IF (ios /= 0) GOTO 910 CLOSE (unit) END DO RETURN + ! a file that could not be completed does not stay behind looking like a finished graph 910 ErrStat = CD_RANGE_BADINPUT ErrMsg = 'CableDyn_RangeOutput: cannot write range-graph output file: '//TRIM(path) - CLOSE (unit) + CLOSE (unit, STATUS='DELETE', IOSTAT=ios) END SUBROUTINE CD_Range_Write_Files + SUBROUTINE CD_Range_Discard_Files(set, out_root, ErrStat, ErrMsg) + !! Remove the .Line.range.out files of an earlier run, so a run that ends + !! before writing its own leaves none behind to be taken for its result. A file that + !! cannot be removed fails here, at the start of the run, not after it. + TYPE(CD_RangeSet), INTENT(IN) :: set + CHARACTER(*), INTENT(IN) :: out_root + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + INTEGER :: il + + ErrStat = CD_RANGE_OK + ErrMsg = '' + IF (.NOT. set%active) RETURN + DO il = 1, SIZE(set%lines) + IF (.NOT. set%lines(il)%want_range) CYCLE + CALL CD_Range_Discard_File(out_root, set%lines(il)%line_id, ErrStat, ErrMsg) + IF (ErrStat /= CD_RANGE_OK) RETURN + END DO + END SUBROUTINE CD_Range_Discard_Files + + SUBROUTINE CD_Range_Discard_File(out_root, line_id, ErrStat, ErrMsg) + !! Remove the .Line.range.out file of an earlier run, if there is one. + CHARACTER(*), INTENT(IN) :: out_root + INTEGER, INTENT(IN) :: line_id + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + INTEGER :: unit, ios + CHARACTER(LEN(out_root) + 32) :: path + CHARACTER(16) :: id_text + + ErrStat = CD_RANGE_OK + ErrMsg = '' + WRITE (id_text, '(I0)') line_id + path = TRIM(out_root)//'.Line'//TRIM(id_text)//'.range.out' + IF (.NOT. CD_Path_Openable(TRIM(path), .FALSE.)) RETURN ! no such file + CALL CD_Open_Write(unit, TRIM(path), ios, status='OLD') + IF (ios == 0) CLOSE (unit, STATUS='DELETE', IOSTAT=ios) + IF (ios == 0) THEN + IF (CD_Path_Openable(TRIM(path), .FALSE.)) ios = 1 + END IF + IF (ios /= 0) THEN + ErrStat = CD_RANGE_BADINPUT + ErrMsg = 'CableDyn_RangeOutput: cannot replace the range-graph output file of an earlier run: '//TRIM(path) + END IF + END SUBROUTINE CD_Range_Discard_File + PURE LOGICAL FUNCTION CD_Is_TDP_Channel(lo) RESULT(yes) !! True for a name of the TDP family (lower case), valid or not: "tdp" then a digit. CHARACTER(*), INTENT(IN) :: lo diff --git a/src/CableDyn_WaveMath.f90 b/src/CableDyn_WaveMath.f90 index c77b7df1..90db3b48 100644 --- a/src/CableDyn_WaveMath.f90 +++ b/src/CableDyn_WaveMath.f90 @@ -2,7 +2,8 @@ ! SPDX-License-Identifier: Apache-2.0 ! Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University MODULE CableDyn_WaveMath - !! The sines, cosines and exponentials of the wave kinematics, evaluated in blocks. + !! The sines, cosines and exponentials of the wave kinematics, evaluated in blocks, and the + !! lanes that the sums over the components run in. !! !! An irregular sea sums, at every point and time, one phase cosine and sine and one depth !! exponential per component: thousands of transcendental evaluations per node and step. The @@ -17,14 +18,19 @@ MODULE CableDyn_WaveMath !! theta = n pi/2 + r with n the integer nearest 2 theta/pi and !! r = ((theta - n P1) - n P2) - n P3, the Cody-Waite reduction with pi/2 split in three !! parts whose products with |n| < 2^20 are exact (P1 and P2 carry 33 bits); sin r and cos r - !! by their Taylor series through r^17 and r^18 (|r| <= pi/4, truncation below 2e-19); the - !! quadrant n mod 4 selects and signs them; + !! by near-minimax polynomials of degree 13 and 14 (|r| <= pi/4; the fit is within 2.1e-17 + !! of sin r relative and 9e-19 of cos r); the quadrant n mod 4 selects and signs them; !! * exp(x) = 2^n exp(r) with n the integer nearest x/ln 2, r = (x - n L1) - n L2 (ln 2 in two - !! parts, |r| <= ln(2)/2), exp r by its Taylor series through r^13 (truncation below 1e-17) - !! and 2^n from its IEEE bits. + !! parts, |r| <= ln(2)/2), exp r by a near-minimax polynomial of degree 11 (within 1.7e-17 + !! relative) and 2^n from its IEEE bits. + !! + !! n never leaves the floating-point registers: the rounding to an integer leaves it in the low + !! bits of y + ROUNDER, whose bits give the quadrant and the exponent. A conversion to INTEGER + !! keeps GNU Fortran from vectorising the loop (AVX2 has none to 64 bits), as does a MERGE on a + !! floating-point comparison, so the signs are set on the bits too. !! !! Against quadruple precision (test_wave_math, and sweeps of 10^6 phases per decade) the - !! results are within 2.4 ulp over |theta| <= 2^20 pi/2 (sine, cosine) and 1.1 ulp over + !! results are within 3 ulp over |theta| <= 2^20 pi/2 (sine, cosine) and 2 ulp over !! -708 <= x <= 709 (exponential). Next to a zero of the sine or cosine, the double nearest !! n pi/2, the value is as small as theta's distance from the multiple (down to 6.2e-19) and the !! part of pi/2 below P1 + P2 + P3, 8.5e-32, adds |n| 8.5e-32 (at most 9e-26) to the error: @@ -40,7 +46,12 @@ MODULE CableDyn_WaveMath IMPLICIT NONE PRIVATE - PUBLIC :: CD_Wave_SinCos, CD_Wave_Exp + PUBLIC :: CD_Wave_SinCos, CD_Wave_Exp, CD_Wave_Sum_Lanes + + ! interleaved partial sums of a wave kinematics sum: component i in lane + ! MOD(i - 1, CD_WAVE_LANES) + 1. An element-by-element loop over the lanes vectorises under + ! a value-safe model, where a running sum cannot, and keeps a fixed order of additions. + INTEGER, PARAMETER, PUBLIC :: CD_WAVE_LANES = 64 REAL(wp), PARAMETER :: TWO_OVER_PI = 0.63661977236758134308_wp ! pi/2 = P1 + P2 + P3 (+ 8.5e-32): P1 and P2 carry 33 significant bits (fdlibm's pio2_1, pio2_2) @@ -56,14 +67,20 @@ MODULE CableDyn_WaveMath ! (y + ROUNDER) - ROUNDER is y rounded to the nearest integer for |y| < 2^51, in plain IEEE ! arithmetic (ANINT can become a library call that blocks the vector loop) REAL(wp), PARAMETER :: ROUNDER = 6755399441055744.0_wp ! 1.5 * 2^52 - ! Taylor coefficients 1/m! - REAL(wp), PARAMETER :: F2 = 0.5_wp, F3 = 1.0_wp/6.0_wp, F4 = 1.0_wp/24.0_wp, F5 = 1.0_wp/120.0_wp, & - F6 = 1.0_wp/720.0_wp, F7 = 1.0_wp/5040.0_wp, F8 = 1.0_wp/40320.0_wp, & - F9 = 1.0_wp/362880.0_wp, F10 = 1.0_wp/3628800.0_wp, F11 = 1.0_wp/39916800.0_wp, & - F12 = 1.0_wp/479001600.0_wp, F13 = 1.0_wp/6227020800.0_wp, & - F14 = 1.0_wp/87178291200.0_wp, F15 = 1.0_wp/1307674368000.0_wp, & - F16 = 1.0_wp/20922789888000.0_wp, F17 = 1.0_wp/355687428096000.0_wp, & - F18 = 1.0_wp/6402373705728000.0_wp + ! sin r = r + r^3 S(r^2), cos r = 1 - r^2/2 + r^4 C(r^2) and exp r = 1 + r + r^2 E(r): the + ! polynomials S, C and E, interpolated at the Chebyshev nodes of the reduced range + REAL(wp), PARAMETER :: S0 = -0.16666666666666666_wp, S1 = 0.008333333333330901_wp, & + S2 = -0.00019841269836686008_wp, S3 = 2.7557316064986773e-06_wp, & + S4 = -2.505112403138208e-08_wp, S5 = 1.59175674789068e-10_wp + REAL(wp), PARAMETER :: C0 = 0.041666666666666664_wp, C1 = -0.0013888888888887367_wp, & + C2 = 2.480158729872029e-05_wp, C3 = -2.7557317248229367e-07_wp, & + C4 = 2.0876141380294955e-09_wp, C5 = -1.1382280957702634e-11_wp + REAL(wp), PARAMETER :: E0 = 0.5000000000000001_wp, E1 = 0.16666666666666669_wp, & + E2 = 0.04166666666662348_wp, E3 = 0.008333333333330011_wp, & + E4 = 0.0013888888917538296_wp, E5 = 0.00019841269863303223_wp, & + E6 = 2.4801520792572694e-05_wp, E7 = 2.75572680728901e-06_wp, & + E8 = 2.762032742826824e-07_wp, E9 = 2.5100569275813683e-08_wp + INTEGER(INT64), PARAMETER :: SIGN_BIT = ISHFT(1_INT64, 63) CONTAINS @@ -73,34 +90,35 @@ PURE SUBROUTINE CD_Wave_SinCos(n, theta, s, c) INTEGER, INTENT(IN) :: n REAL(wp), INTENT(IN) :: theta(n) REAL(wp), INTENT(OUT) :: s(n), c(n) - INTEGER :: i, q - REAL(wp) :: th, fn, r, r2, ps, pc, ss, cc - LOGICAL :: far + INTEGER :: i, n_far + INTEGER(INT64) :: bits + REAL(wp) :: th, y, fn, r, r2, r4, ps, pc, ss, cc DO i = 1, n th = MAX(-THETA_MAX, MIN(THETA_MAX, theta(i))) - fn = (th*TWO_OVER_PI + ROUNDER) - ROUNDER + y = th*TWO_OVER_PI + ROUNDER + fn = y - ROUNDER r = ((th - fn*P1) - fn*P2) - fn*P3 r2 = r*r - ! Horner's scheme, its innermost terms first - ps = F13 + r2*(-F15 + r2*F17) - ps = r + (r*r2)*(-F3 + r2*(F5 + r2*(-F7 + r2*(F9 + r2*(-F11 + r2*ps))))) - pc = F12 + r2*(-F14 + r2*(F16 - r2*F18)) - pc = (1.0_wp - F2*r2) + (r2*r2)*(F4 + r2*(-F6 + r2*(F8 + r2*(-F10 + r2*pc)))) - ps = MERGE(ps, r, ABS(r) > 0.0_wp) ! sin(-0) = -0 (ifx's SIGN would drop the sign) + r4 = r2*r2 + ! Estrin's scheme: pairs of terms, for a shorter dependency chain than Horner's + ps = r + (r*r2)*((S0 + r2*S1) + r4*((S2 + r2*S3) + r4*(S4 + r2*S5))) + pc = (1.0_wp - 0.5_wp*r2) + r4*((C0 + r2*C1) + r4*((C2 + r2*C3) + r4*(C4 + r2*C5))) + ! sin(-0) = -0: the sum above gives +0, and the sign of sin r is that of r + ps = TRANSFER(IOR(TRANSFER(ps, 0_INT64), IAND(TRANSFER(r, 0_INT64), SIGN_BIT)), 0.0_wp) ! quadrant q = n mod 4: (sin, cos) = (ps, pc), (pc, -ps), (-ps, -pc), (-pc, ps) - q = IAND(INT(fn), 3) - ss = MERGE(pc, ps, BTEST(q, 0)) - cc = MERGE(ps, pc, BTEST(q, 0)) - s(i) = MERGE(-ss, ss, BTEST(q, 1)) - c(i) = MERGE(-cc, cc, BTEST(q + 1, 1)) + bits = TRANSFER(y, 0_INT64) + ss = MERGE(pc, ps, BTEST(bits, 0)) + cc = MERGE(ps, pc, BTEST(bits, 0)) + s(i) = TRANSFER(IEOR(TRANSFER(ss, 0_INT64), IAND(ISHFT(bits, 62), SIGN_BIT)), 0.0_wp) + c(i) = TRANSFER(IEOR(TRANSFER(cc, 0_INT64), IAND(ISHFT(bits + 1_INT64, 62), SIGN_BIT)), 0.0_wp) END DO ! beyond the reduction, or NaN (every comparison with a NaN is false) - far = .FALSE. + n_far = 0 DO i = 1, n - far = far .OR. .NOT. (ABS(theta(i)) <= THETA_MAX) + n_far = n_far + MERGE(0, 1, ABS(theta(i)) <= THETA_MAX) END DO - IF (.NOT. far) RETURN + IF (n_far == 0) RETURN DO i = 1, n IF (.NOT. (ABS(theta(i)) <= THETA_MAX)) THEN s(i) = SIN(theta(i)) @@ -114,31 +132,50 @@ PURE SUBROUTINE CD_Wave_Exp(n, x, e) INTEGER, INTENT(IN) :: n REAL(wp), INTENT(IN) :: x(n) REAL(wp), INTENT(OUT) :: e(n) - INTEGER :: i - INTEGER(INT64) :: m - REAL(wp) :: xc, fn, r, p - LOGICAL :: outside + INTEGER :: i, n_out + REAL(wp) :: xc, y, fn, r, r2, r4, p DO i = 1, n xc = MAX(X_LOW, MIN(X_HIGH, x(i))) - fn = (xc*INV_LN2 + ROUNDER) - ROUNDER + y = xc*INV_LN2 + ROUNDER + fn = y - ROUNDER r = (xc - fn*L1) - fn*L2 - p = F9 + r*(F10 + r*(F11 + r*(F12 + r*F13))) - p = 1.0_wp + r*(1.0_wp + r*(F2 + r*(F3 + r*(F4 + r*(F5 + r*(F6 + r*(F7 + r*(F8 + r*p)))))))) - ! times 2^m from its IEEE bits, m in [-1022, 1023] for the clamped argument (SCALE can + r2 = r*r + r4 = r2*r2 + p = ((E0 + r*E1) + r2*(E2 + r*E3)) + r4*(((E4 + r*E5) + r2*(E6 + r*E7)) + r4*(E8 + r*E9)) + p = (1.0_wp + r) + r2*p + ! times 2^m from its IEEE bits, m in [-1022, 1023] for the clamped argument: the low bits + ! of y hold m, and the shift leaves its biased value in the exponent field (SCALE can ! become a library call per element) - m = INT(fn, INT64) + 1023_INT64 - e(i) = p*TRANSFER(ISHFT(m, 52), p) + e(i) = p*TRANSFER(ISHFT(TRANSFER(y, 0_INT64) + 1023_INT64, 52), p) END DO ! outside the normal range, or NaN - outside = .FALSE. + n_out = 0 DO i = 1, n - outside = outside .OR. .NOT. (x(i) >= X_LOW .AND. x(i) <= X_HIGH) + n_out = n_out + MERGE(0, 1, x(i) >= X_LOW .AND. x(i) <= X_HIGH) END DO - IF (.NOT. outside) RETURN + IF (n_out == 0) RETURN DO i = 1, n IF (.NOT. (x(i) >= X_LOW .AND. x(i) <= X_HIGH)) e(i) = EXP(x(i)) END DO END SUBROUTINE CD_Wave_Exp + PURE FUNCTION CD_Wave_Sum_Lanes(lane, n) RESULT(total) + !! The first n <= CD_WAVE_LANES lanes added up pairwise, in a fixed order. + REAL(wp), INTENT(IN) :: lane(:) + INTEGER, INTENT(IN) :: n + REAL(wp) :: total, w(CD_WAVE_LANES) + INTEGER :: h, j, m + w(1:n) = lane(1:n) + m = n + DO WHILE (m > 1) + h = m/2 + DO j = 1, h + w(j) = w(j) + w(m - j + 1) + END DO + m = m - h + END DO + total = w(1) + END FUNCTION CD_Wave_Sum_Lanes + END MODULE CableDyn_WaveMath diff --git a/src/CableDyn_WaveSpectra.f90 b/src/CableDyn_WaveSpectra.f90 index 09487849..5ff84613 100644 --- a/src/CableDyn_WaveSpectra.f90 +++ b/src/CableDyn_WaveSpectra.f90 @@ -30,7 +30,7 @@ MODULE CableDyn_WaveSpectra !! |theta - theta_p| <= pi/2, K(s) = Gamma(s+1)/(sqrt(pi) Gamma(s+1/2)). USE CableDyn_Precision, ONLY: wp, CD_ZERO, CD_ONE, CD_Is_Finite USE CableDyn_Hydro, ONLY: CD_Solve_Dispersion_Wavenumber, CD_HYDRO_OK - USE CableDyn_WaveMath, ONLY: CD_Wave_SinCos, CD_Wave_Exp + USE CableDyn_WaveMath, ONLY: CD_Wave_SinCos, CD_Wave_Exp, CD_Wave_Sum_Lanes, CD_WAVE_LANES USE, INTRINSIC :: ISO_FORTRAN_ENV, ONLY: INT64 IMPLICIT NONE PRIVATE @@ -233,7 +233,9 @@ END FUNCTION CD_Spreading_Constant PURE SUBROUTINE CD_Spreading_Weights(s, n, offset, weight) !! Discretise D(theta) = K(s) cos^2s(theta) on [-pi/2, pi/2] into n equal-angle bins: !! offset(j) [rad] is the bin centre and weight(j) the bin integral of D (composite - !! Simpson quadrature, 128 intervals per bin), normalised to sum 1. + !! Simpson quadrature, 128 intervals per bin), normalised to sum 1. A bin below the smallest + !! normal number holds no energy: its weight is exactly zero, so which bins carry the sea + !! depends on s and n alone, not on where the spectrum's product with it underflows. REAL(wp), INTENT(IN) :: s INTEGER, INTENT(IN) :: n REAL(wp), INTENT(OUT) :: offset(n), weight(n) @@ -263,6 +265,7 @@ PURE SUBROUTINE CD_Spreading_Weights(s, n, offset, weight) END DO total = SUM(weight) IF (total > CD_ZERO) weight = weight/total + WHERE (weight < TINY(CD_ONE)) weight = CD_ZERO END SUBROUTINE CD_Spreading_Weights SUBROUTINE CD_Sea_Reset(sea) @@ -623,7 +626,9 @@ SUBROUTINE CD_Sea_Kinematics(sea, x, y, z, t, depth, scale, eta, velocity, accel !! scale multiplies every amplitude (the start-up ramp). pdyn: the linear dynamic !! pressure per unit density at the stretched depth [m^2/s^2]; zero above the surface. !! The phase sines and cosines (one argument reduction for both) and the depth exponentials - !! come from the block kernels of CableDyn_WaveMath. + !! come from the block kernels of CableDyn_WaveMath, and the sums run in interleaved lanes + !! added up at the end (CD_Wave_Sum_Lanes), so that they vectorise in a fixed order; the + !! amplitude scale multiplies the finished sums. TYPE(CD_SeaType), INTENT(IN) :: sea REAL(wp), INTENT(IN) :: x, y, z, t, depth, scale REAL(wp), INTENT(OUT) :: eta, velocity(3), acceleration(3) @@ -635,9 +640,12 @@ SUBROUTINE CD_Sea_Kinematics(sea, x, y, z, t, depth, scale, eta, velocity, accel ! chunk loop would compute the same values again), for the first NKEEP components; the rest ! are recomputed there. A multiple of NB, so each chunk is either kept whole or recomputed. INTEGER, PARAMETER :: NKEEP = 16*NB - REAL(wp) :: th(NB), cth(NB), sth(NB), ch(NB), sh(NB), ckeep(NKEEP), skeep(NKEEP) - INTEGER :: i0, i1, i, m, n_small + REAL(wp) :: th(NB), cth(NB), sth(NB), ch(NB), sh(NB), e_low(NB), ckeep(NKEEP), skeep(NKEEP) + REAL(wp) :: lane_eta(CD_WAVE_LANES), lane_vx(CD_WAVE_LANES), lane_vy(CD_WAVE_LANES), lane_vz(CD_WAVE_LANES) + REAL(wp) :: lane_ax(CD_WAVE_LANES), lane_ay(CD_WAVE_LANES), lane_az(CD_WAVE_LANES), lane_p(CD_WAVE_LANES) + INTEGER :: i0, i1, i, ic, j0, l, m, n_small, lanes REAL(wp) :: z_eval, a, e1, e2, den, uh + LOGICAL :: same_depth eta = CD_ZERO velocity = CD_ZERO @@ -649,6 +657,8 @@ SUBROUTINE CD_Sea_Kinematics(sea, x, y, z, t, depth, scale, eta, velocity, accel CALL fail(ErrStat, ErrMsg, 'sea kinematics called before synthesis') RETURN END IF + lanes = MIN(sea%n_comp, CD_WAVE_LANES) + lane_eta(1:lanes) = CD_ZERO DO i0 = 1, sea%n_comp, NB i1 = MIN(sea%n_comp, i0 + NB - 1) m = i1 - i0 + 1 @@ -656,14 +666,17 @@ SUBROUTINE CD_Sea_Kinematics(sea, x, y, z, t, depth, scale, eta, velocity, accel th(i - i0 + 1) = sea%k(i)*(x*sea%cosb(i) + y*sea%sinb(i)) - sea%omega(i)*t + sea%phase(i) END DO CALL CD_Wave_SinCos(m, th, sth, cth) - DO i = i0, i1 - eta = eta + (scale*sea%amplitude(i))*cth(i - i0 + 1) + DO j0 = 0, m - 1, CD_WAVE_LANES + DO l = 1, MIN(CD_WAVE_LANES, m - j0) + lane_eta(l) = lane_eta(l) + sea%amplitude(i0 + j0 + l - 1)*cth(j0 + l) + END DO END DO IF (i1 <= NKEEP) THEN ckeep(i0:i1) = cth(1:m) skeep(i0:i1) = sth(1:m) END IF END DO + eta = scale*CD_Wave_Sum_Lanes(lane_eta, lanes) IF (z > eta) RETURN IF (.NOT. (depth + eta > EPSILON(depth)*depth)) THEN CALL fail(ErrStat, ErrMsg, 'Wheeler stretching is undefined: the wave trough reaches the seabed '// & @@ -671,6 +684,14 @@ SUBROUTINE CD_Sea_Kinematics(sea, x, y, z, t, depth, scale, eta, velocity, accel RETURN END IF z_eval = MIN(CD_ZERO, (z - eta)*depth/(depth + eta)) + same_depth = ABS(depth - sea%depth) <= CD_ZERO + lane_vx(1:lanes) = CD_ZERO + lane_vy(1:lanes) = CD_ZERO + lane_vz(1:lanes) = CD_ZERO + lane_ax(1:lanes) = CD_ZERO + lane_ay(1:lanes) = CD_ZERO + lane_az(1:lanes) = CD_ZERO + lane_p(1:lanes) = CD_ZERO DO i0 = 1, sea%n_comp, NB i1 = MIN(sea%n_comp, i0 + NB - 1) m = i1 - i0 + 1 @@ -684,48 +705,72 @@ SUBROUTINE CD_Sea_Kinematics(sea, x, y, z, t, depth, scale, eta, velocity, accel CALL CD_Wave_SinCos(m, th, sth, cth) END IF ! cosh(k(z+h))/sinh(kh) and sinh(k(z+h))/sinh(kh) without overflow, from e1 = EXP(k z) - ! and e2 = EXP(-k (z + 2h)) = e2kd/e1 at the synthesis depth: one exponential per - ! component. A component whose e1 or e2kd underflows, or a call at another depth than the - ! synthesis, takes the second exponential instead. + ! and e2 = EXP(-2 k h) at the synthesis depth, on one division. A block in which e1^2 or + ! e2 underflows, or a call at another depth than the synthesis, forms the lower + ! exponential EXP(-k (z + 2h)) on its own. DO i = i0, i1 th(i - i0 + 1) = sea%k(i)*z_eval END DO CALL CD_Wave_Exp(m, th, ch) - n_small = 0 - IF (ABS(depth - sea%depth) <= CD_ZERO) THEN - n_small = COUNT(ch(1:m) <= TINY(CD_ONE) .OR. sea%e2kd(i0:i1) <= TINY(CD_ONE)) + n_small = 1 + IF (same_depth) THEN + n_small = 0 DO i = i0, i1 - e1 = ch(i - i0 + 1) - e2 = sea%e2kd(i)/MAX(e1, TINY(CD_ONE)) - den = CD_ONE - sea%e2kd(i) - ch(i - i0 + 1) = (e1 + e2)/den - sh(i - i0 + 1) = (e1 - e2)/den + n_small = n_small + MERGE(0, 1, ch(i - i0 + 1)*ch(i - i0 + 1) > TINY(CD_ONE) .AND. sea%e2kd(i) > TINY(CD_ONE)) END DO END IF - IF (n_small > 0 .OR. ABS(depth - sea%depth) > CD_ZERO) THEN + IF (n_small == 0) THEN DO i = i0, i1 - e1 = EXP(sea%k(i)*z_eval) - IF (ABS(depth - sea%depth) <= CD_ZERO .AND. e1 > TINY(CD_ONE) .AND. sea%e2kd(i) > TINY(CD_ONE)) CYCLE - e2 = EXP(-sea%k(i)*(z_eval + 2.0_wp*depth)) + e1 = ch(i - i0 + 1) + e2 = sea%e2kd(i) + den = CD_ONE/(e1*(CD_ONE - e2)) + ch(i - i0 + 1) = (e1*e1 + e2)*den + sh(i - i0 + 1) = (e1*e1 - e2)*den + END DO + ELSE + DO i = i0, i1 + th(i - i0 + 1) = -sea%k(i)*(z_eval + 2.0_wp*depth) + END DO + CALL CD_Wave_Exp(m, th, e_low) + DO i = i0, i1 + e1 = ch(i - i0 + 1) + e2 = e_low(i - i0 + 1) den = CD_ONE - sea%e2kd(i) ch(i - i0 + 1) = (e1 + e2)/den sh(i - i0 + 1) = (e1 - e2)/den END DO END IF - DO i = 1, m - a = scale*sea%amplitude(i0 + i - 1)*sea%omega(i0 + i - 1) - uh = a*ch(i)*cth(i) - velocity(1) = velocity(1) + uh*sea%cosb(i0 + i - 1) - velocity(2) = velocity(2) + uh*sea%sinb(i0 + i - 1) - velocity(3) = velocity(3) + a*sh(i)*sth(i) - a = a*sea%omega(i0 + i - 1) - uh = a*ch(i)*sth(i) - acceleration(1) = acceleration(1) + uh*sea%cosb(i0 + i - 1) - acceleration(2) = acceleration(2) + uh*sea%sinb(i0 + i - 1) - acceleration(3) = acceleration(3) - a*sh(i)*cth(i) - IF (PRESENT(pdyn)) pdyn = pdyn + a/sea%k(i0 + i - 1)*ch(i)*cth(i) + DO j0 = 0, m - 1, CD_WAVE_LANES + DO l = 1, MIN(CD_WAVE_LANES, m - j0) + i = j0 + l + ic = i0 + i - 1 + a = sea%amplitude(ic)*sea%omega(ic) + uh = a*ch(i)*cth(i) + lane_vx(l) = lane_vx(l) + uh*sea%cosb(ic) + lane_vy(l) = lane_vy(l) + uh*sea%sinb(ic) + lane_vz(l) = lane_vz(l) + a*sh(i)*sth(i) + a = a*sea%omega(ic) + uh = a*ch(i)*sth(i) + lane_ax(l) = lane_ax(l) + uh*sea%cosb(ic) + lane_ay(l) = lane_ay(l) + uh*sea%sinb(ic) + lane_az(l) = lane_az(l) - a*sh(i)*cth(i) + END DO END DO + IF (PRESENT(pdyn)) THEN + DO j0 = 0, m - 1, CD_WAVE_LANES + DO l = 1, MIN(CD_WAVE_LANES, m - j0) + i = j0 + l + ic = i0 + i - 1 + lane_p(l) = lane_p(l) + sea%amplitude(ic)*sea%omega(ic)*sea%omega(ic)/sea%k(ic)*ch(i)*cth(i) + END DO + END DO + END IF END DO + velocity = scale*[CD_Wave_Sum_Lanes(lane_vx, lanes), CD_Wave_Sum_Lanes(lane_vy, lanes), & + CD_Wave_Sum_Lanes(lane_vz, lanes)] + acceleration = scale*[CD_Wave_Sum_Lanes(lane_ax, lanes), CD_Wave_Sum_Lanes(lane_ay, lanes), & + CD_Wave_Sum_Lanes(lane_az, lanes)] + IF (PRESENT(pdyn)) pdyn = scale*CD_Wave_Sum_Lanes(lane_p, lanes) END SUBROUTINE CD_Sea_Kinematics SUBROUTINE tokenize(line, tok, ntok) diff --git a/src/cabledyn.def b/src/cabledyn.def index 107e180e..5f69420c 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 @@ -31,6 +32,10 @@ EXPORTS CableDyn_RestoreState CableDyn_SaveState CableDyn_Step + CableDyn_SetHostFluid + CableDyn_NFluidNodes + CableDyn_GetFluidNodePositions + CableDyn_UpdateFluidFields CableDyn_UpdatePointFluidFields CableDyn_UpdateRotations CableDyn_UpdateStates diff --git a/src/cabledyn.exp b/src/cabledyn.exp index 7c6ff0d0..af5b1dba 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 @@ -30,6 +31,10 @@ _CableDyn_NPoints _CableDyn_RestoreState _CableDyn_SaveState _CableDyn_Step +_CableDyn_SetHostFluid +_CableDyn_NFluidNodes +_CableDyn_GetFluidNodePositions +_CableDyn_UpdateFluidFields _CableDyn_UpdatePointFluidFields _CableDyn_UpdateRotations _CableDyn_UpdateStates diff --git a/src/cabledyn.map b/src/cabledyn.map index 53952d5c..a60d41eb 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; @@ -33,6 +34,10 @@ CableDyn_RestoreState; CableDyn_SaveState; CableDyn_Step; + CableDyn_SetHostFluid; + CableDyn_NFluidNodes; + CableDyn_GetFluidNodePositions; + CableDyn_UpdateFluidFields; CableDyn_UpdatePointFluidFields; CableDyn_UpdateRotations; CableDyn_UpdateStates; diff --git a/src/openfast/CableDyn_OF.f90 b/src/openfast/CableDyn_OF.f90 index 14bb6997..fd427bbb 100644 --- a/src/openfast/CableDyn_OF.f90 +++ b/src/openfast/CableDyn_OF.f90 @@ -49,9 +49,11 @@ MODULE CableDyn !! Fixed/Coupled(Vessel) points, EI=0 mooring lines (BA damping + the Morison set), !! finite-EI power cables, penalty seabed, nonzero initial platform displacement !! (PtfmInit), the module's own mooring step (supercycling + zero-order-hold loads), - !! glue correction iterations (NumCrctn > 0: snapshot-rewind re-advance), SeaState + !! glue step retries (snapshot-rewind re-advance at any dtM/DT), SeaState !! wave/current kinematics sampled at the line nodes (single-turbine only; see the - !! farm scope at the use_extf guard), checkpoint restart (rebuild from the packed + !! farm scope at the use_extf guard), range graphs of the LINES flag r (single-turbine + !! only: .Line.range.out, sampled at every committed mooring step, written at the + !! end of a run the host reports complete), checkpoint restart (rebuild from the packed !! deck + state mirror, accepted on committed mooring-interval boundaries), !! linearization with the line dynamics (as MoorDyn-F: the continuous states are the !! EI=0 lines' interior-node positions and the finite-EI cables' free DOFs, positions @@ -81,7 +83,7 @@ MODULE CableDyn USE CableDyn_Conventions, ONLY: CD_NATIVE_CHANNEL_FMT USE CableDyn_Hydro, ONLY: CD_Current_Profile_Velocity, CD_SeaState_Steady_Current, CD_HYDRO_OK USE CableDyn_DeckDriver, ONLY: CD_Deck_Query_dtM, CD_DECKDRV_OK, CD_Is_Line_Node_Channel, & - CD_LINE_NODE_NUMBERING_NOTE + CD_LINE_NODE_NUMBERING_NOTE, CD_INIT_NOTE_LEN USE CableDyn_OpenFAST_Aggregate, ONLY: CD_AGG_ModuleType, CD_AGG_Init_From_Deck, CD_AGG_UpdateStates_Moving, & CD_AGG_Step_Moving, CD_AGG_CalcOutput, CD_AGG_GetMovingPointMesh, & CD_AGG_Snapshot, CD_AGG_Restore, & @@ -98,7 +100,9 @@ MODULE CableDyn CD_AGG_NFluidNodes, CD_AGG_GetFluidNodePositions, CD_AGG_SetFluidFields, & CD_AGG_NumChannels, CD_AGG_ChannelHeader, CD_AGG_EvalChannel, & CD_AGG_GetInitMetadata, CD_AGG_GetInitLine, & - CD_AGG_WriteStaticProfile + CD_AGG_WriteStaticProfile, CD_AGG_Range_Graphs, CD_AGG_Range_Sample, & + CD_AGG_Range_Write, CD_AGG_Range_MirrorSize, CD_AGG_Reopen_Start, & + CD_AGG_Range_Get_State, CD_AGG_Range_Set_State USE CableDyn_OpenFAST_HermiteFMF, ONLY: CD_HFMF_MirrorSize, CD_HFMF_PackMirror, CD_HFMF_UnpackMirror, & CD_HFMF_FrictionMirrorSize, CD_HFMF_BoundaryMirrorSize, & CD_HFMF_ForceMirrorSize, CD_HFMF_TorsionMirrorSize, CD_HFMF_VIVMirrorSize, & @@ -138,10 +142,14 @@ MODULE CableDyn PRIVATE + ! Depth fraction above which the deepest interval of the SeaState grid is noted at initialisation + ! (note_seastate_deep_interval). + REAL(wp), PARAMETER :: SEASTATE_DEEP_INTERVAL = 0.05_wp + ! the kinds of first-order linearization state (lin_state_map) INTEGER(IntKi), PARAMETER :: LIN1_VE = 1, LIN1_SYROPE = 2 - TYPE(ProgDesc), PARAMETER :: CableDyn_ProgDesc = ProgDesc( 'CableDyn', 'v0.4.0', '2026-11-04' ) + TYPE(ProgDesc), PARAMETER :: CableDyn_ProgDesc = ProgDesc( 'CableDyn', 'v0.4.1', '2026-11-08' ) INTERFACE ! 1 when the CableDyn core targets x86-64-v3 (CABLEDYN_TARGET_AVX2) and carries the ! start-up processor check of src/cabledyn_cpu_guard.c; the reference also keeps that @@ -238,6 +246,12 @@ END FUNCTION cd_cpu_guard_armed REAL(DbKi) :: native_wrote_t = 0.0_DbKi LOGICAL :: native_pending = .FALSE. CHARACTER(1024) :: native_root = '' + ! Range graphs (LINES flag r): sampled by the aggregate once per committed mooring step + ! (sample_range); ws_range is the double-precision transfer buffer to the OtherState + ! mirror, which a checkpoint packs and a correction iteration's state copy rewinds. + LOGICAL :: range_on = .FALSE. + LOGICAL :: range_sampled = .FALSE. ! this instance has sampled (not a checkpoint alone) + REAL(wp), ALLOCATABLE :: ws_range(:) ! SeaState ambient-fluid sampling (host-driven wave/current kinematics): when the ! glue's WaveField declares waves or a current, the aggregate is built wave-capable ! (external_fluid) and every mooring advance samples the field at the line nodes' @@ -259,6 +273,16 @@ END FUNCTION cd_cpu_guard_armed ! rebuild_from_checkpoint. INTEGER(IntKi) :: serial = 0 REAL(DbKi) :: t_commit = -1.0_DbKi + ! t_commit_prev: the commit the last advance started from, which a glue retry of that step + ! restores (step_retry). t_run_end: the last time the run has committed, later than + ! t_commit after a restore of an operating point; rewound marks that state, in which the + ! rows and the range samples past t_commit stand until the run takes another step. + REAL(DbKi) :: t_commit_prev = -1.0_DbKi + REAL(DbKi) :: t_run_end = -1.0_DbKi + LOGICAL :: rewound = .FALSE. + ! restored_unserved: a rebuilt, supercycled instance whose committed loads have not been + ! evaluated yet (serve_committed_loads) + LOGICAL :: restored_unserved = .FALSE. ! Opt-in end-to-end profiler. CABLEDYN_PROFILE=1 arms these counters and ! the existing detailed Hermite profiler; ordinary production runs pay no ! SYSTEM_CLOCK overhead. Times are accumulated only for successful calls. @@ -423,21 +447,21 @@ SUBROUTINE open_native_outputs( id, root, t_start, ErrStat, ErrMsg, write_profil END SUBROUTINE open_native_outputs !---------------------------------------------------------------------------------------------------------------------------------- - SUBROUTINE restore_native_outputs( id, old_id, root, t_start, restart_serial, dt_moor, nsuper, & - ErrStat, ErrMsg ) - !! Transfer the run-level native history from a superseded in-process solver - !! instance to its checkpoint reconstruction. If the restored state rewinds - !! time, remove the abandoned committed rows before handing over the still-open - !! unit. A fresh-process restart has no live owner and creates a new history. - INTEGER(IntKi), INTENT(IN) :: id, old_id, restart_serial, nsuper + SUBROUTINE restore_native_outputs( id, old_id, root, t_start, restart_serial, ErrStat, ErrMsg ) + !! Transfer the run-level native history from a superseded in-process solver instance to + !! its checkpoint reconstruction: the restore of an operating point (CalcSteady) keeps the + !! rows of the run, and only a later step abandons those past the restored commit + !! (resume_after_rewind). A fresh-process restart has no live owner and creates a new + !! history. Neither route writes the static profile: the state here is the checkpoint's, + !! not the static equilibrium that file records. + INTEGER(IntKi), INTENT(IN) :: id, old_id, restart_serial CHARACTER(*), INTENT(IN) :: root - REAL(DbKi), INTENT(IN) :: t_start, dt_moor + REAL(DbKi), INTENT(IN) :: t_start INTEGER(IntKi), INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg character(*), parameter :: RoutineName = 'CableDyn:restore_native_outputs' - INTEGER(IntKi) :: ntrim, k, ios - REAL(DbKi) :: delta, tol + INTEGER(IntKi) :: ios LOGICAL :: live_owner, same_history ErrStat = ErrID_None @@ -452,14 +476,14 @@ SUBROUTINE restore_native_outputs( id, old_id, root, t_start, restart_serial, dt END IF END IF IF (.NOT. live_owner) THEN - CALL open_native_outputs( id, root, t_start, ErrStat, ErrMsg ) + CALL open_native_outputs( id, root, t_start, ErrStat, ErrMsg, write_profile=.FALSE. ) RETURN END IF ! A fresh-process restart first passes through CD_Init, which creates a ! provisional t=0 stream. The restored generation stamp differs, so release ! that stream and recreate it at the checkpoint. It must not be treated as a - ! same-process rewind because it contains no pre-checkpoint history. + ! same-process restore because it contains no pre-checkpoint history. IF (.NOT. same_history) THEN CLOSE (Inst(old_id)%native_un, IOSTAT=ios) IF (ios /= 0) THEN @@ -472,60 +496,74 @@ SUBROUTINE restore_native_outputs( id, old_id, root, t_start, restart_serial, dt Inst(old_id)%native_wrote_n = -123456789_IntKi Inst(old_id)%native_wrote_t = 0.0_DbKi Inst(old_id)%native_pending = .FALSE. - CALL open_native_outputs( id, root, t_start, ErrStat, ErrMsg ) + CALL open_native_outputs( id, root, t_start, ErrStat, ErrMsg, write_profile=.FALSE. ) RETURN END IF - tol = 1.0E-7_DbKi*MAX(1.0_DbKi, ABS(t_start), ABS(Inst(old_id)%native_wrote_t)) - delta = Inst(old_id)%native_wrote_t - t_start - IF (delta < -tol) THEN - CALL SetErrStat( ErrID_Fatal, 'The live native history ends before the restored committed state.', & - ErrStat, ErrMsg, RoutineName ) - RETURN + Inst(id)%native_un = Inst(old_id)%native_un + Inst(id)%native_root = Inst(old_id)%native_root + Inst(id)%native_wrote_t = Inst(old_id)%native_wrote_t + Inst(id)%native_wrote_n = Inst(old_id)%native_wrote_n + Inst(id)%native_pending = .FALSE. + + Inst(old_id)%native_un = -1_IntKi + Inst(old_id)%native_root = '' + Inst(old_id)%native_wrote_n = -123456789_IntKi + Inst(old_id)%native_wrote_t = 0.0_DbKi + Inst(old_id)%native_pending = .FALSE. + END SUBROUTINE restore_native_outputs + + SUBROUTINE resume_after_rewind( inst_e, other, ErrStat, ErrMsg ) + !! The run takes a step from a restored state that lies before its last commit: the rows + !! and the range samples past the restored commit now belong to an abandoned branch. + TYPE(CD_OF_Instance), INTENT(INOUT) :: inst_e + TYPE(CD_OtherStateType), INTENT(IN ) :: other + INTEGER(IntKi), INTENT( OUT) :: ErrStat + CHARACTER(*), INTENT( OUT) :: ErrMsg + + character(*), parameter :: RoutineName = 'CableDyn:resume_after_rewind' + INTEGER(IntKi) :: ntrim, k, ios + REAL(DbKi) :: delta, tol + + ErrStat = ErrID_None + ErrMsg = '' + inst_e%rewound = .FALSE. + inst_e%t_run_end = inst_e%t_commit + IF (inst_e%range_on) THEN + CALL restore_range_state( inst_e, other, ErrStat, ErrMsg ) + IF (ErrStat >= AbortErrLev) RETURN END IF - ntrim = MAX(0_IntKi, NINT(MAX(delta, 0.0_DbKi)/dt_moor, IntKi)) - IF (ABS(delta - REAL(ntrim, DbKi)*dt_moor) > tol) THEN + IF (inst_e%native_un <= 0_IntKi) RETURN + tol = 1.0E-7_DbKi*MAX(1.0_DbKi, ABS(inst_e%t_commit), ABS(inst_e%native_wrote_t)) + delta = MAX(inst_e%native_wrote_t - inst_e%t_commit, 0.0_DbKi) + ntrim = NINT(delta/REAL(inst_e%dt_moor, DbKi), IntKi) + IF (ABS(delta - REAL(ntrim, DbKi)*REAL(inst_e%dt_moor, DbKi)) > tol) THEN CALL SetErrStat( ErrID_Fatal, 'The native-history rewind is not an integer number of CableDyn steps.', & ErrStat, ErrMsg, RoutineName ) RETURN END IF DO k = 1, ntrim - BACKSPACE (Inst(old_id)%native_un, IOSTAT=ios) + BACKSPACE (inst_e%native_un, IOSTAT=ios) IF (ios /= 0) THEN CALL SetErrStat( ErrID_Fatal, 'Could not remove abandoned native-output record '// & TRIM(Num2LStr(k))//' of '//TRIM(Num2LStr(ntrim))//' while rewinding from t = '// & - TRIM(Num2LStr(Inst(old_id)%native_wrote_t))//' s to t = '// & - TRIM(Num2LStr(t_start))//' s.', & - ErrStat, ErrMsg, RoutineName ) + TRIM(Num2LStr(inst_e%native_wrote_t))//' s to t = '// & + TRIM(Num2LStr(inst_e%t_commit))//' s.', ErrStat, ErrMsg, RoutineName ) RETURN END IF END DO IF (ntrim > 0_IntKi) THEN - ENDFILE (Inst(old_id)%native_un, IOSTAT=ios) - IF (ios == 0) BACKSPACE (Inst(old_id)%native_un, IOSTAT=ios) + ENDFILE (inst_e%native_un, IOSTAT=ios) + IF (ios == 0) BACKSPACE (inst_e%native_un, IOSTAT=ios) IF (ios /= 0) THEN CALL SetErrStat( ErrID_Fatal, 'Could not truncate the abandoned native-output branch.', & ErrStat, ErrMsg, RoutineName ) RETURN END IF END IF - - Inst(id)%native_un = Inst(old_id)%native_un - Inst(id)%native_root = Inst(old_id)%native_root - Inst(id)%native_wrote_t = t_start - IF (Inst(old_id)%native_wrote_n >= 0_IntKi) THEN - Inst(id)%native_wrote_n = MAX(-1_IntKi, Inst(old_id)%native_wrote_n - ntrim*MAX(1_IntKi, nsuper)) - ELSE - Inst(id)%native_wrote_n = Inst(old_id)%native_wrote_n - END IF - Inst(id)%native_pending = .FALSE. - - Inst(old_id)%native_un = -1_IntKi - Inst(old_id)%native_root = '' - Inst(old_id)%native_wrote_n = -123456789_IntKi - Inst(old_id)%native_wrote_t = 0.0_DbKi - Inst(old_id)%native_pending = .FALSE. - END SUBROUTINE restore_native_outputs + inst_e%native_wrote_n = -123456789_IntKi + inst_e%native_wrote_t = inst_e%t_commit + END SUBROUTINE resume_after_rewind !---------------------------------------------------------------------------------------------------------------------------------- SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, ErrStat, ErrMsg) @@ -565,7 +603,8 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er CHARACTER(16) :: host_kin REAL(wp), ALLOCATABLE :: static_cur(:,:) ! the host's steady current for the static start CHARACTER(ChanLen) :: chan_hdr, chan_unt - CHARACTER(1024) :: native_root, init_note + CHARACTER(1024) :: native_root + CHARACTER(CD_INIT_NOTE_LEN) :: init_note INTEGER(IntKi) :: note_at, note_end ErrStat = ErrID_None @@ -770,6 +809,13 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er ! The host owns the environment: gravity, water density, and depth override ! whatever the deck declares (MoorDyn-faithful coupled behavior). + ! CableDyn-owned result files. Single-turbine names use p%RootName = + ! .CD; FAST.Farm preserves its established FarmCD root. + IF (nT > 0) THEN + native_root = TRIM(InitInp%RootName) + ELSE + native_root = TRIM(InitInp%RootName)//'.CD' + END IF CALL WrScr( ' Parsing CableDyn input file: '//TRIM(InitInp%FileName) ) IF (nT > 0) THEN ! an allocated-but-empty/too-small PtfmInit means "no initial displacement" @@ -816,7 +862,8 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er env_gravity=REAL(InitInp%g, wp), env_rho_water=REAL(InitInp%rhoW, wp), & env_wtrdpth=REAL(InitInp%WtrDepth, wp), ptfm_init=REAL(ptfm6, wp), & external_fluid=use_extf, run_tmax=REAL(InitInp%Tmax, wp), & - host_orientation=.TRUE., static_current=static_cur ) + host_orientation=.TRUE., static_current=static_cur, range_files=.TRUE., & + range_discard_root=TRIM(native_root) ) END IF IF (es /= CD_AGG_OK) THEN ! The slot stays inactive (it is activated only after full success, below), so this and @@ -921,13 +968,6 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er 'calculations; omit dtM or set it to DT for glue-rate solves.' ) END IF END IF - ! CableDyn-owned result files. Single-turbine names use p%RootName = - ! .CD; FAST.Farm preserves its established FarmCD root. - IF (nT > 0) THEN - native_root = TRIM(InitInp%RootName) - ELSE - native_root = TRIM(InitInp%RootName)//'.CD' - END IF ! The static profile records the converged static equilibrium, the state the ! initialization summary reports, so it is written before the SeaState kinematics ! are seeded below: they act from t = 0 and enter the t = 0 output row only. @@ -988,6 +1028,7 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er IF (ErrStat2 < AbortErrLev) CALL save_held_fluid( Inst(id), other ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat >= AbortErrLev) RETURN + CALL note_seastate_deep_interval( Inst(id), InitInp%WaveField ) END IF ! The coupling mesh carries ONLY the host-prescribed (Coupled/Vessel) points. @@ -1032,6 +1073,7 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er p%dtCoupling = REAL(DTcoupling, DbKi) p%PtfmInit = REAL(ptfm6, ReKi) ! the rebuild reapplies the displaced-start geometry p%Tmax = InitInp%Tmax + p%Linearize = InitInp%Linearize ! DTcoupling itself is deliberately left untouched (SubSteps = 1 in MV_AddModule). ! The module provides no visualization meshes: say so when the glue asks for them ! (WrVTK > 0) instead of leaving the mooring silently absent from the VTK output. @@ -1232,13 +1274,13 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er ! normal step, so a state violating the invariant cannot even advance, let alone ! be packed. The vector's real payload is the COMMITTED boundary kinematics: the ! vessel-end kinematics the last advance committed differ in the last bits from the - ! restored input mesh (the glue's input at the restart time, not the extrapolated one - ! the advance used), and on a point deck that round-off is amplified through the + ! glue's input at that time (the advance used an extrapolated one), and on a point + ! deck that round-off is amplified through the ! explicit-point window. A finite-EI cable carries its committed boundary in its own ! mirror block (CD_HFMF_BoundaryMirrorSize). IF (Inst(id)%agg%has_sys) THEN ! the committed boundary kinematics [q; v; a] of the EI=0 lines' coupled endpoints: - ! the rebuild moves them to the restored input mesh, then reloads them from here + ! the rebuild moves them to the committed input, then reloads them from here nstates = nstates + 3*CD_System_NSystemCoupledDOF( Inst(id)%agg%sys%fast%system ) END IF IF (CD_AGG_NDynamicPoints( Inst(id)%agg ) > 0 .AND. Inst(id)%agg%has_sys) THEN @@ -1272,9 +1314,21 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er g_serial = g_serial + 1_IntKi Inst(id)%serial = g_serial Inst(id)%t_commit = 0.0_DbKi ! committed at the static IC (t = 0) + Inst(id)%t_commit_prev = 0.0_DbKi + Inst(id)%t_run_end = 0.0_DbKi other%restart_serial = g_serial other%t_commit = Inst(id)%t_commit other%pred_t = -1.0_DbKi ! no previous commit yet (load predictor) + ! what the state at t = 0 stands on, for a checkpoint written before the first commit + CALL CD_AGG_GetMovingPointMesh( Inst(id)%agg, Inst(id)%sv_pos, Inst(id)%sv_vel, Inst(id)%sv_acc, & + Inst(id)%ws_fld, es, em, orientation=Inst(id)%sv_orient, & + angular_velocity=Inst(id)%sv_omega, angular_acceleration=Inst(id)%sv_alpha, & + cable_loads=.FALSE. ) + IF (es /= CD_AGG_OK) THEN + CALL SetErrStat( ErrID_Fatal, 'CableDyn mooring module: '//TRIM(em), ErrStat, ErrMsg, RoutineName ); RETURN + END IF + CALL save_commit_input( Inst(id), other, Inst(id)%sv_pos, Inst(id)%sv_vel, Inst(id)%sv_acc, & + Inst(id)%sv_orient, Inst(id)%sv_omega, Inst(id)%sv_alpha, u ) ! ------------------------------------------------------------------------------ ! Output channels: the deck OUTPUTS list in the OrcaFlex vocabulary, emitted through @@ -1330,6 +1384,16 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat >= AbortErrLev) RETURN + ! Range graphs (LINES flag r): the first sample is the state of the t = 0 output row. The + ! files of an earlier run went when the deck was parsed: this run writes its own only when + ! it completes. + Inst(id)%range_on = CD_AGG_Range_Graphs( Inst(id)%agg ) + IF (Inst(id)%range_on) THEN + CALL sample_range( Inst(id), other, 0.0_DbKi, ErrStat2, ErrMsg2 ) + CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) + IF (ErrStat >= AbortErrLev) RETURN + END IF + IF (profile_requested) THEN CALL SYSTEM_CLOCK(prof_c1) CALL reset_instance_profile( id, .TRUE., & @@ -1440,15 +1504,36 @@ LOGICAL FUNCTION instance_matches( m, other ) RESULT(ok) ok = .TRUE. END FUNCTION instance_matches + LOGICAL FUNCTION step_retry( m, other, n ) RESULT(retry) + !! TRUE when the glue runs the mooring step n again: the solver's correction loop copies + !! the states of the step's start back over the predicted ones before each pass, so the + !! OtherState then stands at the commit that step started from, while the instance has + !! advanced from it. The aggregate's step-start snapshot rewinds that exactly, at any + !! dtM/DT; the time of the retried call lies inside the mooring interval, not on a commit. + TYPE(CD_MiscVarType), INTENT(IN) :: m + TYPE(CD_OtherStateType), INTENT(IN) :: other + INTEGER(IntKi), INTENT(IN) :: n + retry = .FALSE. + IF (.NOT. ALLOCATED(Inst)) RETURN + IF (m%CDInst < 1 .OR. m%CDInst > SIZE(Inst)) RETURN + IF (.NOT. Inst(m%CDInst)%active) RETURN + IF (Inst(m%CDInst)%serial /= other%restart_serial) RETURN + IF (n /= Inst(m%CDInst)%last_n .OR. n /= Inst(m%CDInst)%last_solve_n) RETURN + retry = ABS(Inst(m%CDInst)%t_commit_prev - other%t_commit) <= 1.0E-9_DbKi*MAX(1.0_DbKi, ABS(other%t_commit)) + END FUNCTION step_retry + SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) !! CHECKPOINT-RESTART: rebuild the module's internal solver instance from the !! registry-packed data alone -- the deck copy in p, the state mirror in x, the !! coupled mesh in u -- exactly the data an `openfast -restart` run unpacks. The !! deck is re-parsed and the static build re-run (structure, meshes, hydro !! configuration), then the committed dynamic state is overlaid: coupled endpoint - !! kinematics from the input mesh, line interiors from the mirror with the - !! acceleration re-derived (the EI=0 mirror is MoorDyn-layout [rd; r]), and each - !! cable's [v; q; a] free-DOF mirror reloaded exactly with the restart clock. + !! kinematics and line-control command as the last commit was reached with them + !! (other%commit_input), line interiors from the mirror (the EI=0 mirror is + !! MoorDyn-layout [rd; r; rdd]), and each cable's [v; q; a] free-DOF mirror, all at the + !! clock of that commit. The restart time t can lie anywhere in the mooring step that + !! follows the commit: between commits nothing of the solver moves, and the input of the + !! coming solve is the glue's, so a run restarted there goes on as the uninterrupted one. REAL(DbKi), INTENT(IN ) :: t TYPE(CD_InputType), INTENT(IN ) :: u1 TYPE(CD_ParameterType), INTENT(IN ) :: p @@ -1461,7 +1546,7 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) character(*), parameter :: RoutineName = 'CableDyn:rebuild_from_checkpoint' CHARACTER(2100) :: rst_deck INTEGER(IntKi) :: id, old_id, stale_id, i, es, ncp, nlines, il, ndofl, nin, k, ic, csz, koff - LOGICAL :: use_extf, profile_requested, preserve_profile + LOGICAL :: use_extf, profile_requested, preserve_profile, same_run REAL(wp), ALLOCATABLE :: static_cur(:,:) ! the host's steady current for the static start LOGICAL :: restored ! held fluid fields restored from the checkpoint CHARACTER(ErrMsgLen) :: em @@ -1482,16 +1567,13 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) RETURN END IF - ! A checkpoint written mid-mooring-interval carries interiors from the LAST - ! committed solve (other%t_commit) while the glue would overlay the boundary at - ! the newer t -- a state no continuous run ever had. Fail closed: restarts are - ! valid only on committed interval boundaries (choose ChkptTime as a multiple - ! of the mooring step dtM). - IF (ABS(REAL(t, DbKi) - other%t_commit) > 1.0E-6_DbKi*MAX(1.0_DbKi, ABS(other%t_commit))) THEN - CALL SetErrStat( ErrID_Fatal, 'CableDyn mooring module: the checkpoint was written at t = '// & - TRIM(Num2LStr(t))//' s, between mooring commits (last commit at '// & - TRIM(Num2LStr(other%t_commit))//' s). Choose ChkptTime as a multiple of the '// & - 'mooring step dtM so the restored state is a committed interval boundary.', & + ! The state of a run at t is its last commit, at most one mooring step back: a restored + ! time and commit further apart are not a state of this run. Fail closed. + IF (t < other%t_commit - 1.0E-6_DbKi*MAX(1.0_DbKi, ABS(other%t_commit)) .OR. & + t > other%t_commit + p%dtM0 - p%dtCoupling + 1.0E-6_DbKi*MAX(1.0_DbKi, ABS(other%t_commit))) THEN + CALL SetErrStat( ErrID_Fatal, 'CableDyn mooring module: the restored state at t = '// & + TRIM(Num2LStr(t))//' s has its last commit at '//TRIM(Num2LStr(other%t_commit))// & + ' s, outside the mooring step dtM = '//TRIM(Num2LStr(p%dtM0))//' s before it.', & ErrStat, ErrMsg, RoutineName ) RETURN END IF @@ -1536,7 +1618,7 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) env_gravity=REAL(p%g, wp), env_rho_water=REAL(p%rhoW, wp), & env_wtrdpth=REAL(p%WtrDpth, wp), external_fluid=use_extf, & ptfm_init=REAL(p%PtfmInit, wp), run_tmax=REAL(p%Tmax, wp), & - host_orientation=.TRUE., static_current=static_cur ) + host_orientation=.TRUE., static_current=static_cur, range_files=.TRUE. ) CALL delete_file( rst_deck ) IF (es /= CD_AGG_OK) THEN CALL SetErrStat( ErrID_Fatal, 'CableDyn mooring module: checkpoint rebuild: '//TRIM(em), & @@ -1564,8 +1646,8 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) END IF ! Dynamic (Free/Connect) point states ride two trailing mirror blocks: the compact ! coupled-motion vector (the committed endpoint/boundary kinematics -- the vessel - ! ends' committed accelerations differ at round-off from the restored input mesh, - ! which a point deck amplifies) and the per-point store. A deck whose layout + ! ends' committed accelerations differ at round-off from the glue's input at that + ! time, which a point deck amplifies) and the per-point store. A deck whose layout ! changed since the checkpoint fails closed on the mirror-extent checks. Inst(id)%ncp = ncp @@ -1605,16 +1687,14 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) END IF END IF - ! overlay the committed dynamic state: coupled endpoints from the restored mesh... - CALL mesh_to_arrays( u1%CoupledKinematics(1), ncp, Inst(id)%ws_pos, Inst(id)%ws_vel, & - Inst(id)%ws_acc, Inst(id)%ws_orient, Inst(id)%ws_omega, Inst(id)%ws_alpha, & - ErrStat2, ErrMsg2 ) + ! overlay the committed dynamic state: coupled endpoints as the last commit took them... + CALL restore_commit_kinematics( Inst(id), other, ErrStat2, ErrMsg2 ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat >= AbortErrLev) RETURN ! a cable restrained in torsion first takes its committed state: moving its fairlead ! frame from the static pose to the restart pose is bounded (pi/2) against the twist ! it had at the checkpoint, not at t = 0 - CALL reload_cable_mirrors( Inst(id), m, x%states, REAL(t, wp), .TRUE., koff, ErrStat2, ErrMsg2 ) + CALL reload_cable_mirrors( Inst(id), m, x%states, REAL(other%t_commit, wp), .TRUE., koff, ErrStat2, ErrMsg2 ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat >= AbortErrLev) RETURN CALL CD_AGG_UpdateStates_Moving( Inst(id)%agg, Inst(id)%ws_pos, Inst(id)%ws_vel, Inst(id)%ws_acc, es, em, & @@ -1635,7 +1715,16 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) ! line interiors and cable mirrors reload below, which bring back the committed state ! of the uninterrupted run, a cable's committed force included. IF (ALLOCATED(u1%DeltaL)) THEN - CALL CD_AGG_Apply_LineControl( Inst(id)%agg, REAL(u1%DeltaL, wp), REAL(u1%DeltaLdot, wp), es, em, & + k = 24_IntKi*ncp + ic = SIZE(u1%DeltaL) + IF (SIZE(other%commit_input) /= k + 2_IntKi*ic) THEN + CALL SetErrStat( ErrID_Fatal, 'CableDyn mooring module: checkpoint rebuild: the committed '// & + 'line-control command does not match the control channels.', & + ErrStat, ErrMsg, RoutineName ) + RETURN + END IF + CALL CD_AGG_Apply_LineControl( Inst(id)%agg, REAL(other%commit_input(k + 1:k + ic), wp), & + REAL(other%commit_input(k + ic + 1:k + 2_IntKi*ic), wp), es, em, & restored=.TRUE. ) IF (es /= CD_AGG_OK) THEN CALL SetErrStat( ErrID_Fatal, 'CableDyn mooring module: line-control apply after the '// & @@ -1645,12 +1734,12 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) END IF ! ...line interiors + cable free-DOF mirrors, exactly (committed accelerations ! reload from the mirror -- shared helper with the state-const CalcOutput) - CALL reload_interiors_ary( Inst(id), m, x%states, REAL(t, wp), ErrStat2, ErrMsg2 ) + CALL reload_interiors_ary( Inst(id), m, x%states, REAL(other%t_commit, wp), ErrStat2, ErrMsg2 ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat >= AbortErrLev) RETURN ! restore the ambient-fluid fields the reference's committed state carried. The last - ! pre-checkpoint solve sampled the WaveField at ITS interval end (= the restart time) at + ! pre-checkpoint solve sampled the WaveField at ITS interval end (= the last commit) at ! the node positions of its START, and held them: the checkpoint carries them, and with ! supercycling they act on the loads served until the next solve. Init saves the first ! fields, so a checkpoint of an instance that takes host fluid always carries them; @@ -1662,7 +1751,53 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) Inst(id)%active = .TRUE. ! sample_wavefield reads through the live instance IF (use_extf) THEN CALL restore_held_fluid( Inst(id), other, restored, ErrStat2, ErrMsg2 ) - IF (ErrStat2 < AbortErrLev .AND. .NOT. restored) CALL sample_wavefield( Inst(id), p, m, t, ErrStat2, ErrMsg2 ) + IF (ErrStat2 < AbortErrLev .AND. .NOT. restored) & + CALL sample_wavefield( Inst(id), p, m, other%t_commit, ErrStat2, ErrMsg2 ) + CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) + IF (ErrStat >= AbortErrLev) THEN + CALL release_instance( id ) + RETURN + END IF + END IF + ! A restart inside the first mooring step: no commit yet, so the bodies and rods are still + ! at their start, and the fluid fields of the first step re-solve their accelerations. At + ! dtM = DT there is no such state: the CalcOutput at t = 0 restores the mirror, which + ! closes the start. + IF (Inst(id)%nsuper > 1_IntKi .AND. other%t_commit <= 0.0_DbKi) CALL CD_AGG_Reopen_Start( Inst(id)%agg ) + ! The same run restoring one of its own states (CalcSteady's operating points) keeps what + ! it has committed, the range samples too, until it steps on from there + ! (resume_after_rewind). A checkpoint in a new process continues from the restored + ! OtherState, which holds the range graphs through other%t_commit. + same_run = .FALSE. + IF (old_id >= 1_IntKi .AND. old_id <= SIZE(Inst) .AND. old_id /= id) & + same_run = Inst(old_id)%active .AND. Inst(old_id)%serial == other%restart_serial + Inst(id)%t_run_end = other%t_commit + IF (same_run) THEN + Inst(id)%t_run_end = MAX(Inst(old_id)%t_run_end, other%t_commit) + Inst(id)%rewound = Inst(id)%t_run_end > other%t_commit + 1.0E-9_DbKi*MAX(1.0_DbKi, ABS(other%t_commit)) + END IF + Inst(id)%range_on = CD_AGG_Range_Graphs( Inst(id)%agg ) + IF (Inst(id)%range_on) THEN + IF (same_run) THEN + Inst(id)%range_sampled = Inst(old_id)%range_sampled + ALLOCATE (Inst(id)%ws_range(CD_AGG_Range_MirrorSize( Inst(id)%agg ))) + CALL CD_AGG_Range_Get_State( Inst(old_id)%agg, Inst(id)%ws_range, es, em ) + IF (es == CD_AGG_OK) CALL CD_AGG_Range_Set_State( Inst(id)%agg, Inst(id)%ws_range, es, em ) + IF (es /= CD_AGG_OK) CALL SetErrStat( ErrID_Fatal, 'CableDyn mooring module: range graphs of the '// & + 'restored run: '//TRIM(em), ErrStat, ErrMsg, RoutineName ) + ELSE + CALL restore_range_state( Inst(id), other, ErrStat2, ErrMsg2 ) + CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) + END IF + IF (ErrStat >= AbortErrLev) THEN + CALL release_instance( id ) + RETURN + END IF + END IF + ! A linearizing run keeps its state map: CalcSteady rewinds to the operating point through + ! this rebuild, and a restart may linearize later. + IF (p%Linearize) THEN + CALL lin_state_map( Inst(id), m, ErrStat2, ErrMsg2 ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat >= AbortErrLev) THEN CALL release_instance( id ) @@ -1686,12 +1821,11 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) Inst(stale_id)%native_un = -1_IntKi Inst(stale_id)%native_root = '' END IF - ! A same-process replacement adopts the run-level stream from its predecessor. - ! Any rows beyond the restored commit are removed before the open unit changes - ! owner. A fresh-process restart has no live unit and starts a new result file at - ! the checkpoint state, matching the normal OpenFAST restart-output policy. + ! A same-process replacement adopts the run-level stream from its predecessor, rows and + ! all. A fresh-process restart has no live unit and starts a new result file at the + ! checkpoint state, matching the normal OpenFAST restart-output policy. CALL restore_native_outputs( id, old_id, TRIM(p%RootName), other%t_commit, other%restart_serial, & - p%dtM0, Inst(id)%nsuper, ErrStat2, ErrMsg2 ) + ErrStat2, ErrMsg2 ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat >= AbortErrLev) THEN CALL release_instance( id ) @@ -1704,9 +1838,11 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) ! adopt the restored identity (the OtherState is authoritative across the restart) Inst(id)%serial = other%restart_serial Inst(id)%t_commit = other%t_commit + Inst(id)%t_commit_prev = other%t_commit Inst(id)%last_n = -1_IntKi Inst(id)%last_solve_n = -1_IntKi Inst(id)%loads_cached = .FALSE. + Inst(id)%restored_unserved = Inst(id)%nsuper > 1_IntKi ! the load predictor's previous commit (shift_predictor_loads) Inst(id)%pred_t = other%pred_t IF (Inst(id)%pred_t >= 0.0_DbKi) THEN @@ -1743,7 +1879,7 @@ SUBROUTINE CD_UpdateStates( t, n, u, t_array, p, x, xd, z, other, m, ErrStat, Er CHARACTER(*) , INTENT( OUT) :: ErrMsg character(*), parameter :: RoutineName = 'CD_UpdateStates' - INTEGER(IntKi) :: ErrStat2, es, id, niter, iu_t, i_u + INTEGER(IntKi) :: ErrStat2, es, id, niter INTEGER(IntKi) :: prof_c0, prof_c1, prof_rate, prof_phase0 CHARACTER(ErrMsgLen) :: ErrMsg2 CHARACTER(ErrMsgLen) :: em @@ -1752,36 +1888,32 @@ SUBROUTINE CD_UpdateStates( t, n, u, t_array, p, x, xd, z, other, m, ErrStat, Er ErrStat = ErrID_None ErrMsg = '' ! CHECKPOINT-RESTART: a restored OtherState that does not match the live instance - ! (fresh process, or a same-process rewind) rebuilds the solver from p + x + u. - ! The overlay must use the input AT the restart time t -- u(1) here is the glue's - ! PREDICTED input for the upcoming interval (t_array(1) = t + DT), one glue step - ! AHEAD of the committed boundary; overlaying it would start the restored run from - ! a boundary it never had. Pick the u(:) entry whose time is t. - IF (.NOT. instance_matches(m, other)) THEN - iu_t = 1_IntKi - DO i_u = 1, SIZE(t_array) - IF (ABS(t_array(i_u) - t) <= 1.0E-6_DbKi*MAX(1.0_DbKi, ABS(t))) THEN - iu_t = i_u - EXIT - END IF - END DO - CALL rebuild_from_checkpoint( t, u(iu_t), p, x, other, m, ErrStat2, ErrMsg2 ) + ! (fresh process, or a same-process rewind) rebuilds the solver from p + x + other. + IF (.NOT. (step_retry(m, other, n) .OR. instance_matches(m, other))) THEN + CALL rebuild_from_checkpoint( t, u(1), p, x, other, m, ErrStat2, ErrMsg2 ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat >= AbortErrLev) RETURN END IF id = m%CDInst + IF (Inst(id)%rewound) THEN + CALL resume_after_rewind( Inst(id), other, ErrStat2, ErrMsg2 ) + CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) + IF (ErrStat >= AbortErrLev) RETURN + END IF IF (Inst(id)%prof_enabled) THEN CALL SYSTEM_CLOCK(prof_c0, prof_rate) Inst(id)%prof_n_update = Inst(id)%prof_n_update + 1_IntKi END IF - ! Correction iterations (NumCrctn > 0): the glue re-runs UpdateStates over the SAME - ! interval with corrected inputs. A re-entry over a glue step where nothing was - ! advanced (an intermediate supercycle call) is a no-op; a re-entry over a COMPLETED + ! Correction iterations: the glue re-runs UpdateStates over the SAME interval with + ! corrected inputs when its solver retries a step. A re-entry over a glue step where nothing + ! was advanced (an intermediate supercycle call) is a no-op; a re-entry over a COMPLETED ! mooring step rewinds the aggregate to the snapshot taken at that step's start and ! falls through to re-advance with the corrected inputs -- exactly the ! copy-states-back semantics a registry-state module gets from the glue, expressed - ! on the module's internal state. + ! on the module's internal state. The glue's copy has taken the OtherState back with the + ! states, so what the first pass mirrored there and does not repeat is mirrored again: + ! the load predictor, shifted once per mooring step. IF (n == Inst(id)%last_n) THEN IF (Inst(id)%last_solve_n /= n) THEN IF (Inst(id)%prof_enabled) THEN @@ -1797,6 +1929,9 @@ SUBROUTINE CD_UpdateStates( t, n, u, t_array, p, x, xd, z, other, m, ErrStat, Er TRIM(em), ErrStat, ErrMsg, RoutineName ) RETURN END IF + Inst(id)%t_commit = Inst(id)%t_commit_prev + other%t_commit = Inst(id)%t_commit + CALL mirror_predictor_loads( Inst(id), other ) END IF ! Supercycling (CableDyn's own dtM): the mooring interval [.., (n+1)*DT] completes only @@ -1837,6 +1972,11 @@ SUBROUTINE CD_UpdateStates( t, n, u, t_array, p, x, xd, z, other, m, ErrStat, Er END IF CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat < AbortErrLev) THEN + IF (Inst(id)%restored_unserved) THEN + CALL serve_committed_loads( Inst(id), ErrStat2, ErrMsg2 ) + CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) + IF (ErrStat >= AbortErrLev) RETURN + END IF ! Load predictor: a new mooring step (not a correction re-entry over the same n) ! makes the loads served since the last commit the previous ones. IF (Inst(id)%nsuper > 1_IntKi .AND. Inst(id)%agg%predict_loads .AND. & @@ -1914,10 +2054,22 @@ SUBROUTINE CD_UpdateStates( t, n, u, t_array, p, x, xd, z, other, m, ErrStat, Er ! the interval end is t + dtCoupling (t + dt_moor from here overshoots by ! dt_moor - DT under supercycling -- the same arithmetic as the WaveField ! sampling time). + Inst(id)%t_commit_prev = Inst(id)%t_commit Inst(id)%t_commit = t + p%dtCoupling + Inst(id)%t_run_end = Inst(id)%t_commit other%t_commit = Inst(id)%t_commit + CALL save_commit_input( Inst(id), other, Inst(id)%ws_pos, Inst(id)%ws_vel, Inst(id)%ws_acc, & + Inst(id)%ws_orient, Inst(id)%ws_omega, Inst(id)%ws_alpha, Inst(id)%u_interp ) CALL pack_state_mirror( Inst(id), m, x, ErrStat2, ErrMsg2 ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) + ! one range-graph sample per commit, here where the OtherState can carry it: a + ! correction re-advance takes the sample back with the step (CD_AGG_Restore, or + ! the glue's state copy and the rebuild), an intermediate supercycle call never + ! gets here, and CalcOutput's probes of off-step inputs are not commits + IF (Inst(id)%range_on .AND. ErrStat < AbortErrLev) THEN + CALL sample_range( Inst(id), other, Inst(id)%t_commit, ErrStat2, ErrMsg2 ) + CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) + END IF END IF END IF @@ -2293,6 +2445,45 @@ SUBROUTINE check_wavefield_grid( inst_e, wf, t_sample, at_init, wl_sampled, ErrS IF (at_init) CALL WrScr( ' WARNING: '//msg ) END SUBROUTINE check_wavefield_grid + SUBROUTINE note_seastate_deep_interval( inst_e, wf ) + !! Say at initialisation, once per run (a restart rebuilds without passing here), when the + !! deepest interval of the SeaState depth grid is coarse and a line lies in it. The levels + !! are z_k = Z_Depth*(sin(k*delta) - 1) with delta = pi/(2*(NZ - 1)): the grid is finest at + !! the surface and coarsest at the bottom, where the interval H = Z_Depth*sin(delta) is + !! pi/2 times the uniform spacing. Inside H the field is interpolated linearly down to the + !! seabed. For the 1/7-power current of CurrMod 1 that is up to 62 % of the value at the top + !! of the interval low, whatever H is, so H alone sets how much of the column carries the + !! difference: the depth integral of the current lacks 3/7*(H/D)^(8/7) of its value, 1.4 % + !! at the threshold H = 5 % of the depth (NZ >= 33 stays below it) and 3.5 % at NZ = 15. A + !! grid short of the seabed has the out-of-grid warning of check_wavefield_grid instead. + !! Reads the grid and the node positions and writes to the screen only. + TYPE(CD_OF_Instance), INTENT(IN) :: inst_e + TYPE(SeaSt_WaveFieldType), INTENT(IN) :: wf + REAL(wp), PARAMETER :: HALF_PI = 2.0_wp*ATAN(1.0_wp) + LOGICAL :: waves, grid_current + INTEGER(IntKi) :: nz, nodes + REAL(wp) :: depth, interval + + waves = inst_e%agg%host_wave_enabled .AND. wf%WaveMod /= WaveMod_None + grid_current = inst_e%agg%host_current_enabled .AND. wf%Current_InitInput%CurrMod /= 0_IntKi + nz = wf%VolGridParams%n(4) + IF (.NOT. (waves .OR. grid_current) .OR. nz < 2_IntKi) RETURN + depth = REAL(wf%GridDepth, wp) + interval = depth*SIN(REAL(wf%VolGridParams%delta(4), wp)) + IF (depth < REAL(wf%EffWtrDpth, wp) - 0.01_wp*interval) RETURN + IF (interval <= SEASTATE_DEEP_INTERVAL*depth) RETURN + nodes = COUNT(inst_e%fx_xyz(3, 1:inst_e%nfluid) < interval - depth) + IF (nodes == 0_IntKi) RETURN + CALL WrScr( ' Note: the deepest interval of the SeaState depth grid is '//TRIM(Num2LStr(interval))// & + ' m (NZ = '//TRIM(Num2LStr(nz))//', Z_Depth = '//TRIM(Num2LStr(depth))//' m), over '// & + TRIM(Num2LStr(NINT(100.0_wp*SEASTATE_DEEP_INTERVAL)))//' % of the depth, and '// & + TRIM(Num2LStr(nodes))//' of the '//TRIM(Num2LStr(inst_e%nfluid))// & + ' CableDyn sampling nodes lie in it. The field is linear there, so near the seabed '// & + 'it differs from a deck depth table. More depth levels refine it: NZ = '// & + TRIM(Num2LStr(1_IntKi + CEILING(HALF_PI/ASIN(SEASTATE_DEEP_INTERVAL), IntKi)))// & + ' or more in the SeaState input file.' ) + END SUBROUTINE note_seastate_deep_interval + SUBROUTINE check_current_field_grid( inst_e, wf, t_sample ) !! A dynamically populated current field (an InflowWind-driven MHK current, hasCurrField) is !! not read on the SeaState grid: InflowWind reads it on its own field at the node position @@ -2530,6 +2721,7 @@ SUBROUTINE CD_CalcOutput( t, u, p, x, xd, z, other, y, m, ErrStat, ErrMsg ) END IF Inst(id)%loads_cached = .TRUE. Inst(id)%loads_at_n = Inst(id)%last_solve_n + Inst(id)%restored_unserved = .FALSE. END IF ! restore the committed coupled kinematics saved above (state-const CalcOutput), @@ -2688,6 +2880,61 @@ SUBROUTINE restore_held_fluid( inst_e, other, restored, ErrStat, ErrMsg ) restored = .TRUE. END SUBROUTINE restore_held_fluid + SUBROUTINE sample_range( inst_e, other, t_sample, ErrStat, ErrMsg ) + !! Add the committed state at t_sample to the range graphs and mirror them into the + !! OtherState, so a checkpoint carries them. The samples are the aggregate's own double + !! precision node values, whatever the precision of the host's outputs (ReKi). + TYPE(CD_OF_Instance), INTENT(INOUT) :: inst_e + TYPE(CD_OtherStateType), INTENT(INOUT) :: other + REAL(DbKi), INTENT(IN ) :: t_sample + INTEGER(IntKi), INTENT( OUT) :: ErrStat + CHARACTER(*), INTENT( OUT) :: ErrMsg + INTEGER(IntKi) :: es, nr + CHARACTER(ErrMsgLen) :: em + ErrStat = ErrID_None + ErrMsg = '' + CALL CD_AGG_Range_Sample( inst_e%agg, REAL(t_sample, wp), es, em ) + nr = CD_AGG_Range_MirrorSize( inst_e%agg ) + IF (.NOT. ALLOCATED(inst_e%ws_range)) ALLOCATE (inst_e%ws_range(nr)) + IF (es == CD_AGG_OK) CALL CD_AGG_Range_Get_State( inst_e%agg, inst_e%ws_range, es, em ) + IF (es /= CD_AGG_OK) THEN + ErrStat = ErrID_Fatal + ErrMsg = 'CableDyn mooring module: range-graph sample at t = '//TRIM(Num2LStr(t_sample))//' s: '//TRIM(em) + RETURN + END IF + IF (ALLOCATED(other%range_state)) THEN + IF (SIZE(other%range_state) /= nr) DEALLOCATE (other%range_state) + END IF + IF (.NOT. ALLOCATED(other%range_state)) ALLOCATE (other%range_state(nr)) + other%range_state = REAL(inst_e%ws_range, DbKi) + inst_e%range_sampled = .TRUE. + END SUBROUTINE sample_range + + SUBROUTINE restore_range_state( inst_e, other, ErrStat, ErrMsg ) + !! Hand the range graphs of a checkpoint (sample_range) back to the rebuilt aggregate. A + !! checkpoint without them for these lines fails by name: continuing would write a range + !! graph that silently misses the run before the restart. + TYPE(CD_OF_Instance), INTENT(INOUT) :: inst_e + TYPE(CD_OtherStateType), INTENT(IN ) :: other + INTEGER(IntKi), INTENT( OUT) :: ErrStat + CHARACTER(*), INTENT( OUT) :: ErrMsg + INTEGER(IntKi) :: es + CHARACTER(ErrMsgLen) :: em + ErrStat = ErrID_None + ErrMsg = '' + es = CD_AGG_OK + IF (ALLOCATED(other%range_state)) THEN + CALL CD_AGG_Range_Set_State( inst_e%agg, REAL(other%range_state, wp), es, em ) + ELSE + es = ErrID_Fatal + em = 'the checkpoint carries no range graphs' + END IF + IF (es /= CD_AGG_OK) THEN + ErrStat = ErrID_Fatal + ErrMsg = 'CableDyn mooring module: checkpoint rebuild of the range graphs (LINES flag r): '//TRIM(em) + END IF + END SUBROUTINE restore_range_state + SUBROUTINE shift_predictor_loads( inst_e, other ) !! The load predictor's previous commit when a new mooring step starts: the loads served !! since the last commit, when CalcOutput evaluated them there (its committed loads), and @@ -2695,24 +2942,119 @@ SUBROUTINE shift_predictor_loads( inst_e, other ) !! continues the extrapolation from the same two commits. TYPE(CD_OF_Instance), INTENT(INOUT) :: inst_e TYPE(CD_OtherStateType), INTENT(INOUT) :: other - INTEGER(IntKi) :: nl IF (inst_e%loads_cached .AND. inst_e%loads_at_n == inst_e%last_solve_n) THEN inst_e%pred_fld = inst_e%ws_fld inst_e%pred_mom = inst_e%ws_moment inst_e%pred_t = inst_e%t_commit - nl = 3_IntKi*inst_e%ncp - IF (ALLOCATED(other%pred_loads)) THEN - IF (SIZE(other%pred_loads) /= 2*nl) DEALLOCATE (other%pred_loads) - END IF - IF (.NOT. ALLOCATED(other%pred_loads)) ALLOCATE (other%pred_loads(2*nl)) - other%pred_loads(1:nl) = RESHAPE(REAL(inst_e%ws_fld, DbKi), [nl]) - other%pred_loads(nl + 1:2*nl) = RESHAPE(REAL(inst_e%ws_moment, DbKi), [nl]) ELSE inst_e%pred_t = -1.0_DbKi END IF - other%pred_t = inst_e%pred_t + CALL mirror_predictor_loads( inst_e, other ) END SUBROUTINE shift_predictor_loads + SUBROUTINE mirror_predictor_loads( inst_e, other ) + !! The load predictor's previous commit, as the instance holds it, into the OtherState. + TYPE(CD_OF_Instance), INTENT(IN ) :: inst_e + TYPE(CD_OtherStateType), INTENT(INOUT) :: other + INTEGER(IntKi) :: nl + other%pred_t = inst_e%pred_t + IF (inst_e%pred_t < 0.0_DbKi) RETURN + nl = 3_IntKi*inst_e%ncp + IF (ALLOCATED(other%pred_loads)) THEN + IF (SIZE(other%pred_loads) /= 2*nl) DEALLOCATE (other%pred_loads) + END IF + IF (.NOT. ALLOCATED(other%pred_loads)) ALLOCATE (other%pred_loads(2*nl)) + other%pred_loads(1:nl) = RESHAPE(REAL(inst_e%pred_fld, DbKi), [nl]) + other%pred_loads(nl + 1:2*nl) = RESHAPE(REAL(inst_e%pred_mom, DbKi), [nl]) + END SUBROUTINE mirror_predictor_loads + + SUBROUTINE save_commit_input( inst_e, other, pos, vel, acc, orient, omega, alpha, u_ctrl ) + !! What the state now committed was reached with, into the OtherState: the coupled + !! kinematics [position; velocity; acceleration; orientation; angular velocity; angular + !! acceleration] and the line-control command [DeltaL; DeltaLdot] of u_ctrl. The glue's + !! input moves on between commits, so a restart inside the mooring step that follows + !! rebuilds from this, not from the input at its own time. + TYPE(CD_OF_Instance), INTENT(IN ) :: inst_e + TYPE(CD_OtherStateType), INTENT(INOUT) :: other + REAL(wp), INTENT(IN ) :: pos(:,:), vel(:,:), acc(:,:), orient(:,:,:), omega(:,:), alpha(:,:) + TYPE(CD_InputType), INTENT(IN ) :: u_ctrl + INTEGER(IntKi) :: n3, nc, k + n3 = 3_IntKi*inst_e%ncp + nc = 0_IntKi + IF (ALLOCATED(u_ctrl%DeltaL)) nc = SIZE(u_ctrl%DeltaL) + IF (ALLOCATED(other%commit_input)) THEN + IF (SIZE(other%commit_input) /= 8_IntKi*n3 + 2_IntKi*nc) DEALLOCATE (other%commit_input) + END IF + IF (.NOT. ALLOCATED(other%commit_input)) ALLOCATE (other%commit_input(8_IntKi*n3 + 2_IntKi*nc)) + other%commit_input(1:n3) = RESHAPE(REAL(pos, DbKi), [n3]) + other%commit_input(n3 + 1:2*n3) = RESHAPE(REAL(vel, DbKi), [n3]) + other%commit_input(2*n3 + 1:3*n3) = RESHAPE(REAL(acc, DbKi), [n3]) + other%commit_input(3*n3 + 1:6*n3) = RESHAPE(REAL(orient, DbKi), [3*n3]) + other%commit_input(6*n3 + 1:7*n3) = RESHAPE(REAL(omega, DbKi), [n3]) + other%commit_input(7*n3 + 1:8*n3) = RESHAPE(REAL(alpha, DbKi), [n3]) + IF (nc > 0_IntKi) THEN + k = 8_IntKi*n3 + other%commit_input(k + 1:k + nc) = REAL(u_ctrl%DeltaL, DbKi) + other%commit_input(k + nc + 1:k + 2_IntKi*nc) = REAL(u_ctrl%DeltaLdot, DbKi) + END IF + END SUBROUTINE save_commit_input + + SUBROUTINE restore_commit_kinematics( inst_e, other, ErrStat, ErrMsg ) + !! The coupled kinematics of the last commit (save_commit_input) into the workspace. + TYPE(CD_OF_Instance), INTENT(INOUT) :: inst_e + TYPE(CD_OtherStateType), INTENT(IN ) :: other + INTEGER(IntKi), INTENT( OUT) :: ErrStat + CHARACTER(*), INTENT( OUT) :: ErrMsg + INTEGER(IntKi) :: ncp, n3 + ErrStat = ErrID_None + ErrMsg = '' + ncp = inst_e%ncp + n3 = 3_IntKi*ncp + IF (.NOT. ALLOCATED(other%commit_input)) THEN + ErrStat = ErrID_Fatal + ErrMsg = 'CableDyn mooring module: checkpoint rebuild: the checkpoint carries no committed input.' + RETURN + END IF + IF (SIZE(other%commit_input) < 8_IntKi*n3) THEN + ErrStat = ErrID_Fatal + ErrMsg = 'CableDyn mooring module: checkpoint rebuild: the committed input does not match the coupled points.' + RETURN + END IF + inst_e%ws_pos = RESHAPE(REAL(other%commit_input(1:n3), wp), [3, ncp]) + inst_e%ws_vel = RESHAPE(REAL(other%commit_input(n3 + 1:2*n3), wp), [3, ncp]) + inst_e%ws_acc = RESHAPE(REAL(other%commit_input(2*n3 + 1:3*n3), wp), [3, ncp]) + inst_e%ws_orient = RESHAPE(REAL(other%commit_input(3*n3 + 1:6*n3), wp), [3, 3, ncp]) + inst_e%ws_omega = RESHAPE(REAL(other%commit_input(6*n3 + 1:7*n3), wp), [3, ncp]) + inst_e%ws_alpha = RESHAPE(REAL(other%commit_input(7*n3 + 1:8*n3), wp), [3, ncp]) + END SUBROUTINE restore_commit_kinematics + + SUBROUTINE serve_committed_loads( inst_e, ErrStat, ErrMsg ) + !! A rebuild on the last glue step of a mooring interval reaches the solve with no + !! CalcOutput since the commit, though the uninterrupted run evaluated the committed loads + !! there and the load predictor shifts them. They are evaluated here, as CalcOutput does; + !! the channels are not needed, as the advance that follows invalidates the cache. + TYPE(CD_OF_Instance), INTENT(INOUT) :: inst_e + INTEGER(IntKi), INTENT( OUT) :: ErrStat + CHARACTER(*), INTENT( OUT) :: ErrMsg + INTEGER(IntKi) :: es + CHARACTER(ErrMsgLen) :: em + ErrStat = ErrID_None + ErrMsg = '' + CALL CD_AGG_CalcOutput( inst_e%agg, es, em ) + IF (es == CD_AGG_OK) & + CALL CD_AGG_GetMovingPointMesh( inst_e%agg, inst_e%sv_pos, inst_e%sv_vel, inst_e%sv_acc, inst_e%ws_fld, & + es, em, orientation=inst_e%sv_orient, moment=inst_e%ws_moment, & + angular_velocity=inst_e%sv_omega, angular_acceleration=inst_e%sv_alpha ) + IF (es /= CD_AGG_OK) THEN + ErrStat = ErrID_Fatal + ErrMsg = 'CableDyn mooring module: committed loads of the restored state: '//TRIM(em) + RETURN + END IF + inst_e%loads_cached = .TRUE. + inst_e%loads_at_n = inst_e%last_solve_n + inst_e%restored_unserved = .FALSE. + END SUBROUTINE serve_committed_loads + SUBROUTINE restore_calcoutput_probe( inst_e, m, ErrStat, ErrMsg ) !! Restore every state surface after the output-probe !! overlay. This is used on both success and every post-overlay error exit. @@ -3169,6 +3511,8 @@ SUBROUTINE CD_End(u, p, x, xd, z, other, y, m, ErrStat , ErrMsg) CHARACTER(:), ALLOCATABLE :: asm_txt INTEGER :: k_i INTEGER :: sb_max, sb_ios + INTEGER(IntKi) :: range_es + CHARACTER(ErrMsgLen) :: range_em CHARACTER(24) :: sb_txt(5) REAL(wp) :: ht_step, ht_resid, ht_eff, ht_solve, ht_am, ht_tan CHARACTER(ErrMsgLen) :: ErrMsg2 @@ -3179,6 +3523,21 @@ SUBROUTINE CD_End(u, p, x, xd, z, other, y, m, ErrStat , ErrMsg) IF (ALLOCATED(Inst)) THEN IF (m%CDInst >= 1 .AND. m%CDInst <= SIZE(Inst)) THEN IF (Inst(m%CDInst)%active) THEN + ! The range graphs cover the whole run, so a run that ended on an error writes none + ! (as the standalone driver, which writes them on completion). The glue comes here + ! after an abort too, so the host says which it was (m%HostComplete): a run can also + ! complete before TMax, on CalcSteady or at FAST.Farm's last whole DT_Low. A process + ! that only restored a checkpoint and stepped nothing (-VTKLin) leaves the file as it is. + IF (Inst(m%CDInst)%range_on) THEN + IF (.NOT. m%HostComplete) THEN + CALL WrScr( ' CableDyn: the run stopped on an error at t = '// & + TRIM(Num2LStr(Inst(m%CDInst)%t_commit))//' s: no range graph is written.' ) + ELSE IF (Inst(m%CDInst)%range_sampled) THEN + CALL CD_AGG_Range_Write( Inst(m%CDInst)%agg, TRIM(p%RootName), range_es, range_em ) + IF (range_es /= CD_AGG_OK) CALL SetErrStat( ErrID_Fatal, 'CableDyn mooring module: '// & + TRIM(range_em), ErrStat, ErrMsg, RoutineName ) + END IF + END IF ! the rotation- and snap-resolved sub-steps of the EI=0 lines (ei0_substeps), as the ! standalone driver reports them CALL CD_AGG_Get_Substep_Counts( Inst(m%CDInst)%agg, sb_calls, sb_events, sb_max, sb_capped, & @@ -4500,8 +4859,7 @@ SUBROUTINE reload_interiors_ary(inst_, m, states, t_clock, ErrStat, ErrMsg) ! guarantees the vector's values regardless). npd = 9*CD_AGG_NDynamicPoints( inst_%agg ) IF (npd == 0 .AND. inst_%agg%has_sys) THEN - ! no dynamic points: restore the committed boundary kinematics (the restored input - ! mesh carries the glue's input at the restart time, not the committed kinematics) + ! no dynamic points: restore the committed boundary kinematics ncd = CD_System_NSystemCoupledDOF( inst_%agg%sys%fast%system ) IF (koff + 3*ncd > SIZE(states)) THEN CALL SetErrStat( ErrID_Fatal, 'CableDyn mooring module: the coupled-motion mirror extends '// & diff --git a/src/openfast/CableDyn_OF_Driver.f90 b/src/openfast/CableDyn_OF_Driver.f90 index 97ee9a91..4c3b2abe 100644 --- a/src/openfast/CableDyn_OF_Driver.f90 +++ b/src/openfast/CableDyn_OF_Driver.f90 @@ -172,6 +172,7 @@ PROGRAM CableDyn_OF_Driver END DO CLOSE (un_out) + m%HostComplete = .TRUE. CALL CD_End(u(1), p, x, xd, z, other, y, m, es, em) IF (es >= AbortErrLev) CALL fail_out('CD_End: '//TRIM(em)) CALL WrScr('CableDyn_OF_Driver: completed '//TRIM(Num2LStr(nglue))//' steps.') @@ -348,7 +349,7 @@ SUBROUTINE set_mesh_pose(uu, r6, v6, a6) uu%CoupledKinematics(1)%TranslationDisp(:, ip) = REAL(rr, ReKi) uu%CoupledKinematics(1)%TranslationVel(:, ip) = REAL(vv, ReKi) uu%CoupledKinematics(1)%TranslationAcc(:, ip) = REAL(aa, ReKi) - uu%CoupledKinematics(1)%Orientation(:, :, ip) = REAL(dcm, ReKi) + uu%CoupledKinematics(1)%Orientation(:, :, ip) = dcm uu%CoupledKinematics(1)%RotationVel(:, ip) = REAL(omg, ReKi) uu%CoupledKinematics(1)%RotationAcc(:, ip) = REAL(alp, ReKi) END DO diff --git a/src/openfast/CableDyn_Registry.txt b/src/openfast/CableDyn_Registry.txt index a4c32cf2..41e8a191 100644 --- a/src/openfast/CableDyn_Registry.txt +++ b/src/openfast/CableDyn_Registry.txt @@ -362,6 +362,8 @@ typedef ^ ^ DbKi t_commit - typedef ^ ^ DbKi pred_t - -1 - "load predictor: time of the committed solve before the last (negative: none), from which the coupled loads between supercycled solves are extrapolated" - typedef ^ ^ DbKi pred_loads {:} - - "load predictor: the coupled forces (3 x ncp) and then moments (3 x ncp) of that solve" - typedef ^ ^ DbKi fluid_held {:} - - "the held ambient-fluid fields of the last committed mooring step (velocity, acceleration, free-surface elevation, dynamic pressure at the line nodes), restored at a restart instead of re-sampled" - +typedef ^ ^ DbKi range_state {:} - - "the accumulated range graphs (LINES flag r) through the last committed mooring step, in double precision, restored at a restart" - +typedef ^ ^ DbKi commit_input {:} - - "what the last commit was reached with: the coupled kinematics (position, velocity, acceleration, orientation, angular velocity, angular acceleration; 24 x ncp) then the line-control command (DeltaL, DeltaLdot); a rebuild stands on it, at any time of the mooring step after the commit" - ## ============================== Parameters ============================================================================================================================================ typedef ^ ParameterType IntKi nLineTypes - 0 - "number of line types" "" @@ -384,6 +386,7 @@ typedef ^ ^ IntKi nCpldPoints {:} typedef ^ ^ IntKi NConns - 0 - "number of Connect type Points - not to be confused with NPoints" "" typedef ^ ^ IntKi NAnchs - 0 - "number of Anchor type Points" "" typedef ^ ^ DbKi Tmax - - - "simulation duration" "[s]" +typedef ^ ^ LOGICAL Linearize - .FALSE. - "the run linearizes: a rebuilt instance needs the linearization state map" - typedef ^ ^ IntKi tScheme - 0 - "Time integration scheme (0 = RK2, 1 = RK4). Default is RK2" - typedef ^ ^ DbKi g - 9.81 - "gravitational constant (positive)" "[m/s^2]" typedef ^ ^ DbKi rhoW - 1025 - "density of seawater" "[kg/m^3]" @@ -513,3 +516,4 @@ typedef ^ ^ IntKi BathGrid_npoints {:} typedef ^ ^ GridInterp_MiscVarType WaveField_m - - - "misc var information from the SeaState Interpolation module" - typedef ^ ^ LOGICAL IC_gen - .FALSE. - "boolean to indicate dynamic relaxation occuring" "-" typedef ^ ^ IntKi CDInst - 0 - "CableDyn mooring-backend instance handle (0 = none / needs rebuild after restart)" "" +typedef ^ ^ LOGICAL HostComplete - .FALSE. - "set by the host before CD_End when the run ended without an error" - diff --git a/src/openfast/CableDyn_Types.f90 b/src/openfast/CableDyn_Types.f90 index e654f304..a7f24bd9 100644 --- a/src/openfast/CableDyn_Types.f90 +++ b/src/openfast/CableDyn_Types.f90 @@ -396,6 +396,8 @@ MODULE CableDyn_Types REAL(DbKi) :: pred_t = -1 !< load predictor: time of the committed solve before the last (negative: none), from which the coupled loads between supercycled solves are extrapolated [-] REAL(DbKi) , DIMENSION(:), ALLOCATABLE :: pred_loads !< load predictor: the coupled forces (3 x ncp) and then moments (3 x ncp) of that solve [-] REAL(DbKi) , DIMENSION(:), ALLOCATABLE :: fluid_held !< the held ambient-fluid fields of the last committed mooring step (velocity, acceleration, free-surface elevation, dynamic pressure at the line nodes), restored at a restart instead of re-sampled [-] + REAL(DbKi) , DIMENSION(:), ALLOCATABLE :: range_state !< the accumulated range graphs (LINES flag r) through the last committed mooring step, in double precision, restored at a restart [-] + REAL(DbKi) , DIMENSION(:), ALLOCATABLE :: commit_input !< what the last commit was reached with: the coupled kinematics (position, velocity, acceleration, orientation, angular velocity, angular acceleration; 24 x ncp) then the line-control command (DeltaL, DeltaLdot); a rebuild stands on it, at any time of the mooring step after the commit [-] END TYPE CD_OtherStateType ! ======================= ! ========= CD_ParameterType ======= @@ -420,6 +422,7 @@ MODULE CableDyn_Types INTEGER(IntKi) :: NConns = 0 !< number of Connect type Points - not to be confused with NPoints [] INTEGER(IntKi) :: NAnchs = 0 !< number of Anchor type Points [] REAL(DbKi) :: Tmax = 0.0_R8Ki !< simulation duration [[s]] + LOGICAL :: Linearize = .FALSE. !< the run linearizes: a rebuilt instance needs the linearization state map [-] INTEGER(IntKi) :: tScheme = 0 !< Time integration scheme (0 = RK2, 1 = RK4). Default is RK2 [-] REAL(DbKi) :: g = 9.81 !< gravitational constant (positive) [[m/s^2]] REAL(DbKi) :: rhoW = 1025 !< density of seawater [[kg/m^3]] @@ -540,6 +543,7 @@ MODULE CableDyn_Types TYPE(GridInterp_MiscVarType) :: WaveField_m !< misc var information from the SeaState Interpolation module [-] LOGICAL :: IC_gen = .FALSE. !< boolean to indicate dynamic relaxation occuring [-] INTEGER(IntKi) :: CDInst = 0 !< CableDyn mooring-backend instance handle (0 = none / needs rebuild after restart) [] + LOGICAL :: HostComplete = .FALSE. !< set by the host before CD_End when the run ended without an error [-] END TYPE CD_MiscVarType ! ======================= integer(IntKi), public, parameter :: CD_x_states = 1 ! CD%states @@ -3004,6 +3008,30 @@ subroutine CD_CopyOtherState(SrcOtherStateData, DstOtherStateData, CtrlCode, Err end if DstOtherStateData%fluid_held = SrcOtherStateData%fluid_held end if + if (allocated(SrcOtherStateData%range_state)) then + LB(1:1) = lbound(SrcOtherStateData%range_state) + UB(1:1) = ubound(SrcOtherStateData%range_state) + if (.not. allocated(DstOtherStateData%range_state)) then + allocate(DstOtherStateData%range_state(LB(1):UB(1)), stat=ErrStat2) + if (ErrStat2 /= 0) then + call SetErrStat(ErrID_Fatal, 'Error allocating DstOtherStateData%range_state.', ErrStat, ErrMsg, RoutineName) + return + end if + end if + DstOtherStateData%range_state = SrcOtherStateData%range_state + end if + if (allocated(SrcOtherStateData%commit_input)) then + LB(1:1) = lbound(SrcOtherStateData%commit_input) + UB(1:1) = ubound(SrcOtherStateData%commit_input) + if (.not. allocated(DstOtherStateData%commit_input)) then + allocate(DstOtherStateData%commit_input(LB(1):UB(1)), stat=ErrStat2) + if (ErrStat2 /= 0) then + call SetErrStat(ErrID_Fatal, 'Error allocating DstOtherStateData%commit_input.', ErrStat, ErrMsg, RoutineName) + return + end if + end if + DstOtherStateData%commit_input = SrcOtherStateData%commit_input + end if end subroutine subroutine CD_DestroyOtherState(OtherStateData, ErrStat, ErrMsg) @@ -3019,6 +3047,12 @@ subroutine CD_DestroyOtherState(OtherStateData, ErrStat, ErrMsg) if (allocated(OtherStateData%fluid_held)) then deallocate(OtherStateData%fluid_held) end if + if (allocated(OtherStateData%range_state)) then + deallocate(OtherStateData%range_state) + end if + if (allocated(OtherStateData%commit_input)) then + deallocate(OtherStateData%commit_input) + end if end subroutine subroutine CD_PackOtherState(RF, Indata) @@ -3031,6 +3065,8 @@ subroutine CD_PackOtherState(RF, Indata) call RegPack(RF, InData%pred_t) call RegPackAlloc(RF, InData%pred_loads) call RegPackAlloc(RF, InData%fluid_held) + call RegPackAlloc(RF, InData%range_state) + call RegPackAlloc(RF, InData%commit_input) if (RegCheckErr(RF, RoutineName)) return end subroutine @@ -3047,6 +3083,8 @@ subroutine CD_UnPackOtherState(RF, OutData) call RegUnpack(RF, OutData%pred_t); if (RegCheckErr(RF, RoutineName)) return call RegUnpackAlloc(RF, OutData%pred_loads); if (RegCheckErr(RF, RoutineName)) return call RegUnpackAlloc(RF, OutData%fluid_held); if (RegCheckErr(RF, RoutineName)) return + call RegUnpackAlloc(RF, OutData%range_state); if (RegCheckErr(RF, RoutineName)) return + call RegUnpackAlloc(RF, OutData%commit_input); if (RegCheckErr(RF, RoutineName)) return end subroutine subroutine CD_CopyParam(SrcParamData, DstParamData, CtrlCode, ErrStat, ErrMsg) @@ -3115,6 +3153,7 @@ subroutine CD_CopyParam(SrcParamData, DstParamData, CtrlCode, ErrStat, ErrMsg) DstParamData%NConns = SrcParamData%NConns DstParamData%NAnchs = SrcParamData%NAnchs DstParamData%Tmax = SrcParamData%Tmax + DstParamData%Linearize = SrcParamData%Linearize DstParamData%tScheme = SrcParamData%tScheme DstParamData%g = SrcParamData%g DstParamData%rhoW = SrcParamData%rhoW @@ -3484,6 +3523,7 @@ subroutine CD_PackParam(RF, Indata) call RegPack(RF, InData%NConns) call RegPack(RF, InData%NAnchs) call RegPack(RF, InData%Tmax) + call RegPack(RF, InData%Linearize) call RegPack(RF, InData%tScheme) call RegPack(RF, InData%g) call RegPack(RF, InData%rhoW) @@ -3594,6 +3634,7 @@ subroutine CD_UnPackParam(RF, OutData) call RegUnpack(RF, OutData%NConns); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%NAnchs); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%Tmax); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%Linearize); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%tScheme); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%g); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%rhoW); if (RegCheckErr(RF, RoutineName)) return @@ -4529,6 +4570,7 @@ subroutine CD_CopyMisc(SrcMiscData, DstMiscData, CtrlCode, ErrStat, ErrMsg) if (ErrStat >= AbortErrLev) return DstMiscData%IC_gen = SrcMiscData%IC_gen DstMiscData%CDInst = SrcMiscData%CDInst + DstMiscData%HostComplete = SrcMiscData%HostComplete end subroutine subroutine CD_DestroyMisc(MiscData, ErrStat, ErrMsg) @@ -4809,6 +4851,7 @@ subroutine CD_PackMisc(RF, Indata) call GridInterp_PackMisc(RF, InData%WaveField_m) call RegPack(RF, InData%IC_gen) call RegPack(RF, InData%CDInst) + call RegPack(RF, InData%HostComplete) if (RegCheckErr(RF, RoutineName)) return end subroutine @@ -4962,6 +5005,7 @@ subroutine CD_UnPackMisc(RF, OutData) call GridInterp_UnpackMisc(RF, OutData%WaveField_m) ! WaveField_m call RegUnpack(RF, OutData%IC_gen); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%CDInst); if (RegCheckErr(RF, RoutineName)) return + call RegUnpack(RF, OutData%HostComplete); if (RegCheckErr(RF, RoutineName)) return end subroutine subroutine CD_Input_ExtrapInterp(u, t, u_out, t_out, ErrStat, ErrMsg) diff --git a/tests/check_bend_stiffener.py b/tests/check_bend_stiffener.py index c1e077a1..13f123e6 100644 --- a/tests/check_bend_stiffener.py +++ b/tests/check_bend_stiffener.py @@ -4,11 +4,12 @@ A power cable leaves a hang-off clamped horizontal at End A through a 5 m polyurethane bend stiffener (a 0.5 m cylindrical root and a 4.5 m cone) and hangs to a fixed End B 52 m away and 30 m lower. The driver solves the static equilibrium with the stiffener given as a STIFFENERS -table. The reference is an independent solution of the planar extensible elastica, +table. The reference is an independent solution of the planar extensible elastica of the energy +EI kappa^2/2 + EA e^2/2 per unstretched length, kappa the curvature of the stretched line, - x' = (1 + e) cos(theta), z' = (1 + e) sin(theta), theta' = M / EI(s), + x' = (1 + e) cos(theta), z' = (1 + e) sin(theta), theta' = (1 + e)^2 M / EI(s), M' = -(1 + e) (cos(theta) n_z - sin(theta) n_x), n' = (0, w(s)), - e = (n_x cos(theta) + n_z sin(theta)) / EA, + EA e - (1 + e) M^2 / EI(s) = n_x cos(theta) + n_z sin(theta), with EI(s) the cable's EI plus E pi/64 (OD(s)^4 - ID^4) of the sleeve and w(s) the submerged weight of cable and sleeve per unstretched length, solved by collocation (scipy solve_bvp) @@ -20,10 +21,29 @@ within 2 % of the root moment; and FairDefl1, the angle between the end force and the clamp axis, must follow the reference within 0.05 deg. +The nodal curvature and the cable's moment are also gated locally: at every node of the first +8 m they must follow the reference within a share of its value at that arc, not of the root +value: 0.5 % on elements of 0.1 and 0.05 m, 1 % on 0.2 and 0.15 m (the tip then inside an +element). The curvature changes sign near the tip, so the value is floored at 0.1 % of the root +curvature. The stiffness at a node is EI(s) there; at the tip the bare side's, the larger +curvature of a real step. The same gate holds under the stiffener, where a sleeve makes the +interpolation's curvature no section's, the node values of OutputRecovery element and the peak of +each element in .elements.out against the largest reference curvature over that element, read +through cabledyn.bend_check where the package imports. + The curvature over the same 8 m must also follow OrcaFlex 11.6d, with the stiffener given as a line attachment (tests/data/orcaflex_bend_stiffener_curvature.csv), within 1.5 % of the root curvature; this part needs no scipy. +At a node inside the cone Curv, CurvX, CurvY, BendMom and StfMom must agree: the components +compose the curvature, the moments are EI_cable and EI(s) - EI_cable times it. At real steps of +the section the side of the larger curvature must give the values: the bare tip, a step between +stiffener rows and a junction of two line types; a junction of two meshes of one line type +under the stiffener is no step. A deck node keeps the rule on a mesh the static solve refined. +A sleeve too soft for the recovered moment, where the moment's error is far above its elastic +moment, must keep its element peaks at the curvature of its nodes. These parts need no scipy +either. + The initialisation notes are checked too: none on this deck; the resolution note when the 5 m stiffener spans 3 elements (2 m elements); the pinned-end note when End A has no END CONNECTIONS row. @@ -45,11 +65,24 @@ except ImportError: # pragma: no cover - environment dependent solve_bvp = None +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "python")) +try: + from cabledyn import bend_check, read_output +except ImportError: # pragma: no cover - environment dependent + bend_check = read_output = None + MOMENT_TOLERANCE = 0.02 POSITION_TOLERANCE = 0.01 ANGLE_TOLERANCE = 0.05 ORCAFLEX_TOLERANCE = 0.015 +IDENTITY_TOLERANCE = 1.0e-5 +# elements over the first 8 m with their local curvature tolerance: 0.2, 0.15 (the stiffener tip +# inside an element), 0.1 and 0.05 m +MESH_SWEEP = ((40, 0.01), (54, 0.01), (80, 0.005), (160, 0.005)) +LOCAL_FLOOR = 1.0e-3 +ELEMENT_RECOVERY = ("9.80665 g\n", "element OutputRecovery\n9.80665 g\n") ORCAFLEX_CSV = pathlib.Path(__file__).resolve().parent / "data" / "orcaflex_bend_stiffener_curvature.csv" +REFINED_DECK = pathlib.Path(__file__).resolve().parent / "data" / "lazy_wave_touchdown_planar_coarse.dat" WINDOW = 8.0 G = 9.80665 @@ -107,6 +140,10 @@ "StfMom1N1" "BendMom1N41" "StfMom1N41" +"Curv1N41" +"CurvX1N41" +"CurvY1N41" +"Ten1N41" END """ @@ -135,6 +172,13 @@ def section(s: np.ndarray) -> tuple[np.ndarray, np.ndarray]: MOMENT_SCALE = 1.0e4 +def reference_stretch(y: np.ndarray, ei: np.ndarray) -> np.ndarray: + """1 + e of the elastica state y = (x, z, theta, M, n_x, n_z), scaled as in reference().""" + bending = (MOMENT_SCALE * y[3]) ** 2 / ei + axial = FORCE_SCALE * (y[4] * np.cos(y[2]) + y[5] * np.sin(y[2])) + return 1.0 + (axial + bending) / (EA - bending) + + def reference(s_nodes: np.ndarray, x_seed: np.ndarray, z_seed: np.ndarray): """Planar extensible elastica along the unstretched arc, y = (x, z, theta, M, n_x, n_z), with the moment and the force scaled by MOMENT_SCALE and FORCE_SCALE. The seed is the shape only: @@ -144,8 +188,8 @@ def rhs(s, y): x, z, th, m, nx, nz = y ei, w = section(s) c, sn = np.cos(th), np.sin(th) - stretch = 1.0 + FORCE_SCALE * (nx * c + nz * sn) / EA - return np.vstack([stretch * c, stretch * sn, MOMENT_SCALE * m / ei, + stretch = reference_stretch(y, ei) + return np.vstack([stretch * c, stretch * sn, stretch**2 * MOMENT_SCALE * m / ei, -stretch * FORCE_SCALE / MOMENT_SCALE * (c * nz - sn * nx), np.zeros_like(s), w / FORCE_SCALE]) @@ -167,6 +211,7 @@ def bc(ya, yb): RESOLUTION_NOTE = "stiffener at End A spans" +FALLBACK_NOTES = ("reached after a failed Newton solve", "stalled", "looser tolerance") PINNED_NOTE = "the stiffener at End A sleeves a pinned end" @@ -218,6 +263,209 @@ def check_orcaflex(arc: np.ndarray, curvature: np.ndarray) -> bool: return ok +def step_deck(sections: str, rows: list[tuple[float, float, float]], channels: list[str]) -> str: + """The deck on 0.1 m elements with other first sections (the last 52 m stay) and stiffener + rows (length, root OD, tip OD), writing the node channels asked for.""" + deck = DECK.replace("1 cable 8.0 160\n", sections) + start = deck.index("1 A ") + deck = deck[:start] + "".join( + f"1 A {length} {od0} {od1} {ID_STF} {E_STF} {RHO_STF}\n" for length, od0, od1 in rows + ) + deck[deck.index("--------------------- END CONNECTIONS"):] + deck = deck.replace( + f"cable {D_CABLE} {M_CABLE} {EA} -1.0 {EI_CABLE} 1.2 0.0 1.0 0.0\n", + f"cable {D_CABLE} {M_CABLE} {EA} -1.0 {EI_CABLE} 1.2 0.0 1.0 0.0\n" + f"stiff {D_CABLE} {M_CABLE} {EA} -1.0 {2 * EI_CABLE} 1.2 0.0 1.0 0.0\n") + return deck[:deck.index('"FairTen1"')] + "".join(f'"{c}"\n' for c in channels) + "END\n" + + +def sleeve(od: float) -> float: + return E_STF * math.pi / 64.0 * (od**4 - ID_STF**4) + + +def check_steps(exe: str, tmp: str) -> bool: + """The section moment BendMom + StfMom over the softer side's stiffness at a real step, and + over EI(s) at a node where only the mesh changes (node 31 at 3 m, node 21 at 2 m, node 51 at + the 5 m tip).""" + cone = [(0.5, 0.60, 0.60), (4.5, 0.60, 0.22)] + od_3m = 0.60 + 2.5 / 4.5 * (0.22 - 0.60) + # name, sections, stiffener rows, node, stiffness of the side of the curvature, cable stiffness of + # the BendMom share (the larger share, on the side of the larger EI_cable) + cases = [ + ("bare tip", "1 cable 8.0 80\n", cone, 51, EI_CABLE, EI_CABLE), + ("step between rows", "1 cable 8.0 80\n", [(2.0, 0.60, 0.60), (3.0, 0.45, 0.22)], 21, + EI_CABLE + sleeve(0.45), EI_CABLE), + ("junction of two line types", "1 cable 3.0 30\n1 stiff 5.0 50\n", cone, 31, + EI_CABLE + sleeve(od_3m), 2 * EI_CABLE), + ("junction of two meshes", "1 cable 3.0 60\n1 cable 5.0 50\n", cone, 61, + EI_CABLE + sleeve(od_3m), EI_CABLE), + ] + ok = True + for name, sections, rows, node, ei, ei_cable in cases: + channels = [f"{c}1N{node}" for c in ("Curv", "BendMom", "StfMom", "Ten")] + rc, log, _, out = solve(exe, tmp, "step", step_deck(sections, rows, channels)) + if rc != 0: + print(log[-3000:]) + print(f"FAIL {name}: the driver did not solve the deck") + return False + curvature, bend, stiffener, tension = (out[c] for c in channels) + moment = bend + stiffener + stretch = 1.0 + tension / EA + full = ei_cable == ei + if name == "junction of two line types": + # the stiffer type's own share: EI_cable M / (EI_cable + sleeve) + share = ei_cable * moment / (ei_cable + sleeve(od_3m)) + else: + share = moment if full else ei_cable * curvature / stretch + error = max(abs(curvature * ei / (stretch * moment) - 1.0), abs(bend / share - 1.0)) + good = error <= IDENTITY_TOLERANCE + print(f"{'ok ' if good else 'FAIL'} {name}: curvature {curvature:.5f} 1/m, " + f"section moment {moment:.1f} N m, largest relative difference {error:.1e}") + ok = ok and good + return ok + + +def check_refined(exe: str, tmp: str) -> bool: + """A stiffener on a line whose static solve refines the deck mesh, the coarse lazy wave with + 20 m elements under a 60 m cone: the stiffness at the deck node 40 m along, on the final mesh, + gives its curvature.""" + deck = REFINED_DECK.read_text().replace("4 bare 80.0 20", "4 bare 80.0 4") + length, od0, od1, bore = 60.0, 0.22, 0.17, 0.16 + deck = deck.replace("--------------------- OPTIONS", ( + "--------------------- STIFFENERS ---------------------------------------------\n" + "LineID End Length RootOD TipOD InnerD E Density\n" + "(-) (-) (m) (m) (m) (m) (Pa) (kg/m^3)\n" + f"4 A {length} {od0} {od1} {bore} {E_STF} {RHO_STF}\n" + "--------------------- OPTIONS"), 1) + deck = deck.replace("FairTen4\n", "Curv4N3\nBendMom4N3\nStfMom4N3\nTen4N3\n") + rc, log, rows, out = solve(exe, tmp, "refined", deck) + if rc != 0 or "refined to" not in log: + print(log[-3000:]) + print("FAIL: the driver did not solve and refine the coarse deck") + return False + od = od0 + 40.0 / length * (od1 - od0) + ei = 1.99e4 + E_STF * math.pi / 64.0 * (od**4 - bore**4) + stretch = 1.0 + out["Ten4N3"] / 4.69e8 + error = abs(out["Curv4N3"] * ei / (stretch * (out["BendMom4N3"] + out["StfMom4N3"])) - 1.0) + ok = error <= IDENTITY_TOLERANCE + print(f"{'ok ' if ok else 'FAIL'} refined mesh of {len(rows) - 1} elements: curvature at the " + f"deck node within {error:.1e} of the section moment over EI(s)") + return ok + + +def check_soft_sleeve(exe: str, tmp: str) -> bool: + """The coarse lazy wave with EI 100 N m^2 under a 100 m sleeve of 120 N m^2 at most, far below + the stiffness that resolves the recovered moment, max(w L0^2)/12 of about 1300 N m^2 on its + 10 m elements: moment over stiffness would be noise there. The element peaks must stay with + the curvature of their end nodes, which take the interpolation's.""" + length = 100.0 + deck = REFINED_DECK.read_text().replace(" 1.99e4 ", " 100.0 ") + deck = deck.replace("--------------------- OPTIONS", ( + "--------------------- STIFFENERS ---------------------------------------------\n" + "LineID End Length RootOD TipOD InnerD E Density\n" + "(-) (-) (m) (m) (m) (m) (Pa) (kg/m^3)\n" + f"4 A {length} 0.18 0.17 0.16 1.0e6 {RHO_STF}\n" + "--------------------- OPTIONS"), 1) + rc, log, rows, _ = solve(exe, tmp, "soft", deck) + if rc != 0: + print(log[-3000:]) + print("FAIL: the driver did not solve the deck with a soft sleeve") + return False + nodes = np.array([float(r.split()[7]) for r in rows if r.strip()]) + table = (pathlib.Path(tmp) / "soft.elements.out").read_text().splitlines()[3:] + table = np.array([[float(v) for v in r.split()] for r in table if r.strip()]) + covered = int(np.sum(table[:, 3] <= length + 1.0e-9)) + ratio = np.max(table[:covered, 6] / np.maximum(nodes[:covered], nodes[1:covered + 1])) + ok = ratio <= 1.02 + print(f"{'ok ' if ok else 'FAIL'} soft sleeve: element peaks within a factor {ratio:.3f} of the " + f"curvature at their end nodes") + return ok + + +def check_identities(out: dict[str, float], name: str = "node 41", stretch: float | None = None) -> bool: + """Curv, CurvX, CurvY, BendMom and StfMom of a node inside the cone (node 41, 2 m): the moments + are the stiffnesses times the curvature over the stretch, 1 under OutputRecovery element.""" + curvature = out["Curv1N41"] + ei = float(section(np.array([2.0]))[0][0]) + stretch = 1.0 + out["Ten1N41"] / EA if stretch is None else stretch + error = max(abs(math.hypot(out["CurvX1N41"], out["CurvY1N41"]) / curvature - 1.0), + abs(out["BendMom1N41"] * stretch / (EI_CABLE * curvature) - 1.0), + abs(out["StfMom1N41"] * stretch / ((ei - EI_CABLE) * curvature) - 1.0)) + ok = error <= IDENTITY_TOLERANCE + print(f"{'ok ' if ok else 'FAIL'} {name}: curvature components and moment shares agree " + f"within {error:.1e}") + return ok + + +def element_peaks(path: pathlib.Path, n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Arc start and end of the first n elements of a .elements.out, and their peak curvature, + through bend_check where the package imports.""" + table = np.array([[float(v) for v in r.split()] for r in path.read_text().splitlines()[3:]])[:n] + if bend_check is None: + return table[:, 2], table[:, 3], table[:, 6] + return table[:, 2], table[:, 3], bend_check(read_output(path), 1.0, condition="storage", line_id=1).curvature[:n] + + +def reference_curvature(sol, s: np.ndarray, ei: np.ndarray | None = None) -> np.ndarray: + """Curvature of the stretched line, (1 + e) M / EI, at arc s.""" + ei = section(s)[0] if ei is None else ei + y = sol.sol(s) + return reference_stretch(y, ei) * np.abs(y[3]) * MOMENT_SCALE / ei + + +def check_local(exe: str, tmp: str, sol) -> bool: + """Curvature and cable moment over the first WINDOW m against the elastica, each within the + tolerance of MESH_SWEEP of the reference where it is reported, element size swept: at the + nodes, and under the stiffener at the nodes of OutputRecovery element and the peak of each + element.""" + ok = True + root_curvature = float(reference_curvature(sol, np.array([0.0]))[0]) + tip = SEGMENTS[-1][1] + for n, tolerance in MESH_SWEEP: + deck = DECK.replace("1 cable 8.0 160", f"1 cable 8.0 {n}") + s = np.arange(n + 1) * (WINDOW / n) + ei = section(s)[0] + ei[np.abs(s - tip) < 1.0e-9] = EI_CABLE + curvature = reference_curvature(sol, s, ei) + moment = np.abs(sol.sol(s)[3]) * MOMENT_SCALE / ei + scale = np.maximum(curvature, LOCAL_FLOOR * root_curvature) + for name, text in (("equilibrium nodes", deck), ("element nodes", deck.replace(*ELEMENT_RECOVERY))): + rc, log, rows, _ = solve(exe, tmp, "local", text) + if rc != 0: + print(log[-3000:]) + print(f"FAIL: the driver did not solve the deck with {n} elements") + return False + if any(note in log for note in FALLBACK_NOTES): + print(log[-3000:]) + print(f"FAIL: the static solve with {n} elements fell back") + return False + data = np.array([[float(v) for v in r.split()[2:9]] for r in rows[:n + 1]]) + # past the tip an element's own curvature is the section's, by definition + covered = s < tip - 1.0e-9 if name == "element nodes" else s < np.inf + error = np.where(covered, np.abs(data[:, 5] - curvature) / scale, 0.0) + # the element definition of the bend moment is EI times the curvature + shared = curvature if name == "element nodes" else moment + moment_error = np.max(np.where(covered, np.abs(np.abs(data[:, 6]) / EI_CABLE - shared) / scale, 0.0)) + worst = int(np.argmax(error)) + good = max(error[worst], moment_error) <= tolerance + print(f"{'ok ' if good else 'FAIL'} {WINDOW / n:.3f} m elements, {name}: curvature within " + f"{100 * error[worst]:.3f} % locally (at {s[worst]:.3f} m), cable moment within " + f"{100 * moment_error:.3f} %, gate {100 * tolerance:.1f} %") + ok = ok and good + start, stop, peak = element_peaks(pathlib.Path(tmp) / "local.elements.out", n) + # inside each element, so a tip on its end reads the element's own side + inner = start[:, None] + (stop - start)[:, None] * np.linspace(1.0e-6, 1.0 - 1.0e-6, 33) + reference = reference_curvature(sol, inner.ravel()).reshape(inner.shape).max(axis=1) + error = np.abs(peak - reference) / np.maximum(reference, LOCAL_FLOOR * root_curvature) + error[start >= tip - 1.0e-9] = 0.0 + worst = int(np.argmax(error)) + good = error[worst] <= tolerance + print(f"{'ok ' if good else 'FAIL'} {WINDOW / n:.3f} m elements, element peaks: within " + f"{100 * error[worst]:.3f} % locally (element at {start[worst]:.3f} m), gate " + f"{100 * tolerance:.1f} %") + ok = ok and good + return ok + + def main() -> int: exe = sys.argv[1] with tempfile.TemporaryDirectory() as tmp: @@ -227,14 +475,25 @@ def main() -> int: print("FAIL: the driver did not solve the stiffener deck") return 1 notes_ok = check_notes(exe, tmp, log) - data = np.array([[float(v) for v in r.split()[2:9]] for r in rows if r.strip()]) - orcaflex_ok = check_orcaflex(data[:, 0], data[:, 5]) - if solve_bvp is None: - print("SKIP: scipy is not installed (the elastica comparison needs solve_bvp)") - return 77 if notes_ok and orcaflex_ok else 1 - arc, x, z, moment = data[:, 0], data[:, 1], data[:, 3] + 20.0, data[:, 6] - s_nodes = np.cumsum(np.r_[0.0, np.full(160, 8.0 / 160), np.full(52, 1.0)]) - sol = reference(s_nodes, x, z) + notes_ok = check_steps(exe, tmp) and notes_ok + notes_ok = check_refined(exe, tmp) and notes_ok + notes_ok = check_soft_sleeve(exe, tmp) and notes_ok + data = np.array([[float(v) for v in r.split()[2:9]] for r in rows if r.strip()]) + notes_ok = check_identities(out) and notes_ok + rc, log, _, element_out = solve(exe, tmp, "element", DECK.replace(*ELEMENT_RECOVERY)) + if rc != 0: + print(log[-3000:]) + print("FAIL: the driver did not solve the deck under OutputRecovery element") + return 1 + notes_ok = check_identities(element_out, "node 41, OutputRecovery element", 1.0) and notes_ok + orcaflex_ok = check_orcaflex(data[:, 0], data[:, 5]) + if solve_bvp is None: + print("SKIP: scipy is not installed (the elastica comparison needs solve_bvp)") + return 77 if notes_ok and orcaflex_ok else 1 + arc, x, z, moment = data[:, 0], data[:, 1], data[:, 3] + 20.0, data[:, 6] + s_nodes = np.cumsum(np.r_[0.0, np.full(160, 8.0 / 160), np.full(52, 1.0)]) + sol = reference(s_nodes, x, z) + local_ok = check_local(exe, tmp, sol) ref = sol.sol(s_nodes) ref[3] *= MOMENT_SCALE section_moment = np.abs(ref[3]) @@ -255,7 +514,8 @@ def main() -> int: angle_error = abs(out["FairDefl1"] - deflection) print(f"FairDefl1: driver {out['FairDefl1']:.4f} deg, elastica {deflection:.4f} deg") ok = (cable_error <= MOMENT_TOLERANCE and position_error <= POSITION_TOLERANCE and notes_ok - and total_error <= MOMENT_TOLERANCE and angle_error <= ANGLE_TOLERANCE and orcaflex_ok) + and total_error <= MOMENT_TOLERANCE and angle_error <= ANGLE_TOLERANCE and orcaflex_ok + and local_ok) print("PASS" if ok else "FAIL") return 0 if ok else 1 diff --git a/tests/check_curvature_stretch.py b/tests/check_curvature_stretch.py new file mode 100644 index 00000000..683a1714 --- /dev/null +++ b/tests/check_curvature_stretch.py @@ -0,0 +1,175 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Curvature and section moment of a stretched finite-EI line (driver_curvature_stretch in +CMakeLists.txt). + +The element energy is EI kappa^2/2 + EA e^2/2 per unstretched length, with kappa the curvature of +the stretched line. A soft cable (axial strain up to 1.2 %) is clamped at End A and pinned at End +B, and the driver's static profile is compared with an independent solution of the planar +elastica of that energy, + + x' = (1 + e) cos(theta), z' = (1 + e) sin(theta), theta' = (1 + e)^2 M / EI, + M' = -(1 + e) (cos(theta) n_z - sin(theta) n_x), n' = (0, w), + EA e - (1 + e) M^2 / EI = n_x cos(theta) + n_z sin(theta), + +where M is the moment that balances the forces and kappa = theta'/(1 + e) = (1 + e) M / EI. The +strain is swept with EA, and at every node, relative to the local value floored at 1 % of the +peak, Curvature must follow kappa and BendMoment must follow M within 0.1 % (the old definitions +were low by 2e and e: 2.3 % and 1.2 % at the root). The exact trace of OutputRecovery element must +agree with the recovered curvature to the mesh's second order, within 0.5 %. The channels must +keep CurvX^2 + CurvY^2 = Curv^2 and BendMom (1 + e) = EI Curv. + +Usage: check_curvature_stretch.py +Exit 77 (skipped) when scipy is not installed. +""" + +import math +import pathlib +import subprocess +import sys +import tempfile + +import numpy as np + +try: + from scipy.integrate import solve_bvp +except ImportError: # pragma: no cover - environment dependent + solve_bvp = None + +G = 9.80665 +RHO_W = 1025.0 +D_CABLE = 0.20 +M_CABLE = 60.0 +EI = 2.0e4 +LENGTH = 60.0 +END_B = (45.0, -30.0) +W = (M_CABLE - RHO_W * math.pi / 4.0 * D_CABLE**2) * G +SWEEP = (2.0e6, 1.0e7, 5.0e8) +ELEMENTS = 480 +TOLERANCE = 1.0e-3 +TRACE_TOLERANCE = 5.0e-3 +IDENTITY_TOLERANCE = 2.0e-5 +FLOOR = 1.0e-2 +NODE = 10 +MOMENT_SCALE = 1.0e3 +FORCE_SCALE = 1.0e4 + + +def deck(ea: float, recovery: str = "") -> str: + return f"""--------------------- CableDyn Input File ------------------------------------ +Curvature of a stretched cable: clamped at End A, static +--------------------- LINE TYPES --------------------------------------------- +TypeName Diam MassDenInAir EA BA/-zeta EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (N-s/-) (N-m^2) (-) (-) (-) (-) +cable {D_CABLE} {M_CABLE} {ea} -1.0 {EI} 1.2 0.0 1.0 0.0 +--------------------- POINTS ------------------------------------------------- +ID Type X Y Z Mass Vol CdA Ca +(-) (-) (m) (m) (m) (kg) (m^3) (m^2) (-) +1 Coupled 0.0 0.0 -20.0 0 0 0 0 +2 Fixed {END_B[0]} 0.0 {-20.0 + END_B[1]} 0 0 0 0 +--------------------- LINES -------------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +1 1 2 - +--------------------- SECTIONS ----------------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +1 cable {LENGTH} {ELEMENTS} +--------------------- END CONNECTIONS ---------------------------------------- +LineID End Stiffness EzX EzY EzZ +(-) (-) (N-m/rad) (-) (-) (-) +1 A Rigid 1.0 0.0 0.0 +--------------------- OPTIONS ------------------------------------------------ +{recovery}9.80665 g +1025.0 rhoW +0.05 dtM +0.0 TMax +--------------------- OUTPUTS ------------------------------------------------ +"Curv1N{NODE}" +"CurvX1N{NODE}" +"CurvY1N{NODE}" +"BendMom1N{NODE}" +"Ten1N{NODE}" +END +""" + + +def solve(exe: str, tmp: str, name: str, text: str) -> tuple[np.ndarray, dict[str, float]]: + """The static profile (columns from ArcLength) and the output channels at t = 0.""" + path = pathlib.Path(tmp) / f"{name}.dat" + path.write_text(text) + root = pathlib.Path(tmp) / name + run = subprocess.run([exe, str(path), str(root)], capture_output=True, text=True) + if run.returncode != 0: + raise SystemExit(f"FAIL: the driver did not solve {name}:\n{(run.stdout + run.stderr)[-2000:]}") + rows = pathlib.Path(f"{root}.static.out").read_text().splitlines()[3:] + lines = pathlib.Path(f"{root}.out").read_text().splitlines() + channels = dict(zip(lines[1].split(), (float(v) for v in lines[2].split()))) + return np.array([[float(v) for v in r.split()[2:9]] for r in rows if r.strip()]), channels + + +def stretch_of(y: np.ndarray, ea: float) -> np.ndarray: + bending = (MOMENT_SCALE * y[3]) ** 2 / EI + axial = FORCE_SCALE * (y[4] * np.cos(y[2]) + y[5] * np.sin(y[2])) + return 1.0 + (axial + bending) / (ea - bending) + + +def reference(arc: np.ndarray, x_seed: np.ndarray, z_seed: np.ndarray, ea: float): + """The elastica, y = (x, z, theta, M, n_x, n_z), the moment and force scaled, seeded by the + driver's shape.""" + + def rhs(s, y): + stretch = stretch_of(y, ea) + c, sn = np.cos(y[2]), np.sin(y[2]) + return np.vstack([stretch * c, stretch * sn, stretch**2 * MOMENT_SCALE * y[3] / EI, + -stretch * FORCE_SCALE / MOMENT_SCALE * (c * y[5] - sn * y[4]), + np.zeros_like(s), np.full_like(s, W / FORCE_SCALE)]) + + def bc(ya, yb): + return np.array([ya[0], ya[1], ya[2], yb[0] - END_B[0], yb[1] - END_B[1], yb[3]]) + + mesh = np.linspace(0.0, LENGTH, 3001) + xs, zs = np.interp(mesh, arc, x_seed), np.interp(mesh, arc, z_seed) + th = np.unwrap(np.arctan2(np.gradient(zs, mesh), np.gradient(xs, mesh))) + y0 = np.vstack([xs, zs, th, np.zeros_like(mesh), np.full_like(mesh, np.cos(th[-1])), np.sin(th)]) + sol = solve_bvp(rhs, bc, mesh, y0, tol=1.0e-7, max_nodes=400000) + if not sol.success: + raise SystemExit(f"FAIL: the reference elastica did not converge: {sol.message}") + return sol + + +def check(exe: str, tmp: str, ea: float) -> bool: + data, out = solve(exe, tmp, "stretch", deck(ea)) + element, _ = solve(exe, tmp, "trace", deck(ea, "element OutputRecovery\n")) + arc = np.arange(ELEMENTS + 1) * (LENGTH / ELEMENTS) + y = reference(arc, data[:, 1], data[:, 3] + 20.0, ea).sol(arc) + stretch = stretch_of(y, ea) + moment = np.abs(y[3]) * MOMENT_SCALE + kappa = stretch * moment / EI + scale = np.maximum(kappa, FLOOR * kappa.max()) + curvature_error = np.max(np.abs(data[:, 5] - kappa) / scale) + moment_error = np.max(np.abs(data[:, 6] - moment) / (EI * scale)) + trace_error = np.max(np.abs(element[:, 5] - data[:, 5]) / scale) + curvature = out[f"Curv1N{NODE}"] + identity_error = max( + abs(math.hypot(out[f"CurvX1N{NODE}"], out[f"CurvY1N{NODE}"]) / curvature - 1.0), + abs(out[f"BendMom1N{NODE}"] * stretch[NODE - 1] / (EI * curvature) - 1.0)) + ok = (curvature_error <= TOLERANCE and moment_error <= TOLERANCE and trace_error <= TRACE_TOLERANCE + and identity_error <= IDENTITY_TOLERANCE) + print(f"{'ok ' if ok else 'FAIL'} EA {ea:.1e} N, strain up to {100 * (stretch.max() - 1.0):.3f} %: " + f"curvature within {100 * curvature_error:.3f} %, moment within {100 * moment_error:.3f} %, " + f"exact trace within {100 * trace_error:.3f} %, identities within {identity_error:.1e}") + return ok + + +def main() -> int: + if solve_bvp is None: + print("SKIP: scipy is not installed") + return 77 + with tempfile.TemporaryDirectory() as tmp: + ok = all([check(sys.argv[1], tmp, ea) for ea in SWEEP]) + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/check_example_fallbacks.py b/tests/check_example_fallbacks.py new file mode 100644 index 00000000..b61d1dfd --- /dev/null +++ b/tests/check_example_fallbacks.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Run every shipped example deck and fail on a solver fallback note. + +Each deck of the examples directory runs on the given driver, single-threaded, with its output +root under a scratch directory. A deck offends when the driver does not exit with 0, runs past +the timeout, or writes a fallback note to the console: a deck that should converge cleanly must +not reach its result through a failed Newton solve, a stall, a looser tolerance or a sub-step that +gave up. Such a note marks round-off fragility, which a different BLAS kernel can turn into a +failure. + +With --coretype every run sets OPENBLAS_CORETYPE to that kernel and asks OpenBLAS to name the +kernel it selected. The check fails when OpenBLAS names another kernel, or when no run names one +at all, as with a build of OpenBLAS without dynamic architecture support, which ignores the +variable. --kernel-only runs the first deck alone for that check, before a long test run. + +Usage: check_example_fallbacks.py --driver --examples + --out [--coretype ] + [--kernel-only] [--jobs ] [--timeout ] +Exit status: 0 clean; 1 an offending deck or the wrong kernel; 2 no decks or bad arguments. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import time +from collections.abc import Sequence +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from pathlib import Path + +# The console notes of a fallback. A note may be wrapped, so whitespace runs match any spacing. +FALLBACK_NOTES = ( + ("failed Newton solve", re.compile(r"after\s+a\s+failed\s+Newton\s+solve", re.IGNORECASE)), + ("stall", re.compile(r"\bstalled\b", re.IGNORECASE)), + ("looser tolerance", re.compile(r"\blooser\s+tolerance\b", re.IGNORECASE)), + # The note of an equilibrium accepted above the requested tolerance. + ("looser tolerance", re.compile(r"\bresidual\s+floor\s+above\b", re.IGNORECASE)), + ("sub-step", re.compile(r"\bresolving\s+sub-step\s+did\s+not\s+converge", re.IGNORECASE)), +) +# What OpenBLAS prints at start-up under OPENBLAS_VERBOSE=2, in a dynamic-architecture build only. +CORE_LINE = re.compile(r"^\s*Core:\s*(\S+)\s*$", re.MULTILINE) + + +@dataclass +class DeckRun: + """The outcome of one deck.""" + + deck: str + returncode: int | None = None + seconds: float = 0.0 + notes: list[str] = field(default_factory=list) + cores: set[str] = field(default_factory=set) + + @property + def offends(self) -> bool: + return self.returncode != 0 or bool(self.notes) + + +def fallback_notes(console: str) -> list[str]: + """The console text of every fallback note, one entry per note found.""" + found: list[str] = [] + for name, pattern in FALLBACK_NOTES: + for match in pattern.finditer(console): + start = console.rfind("\n", 0, match.start()) + 1 + stop = console.find("\n", match.end()) + line = console[start : stop if stop >= 0 else len(console)].strip() + found.append(f"{name}: {line[:200]}") + return found + + +def run_deck( + command: Sequence[str], + deck: Path, + out: Path, + timeout: float, + coretype: str | None, +) -> DeckRun: + """Run one deck from the directory of the deck, whose relative paths it may use.""" + env = dict(os.environ, OMP_NUM_THREADS="1", OPENBLAS_NUM_THREADS="1") + if coretype is not None: + env.update(OPENBLAS_CORETYPE=coretype, OPENBLAS_VERBOSE="2") + result = DeckRun(deck.name) + start = time.monotonic() + try: + process = subprocess.run( + [*command, deck.name, str(out / deck.stem)], + cwd=deck.parent, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired: + result.seconds = time.monotonic() - start + result.notes.append(f"timeout: still running after {timeout:g} s") + return result + result.seconds = time.monotonic() - start + result.returncode = process.returncode + console = process.stdout + "\n" + process.stderr + (out / f"{deck.stem}.console.log").write_text(console, encoding="utf-8") + result.notes = fallback_notes(console) + result.cores = set(CORE_LINE.findall(console)) + return result + + +def kernel_problem(runs: Sequence[DeckRun], coretype: str) -> str | None: + """Why the runs did not use the kernel ``coretype``, or None if they did.""" + cores = set().union(*(run.cores for run in runs)) if runs else set() + if not cores: + return ( + f"OpenBLAS named no kernel, so OPENBLAS_CORETYPE={coretype} was not shown to take " + "effect: the driver is not on a dynamic-architecture OpenBLAS" + ) + wrong = sorted(core for core in cores if core.lower() != coretype.lower()) + if wrong: + return f"OPENBLAS_CORETYPE={coretype} did not take effect: OpenBLAS ran {', '.join(wrong)}" + return None + + +def scan( + command: Sequence[str], + decks: Sequence[Path], + out: Path, + *, + jobs: int, + timeout: float, + coretype: str | None, +) -> list[DeckRun]: + """Run the decks, printing each result as it arrives.""" + out.mkdir(parents=True, exist_ok=True) + runs: list[DeckRun] = [] + with ThreadPoolExecutor(jobs) as pool: + pending = [ + pool.submit(run_deck, command, deck, out, timeout, coretype) for deck in decks + ] + for future in as_completed(pending): + run = future.result() + runs.append(run) + state = "OFFENDS" if run.offends else "ok" + print(f"{state:8}{run.seconds:9.1f} s {run.deck}", flush=True) + return sorted(runs, key=lambda run: run.deck) + + +def report(runs: Sequence[DeckRun], coretype: str | None) -> int: + """Print the offending decks and the verdict; return the exit status.""" + offending = [run for run in runs if run.offends] + if offending: + print(f"\n{len(offending)} of {len(runs)} example decks offend:") + for run in offending: + exit_text = "no exit" if run.returncode is None else f"exit {run.returncode}" + print(f" {run.deck} ({exit_text})") + for note in run.notes: + print(f" {note}") + problem = kernel_problem(runs, coretype) if coretype is not None else None + if problem is not None: + print(f"\nFAIL: {problem}") + if offending or problem is not None: + return 1 + kernel = f" on the {coretype} kernel" if coretype is not None else "" + print(f"\n{len(runs)} example decks ran{kernel} with no fallback note") + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Fail on a fallback note of an example deck.") + parser.add_argument("--driver", required=True, type=Path, help="the cabledyn executable") + parser.add_argument("--examples", required=True, type=Path, help="the examples directory") + parser.add_argument("--out", required=True, type=Path, help="scratch output directory") + parser.add_argument("--coretype", help="the OpenBLAS kernel to force and to confirm") + parser.add_argument( + "--kernel-only", action="store_true", help="only confirm --coretype, on the first deck" + ) + parser.add_argument("--jobs", type=int, default=os.cpu_count() or 1, help="parallel decks") + parser.add_argument("--timeout", type=float, default=3600.0, help="seconds for each deck") + args = parser.parse_args(argv) + if args.kernel_only and args.coretype is None: + parser.error("--kernel-only needs --coretype") + + examples = args.examples.resolve() + decks = sorted(path for path in examples.glob("*.dat") if path.is_file()) + if not decks: + print(f"FAIL: no example decks in {examples}") + return 2 + command = [str(args.driver.resolve())] + out = args.out.resolve() + if args.kernel_only: + runs = scan(command, decks[:1], out, jobs=1, timeout=args.timeout, coretype=args.coretype) + problem = kernel_problem(runs, args.coretype) + print(f"FAIL: {problem}" if problem else f"OpenBLAS ran the {args.coretype} kernel") + return 1 if problem else 0 + runs = scan( + command, decks, out, jobs=args.jobs, timeout=args.timeout, coretype=args.coretype + ) + return report(runs, args.coretype) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/data/wavekin_mdc_mean/mdc_wavekin3.csv b/tests/data/wavekin_mdc_mean/mdc_wavekin3.csv new file mode 100644 index 00000000..ab8d9207 --- /dev/null +++ b/tests/data/wavekin_mdc_mean/mdc_wavekin3.csv @@ -0,0 +1,13 @@ +t,x,y,z,zeta,ux,uy,uz,ax,ay,az +0.99999999999999989,0,0,-5,1.7656452967352594,0.47135439858475092,0,-0.29093734127193621,-0.22924510870124556,0,-0.45098576580724214 +1,10,0,-20,1.5690766434753844,0.16941914094865154,0,-0.0078435433010975823,0.00096846301730661504,0,-0.12599244844427579 +1,20,-5,-35,1.112362131387534,0.06783435076708863,0,0.026051979639990383,0.028040421037256014,0,-0.037700903133677501 +1,-10,5,-2,0.91266849598056543,0.072837870869480681,0,-0.83498814438788249,-0.80734296163395158,0,-0.053093553014689598 +2.4999999999998339,0,0,-5,0.37564516514619584,-0.23553638413104661,0,-0.61718481847489548,-0.58290981241145723,0,0.2136805234959544 +2.5,10,0,-20,1.3755740128737259,0.058057303846107994,0,-0.16490950041415758,-0.13813731121596753,0,-0.052052106001920929 +2.5,20,-5,-35,1.4991103067360099,0.059901405251279349,0,-0.034834434114414517,-0.037385801049596705,0,-0.034382628028303712 +2.5,-10,5,-2,-0.2605257763638415,-0.84023102804197525,0,-0.16611787248606089,-0.071989247228249356,0,0.82674813935607339 +6.3000000000008001,0,0,-5,1.3607144758357559,0.27319781347647693,0,0.34656556520065551,0.24374637664390825,0,-0.29566432600598991 +6.3000000000008001,10,0,-20,0.61875379753327753,-0.062648480661586736,0,0.1747058117574215,0.15165498943135303,0,0.043872097232282969 +6.3000000000008001,20,-5,-35,-0.26857987692399771,-0.070031511042301292,0,0.03108222587075667,0.03306636054444799,0,0.041893438306418285 +6.3000000000008001,-10,5,-2,1.2532524850724429,0.27573000972212669,0,0.055019136270277241,-0.086139173441920722,0,-0.15924458020052942 diff --git a/tests/data/wavekin_mdc_mean/mdc_wavekin7.csv b/tests/data/wavekin_mdc_mean/mdc_wavekin7.csv new file mode 100644 index 00000000..2d6e1f3a --- /dev/null +++ b/tests/data/wavekin_mdc_mean/mdc_wavekin7.csv @@ -0,0 +1,41 @@ +t,x,y,z,zeta,ux,uy,uz,ax,ay,az +0.014900835100000006,79.1728498258,-89.578804867599999,-4.2729514839,1.3533181799406684,0.0033803362336228479,-0.11435574237165855,-0.017446805942206552,0.082027710610248356,-0.2110306980128252,-0.15350668250870234 +0.30346007659999996,37.836530609599997,-48.778135586499999,-8.7890383547000006,1.0450276229723783,-0.22095071472922914,0.0034170814895026769,-0.16739510779509506,-0.20110853666145792,-0.016125333150614447,-0.043016483968293238 +0.85649167140000015,43.8717378865,-1.7167250582,-13.0077272247,0.7602247933398032,-0.15954859103125976,0.080024245100983812,-0.29368949622438034,-0.073865451433667934,0.0826089237639724,-0.0016331914241466544 +0.90852713500000015,76.818180040900003,83.870498685499996,-10.033959214599999,1.3029353544188775,0.28724406170807942,0.014212927420526449,0.40655298734744716,-0.023647324283988479,-0.17453959128356644,-0.073960850954690599 +0.94128642240000016,-17.142502968399999,111.655541001,-9.7340212099999999,-0.20098181727346828,-0.22648210231579227,0.1707583681860613,-0.58048422329612825,-0.053488351282702018,0.13027221406743086,0.25308132341089784 +1.1367201991999851,62.089310677199997,49.954744964900001,-34.088526625199997,0.19377826830578435,0.091880137393983541,0.1223510468724346,0.028893225229507616,-0.056763531340962091,-0.046270668648265172,0.038185275980553948 +1.597389146399949,90.835244319899999,-68.715067336700002,-24.275588970899999,1.068425514756459,0.12918206418169056,-0.098239838644788705,-0.21413237308721875,-0.0068206643108513097,0.0087572485217774342,-0.026958404243894028 +2.0719116807999476,-95.443218738699997,10.795843549900001,-30.263179721699998,0.94971164108743389,0.23885634825720128,-0.024062595318653752,0.010314618703632351,0.03936865952589351,0.026781158428063591,-0.0082917352182394783 +2.1871542456999875,83.944400991899997,49.430170316900004,-33.745468373999998,1.0756767385390711,0.017054105714452646,0.098658520422982399,0.04844359113243972,-0.063574230267828061,-0.089874003333895766,-0.0084693488765218566 +2.3681050659999801,-25.457440168200002,-107.5481933456,-20.4232819257,0.32800066739109063,0.049102906664738898,0.14392442630567165,-0.047811259763716002,0.047466150798345638,0.089613025081260261,0.079024908814773531 +2.8420116374999478,-4.8758583571000003,43.172200133799997,-31.330905317799999,1.0426025850056049,-0.082519658920334096,-0.14300218032536544,-0.013875237671252805,0.023095112496890932,0.065847448179976509,-0.02017531819244256 +2.9272074900999905,-84.879703258000006,-31.612359566599999,-18.397768531800001,1.1032479195038283,0.32303973488096754,0.027390513860167259,0.15531360457812468,0.0010363193121072705,0.0032183154573826233,-0.014123205913095779 +2.9816309065999937,47.6223227873,21.528068826999998,-19.956089198299999,0.0073925461148624458,-0.11225237965009256,0.11087773048049371,0.033793686367833045,0.0015511724320952051,-0.087299489711745573,0.11169952546953041 +2.9840122301999998,-49.925132162899999,40.687987474000003,-40.5108441016,0.96427341201692318,-0.028330239179394777,-0.080096621398929438,0.0040298380011980746,0.027580721248794283,0.077324665950130989,-0.010393242101244309 +3.1398600202999831,89.0733959505,40.590599075,-25.976040983000001,1.0931217360187637,-0.064903258123395521,0.05117111262183327,0.028227174531857503,-0.045839731189184579,-0.12898459543832116,-0.022616002944165988 +3.742438334799933,-53.9101495325,5.5319461262000003,-28.2362404189,2.0031306469017465,0.064917831899289694,-0.087698128797432107,0.097019128756559797,-0.00045637262679477307,0.051354905690258336,-0.099841097851714175 +3.9122819049999813,14.8343324954,13.136983791600001,-14.6139463227,0.77635351587265178,-0.095473788386578703,-0.057692841295813785,0.13204246554921414,0.12842438170895912,-0.053681828658230679,-0.015956104360285623 +4.3062802041000916,-24.082506928699999,-72.444068732800005,-41.2102877162,1.4853676103885447,0.022299546489968528,0.041135415195587229,0.040824811862157258,0.066244334751953973,-0.039632944380978696,-0.018521876916435583 +4.3312694024000082,27.098278056600002,-1.1550260315,-37.7347985842,0.32517418650394703,-0.086391235888650886,-0.006870701436502643,0.032300151587738406,0.082890760709864009,-0.035775352163157997,0.02281047615559809 +4.713096651800126,-72.806582454700006,-89.901726565100006,-22.958309393899999,1.1935417174115353,0.19876113919686722,0.093741471539768931,0.0066655567961241315,-0.011594762224279045,-0.072734607139669963,-0.039870216805354186 +4.7905129814000249,-76.730990197699995,-4.6207195377000003,-27.602469659499999,1.8176058976925606,0.1104221075867783,0.011216540634493124,0.01296117331780668,-0.098113358921996158,0.046696289626768778,-0.084839488510186703 +5.167401826200126,59.246492556,8.6987821368000002,-16.390092752200001,0.26602744076339357,-0.13014478929321432,-0.10822983250397082,0.22012697762885991,0.018027664121081498,-0.18132438637958453,0.063394750929778435 +5.8216203606002184,60.533620408600001,65.785155914499995,-30.687162426499999,0.56906482430308913,-0.24881273753026723,-0.042043247629852287,-0.13934578968536784,-0.017190654070643224,0.062976644771410475,0.0029391834218828673 +5.8516293989000099,16.074689868699998,-25.520449681399999,-36.519592961199997,0.47224851811135071,0.10615556325244535,-0.013909581377304331,0.045188647504468459,0.11219577936146344,-0.02008029096886978,0.013832332669513673 +5.8679857144000058,101.0591225479,-115.2958995476,-4.9306467995999999,0.53122442393206759,-0.10136365085857404,-0.018148387859710405,-0.12431387984786844,-0.076243379603036496,0.22098597975719333,0.052979558077535932 +6.3009019979001435,-70.613988425000002,6.6599839712,-34.512450787100001,1.2077907042848384,0.0078981541478090733,0.11502593431940224,-0.063013933157145402,-0.036275020144488593,0.043297704407829977,-0.020206010263383922 +6.4854720708000615,84.216269901999993,-70.744201812,-38.676234780400002,0.39708945857437034,-0.15841351960446934,0.015851588407929905,0.07039194409148318,-0.016121505606015982,0.012068220767438397,0.021847270791199597 +6.6050006743000393,-79.443045281500005,57.901805566599997,-32.9645513643,1.7208113071599955,0.18473532048468777,-0.024150670845414893,0.023317030408501496,0.015892752587309145,-0.02560809132209425,-0.049898809857715554 +6.9621599667001179,111.4458530146,-26.712664700000001,-30.391851773999999,0.40912063160856921,-0.2407743824257349,-0.018907586978074015,0.019700009412049985,0.017908278981617304,0.018883982609223186,0.031154915495135944 +7.069650956600035,29.686254745500001,-28.6864406081,-35.320012315600003,0.9064585163633232,0.15787878716643972,-0.052473322343211531,0.096113900455676665,0.048764077235548736,-0.020645557071628722,3.5418779477682655e-05 +7.2216480814000503,25.652109122399999,98.269099144600006,-21.938392604299999,1.4986071859963108,-0.17169387056500832,-0.065103643506912889,0.1818403255945,0.16616557131603352,-0.06096774430709246,-0.09412937464796646 +7.3457715141000408,112.9341031518,-25.687093450700001,-4.7118446363000004,0.36166662499636393,-0.46699978629222627,0.0094698757519200566,-0.014609750532359342,0.12812813419542593,0.11358759760005291,0.052434988338617247 +7.3783778729000113,68.887851309300004,-36.275621483599998,-39.5811893849,0.56165939147101951,-0.067536366208613322,-0.057383929764731716,0.090153593778178012,0.041144817694670356,-0.0063229586865037705,0.001051635501535469 +7.4175668007000128,69.580194261299994,-36.475263513999998,-27.147921646,0.57726251270498907,-0.087029587180307455,-0.06037528973541028,0.2099618224456854,0.04523142119730035,-0.01012760107480461,0.0051828708908486371 +7.7327700965001052,-107.01749811099999,-4.6162631273999999,-16.269230035500001,0.80351228412341003,-0.31639296373729425,0.055992695802537726,0.051766712502043302,-0.052070246335253131,-0.089334231253165072,-0.0017450841420043536 +8.012744652100082,-31.371286537300001,-97.609893899699998,-6.9572557531000001,1.2258114450763209,0.16650422613211832,-0.16226495989851594,-0.29005294543532045,-0.010342858757003765,0.10059305695593536,-0.08094460859383748 +8.9171107044994997,-99.625254680300003,11.219377907,-2.0084697836999998,0.96048954963252176,-0.11598034930971057,-0.075141376746189051,0.012818199725523609,0.16992226980081412,-0.18957163376823963,-0.026849803974292957 +9.314638547399781,-73.553380448300004,101.1425908881,-38.823611307,0.39073062024197636,-0.0052485418776287091,0.094212861048195415,-0.016511095815423607,-0.13088265651550551,0.0047714976673098233,0.017983785244080973 +9.5626725483998634,-68.671922233900005,15.1012955711,-21.8961978496,0.86581395543153905,0.038330865745534515,-0.016638763782584609,-0.020187829650832771,0.0087817048754248624,-0.13780666063566374,-0.0041517837451947349 +9.7346027476999062,86.074063719799994,58.538449764100001,-7.1083772647999997,-0.7770742811106871,-0.034270260189710938,0.39665987853670143,0.485867951865949,0.058032526547136185,-0.078859279848147917,0.52001574037284726 diff --git a/tests/data/wavekin_mdc_mean/points3.txt b/tests/data/wavekin_mdc_mean/points3.txt new file mode 100644 index 00000000..2049952f --- /dev/null +++ b/tests/data/wavekin_mdc_mean/points3.txt @@ -0,0 +1,12 @@ +1.000000 0.000000 0.000000 -5.000000 +1.000000 10.000000 0.000000 -20.000000 +1.000000 20.000000 -5.000000 -35.000000 +1.000000 -10.000000 5.000000 -2.000000 +2.500000 0.000000 0.000000 -5.000000 +2.500000 10.000000 0.000000 -20.000000 +2.500000 20.000000 -5.000000 -35.000000 +2.500000 -10.000000 5.000000 -2.000000 +6.300000 0.000000 0.000000 -5.000000 +6.300000 10.000000 0.000000 -20.000000 +6.300000 20.000000 -5.000000 -35.000000 +6.300000 -10.000000 5.000000 -2.000000 diff --git a/tests/data/wavekin_mdc_mean/points7.txt b/tests/data/wavekin_mdc_mean/points7.txt new file mode 100644 index 00000000..86f993b4 --- /dev/null +++ b/tests/data/wavekin_mdc_mean/points7.txt @@ -0,0 +1,40 @@ +0.0149008351 79.1728498258 -89.5788048676 -4.2729514839 +0.3034600766 37.8365306096 -48.7781355865 -8.7890383547 +0.8564916714 43.8717378865 -1.7167250582 -13.0077272247 +0.9085271350 76.8181800409 83.8704986855 -10.0339592146 +0.9412864224 -17.1425029684 111.6555410010 -9.7340212100 +1.1367201992 62.0893106772 49.9547449649 -34.0885266252 +1.5973891464 90.8352443199 -68.7150673367 -24.2755889709 +2.0719116808 -95.4432187387 10.7958435499 -30.2631797217 +2.1871542457 83.9444009919 49.4301703169 -33.7454683740 +2.3681050660 -25.4574401682 -107.5481933456 -20.4232819257 +2.8420116375 -4.8758583571 43.1722001338 -31.3309053178 +2.9272074901 -84.8797032580 -31.6123595666 -18.3977685318 +2.9816309066 47.6223227873 21.5280688270 -19.9560891983 +2.9840122302 -49.9251321629 40.6879874740 -40.5108441016 +3.1398600203 89.0733959505 40.5905990750 -25.9760409830 +3.7424383348 -53.9101495325 5.5319461262 -28.2362404189 +3.9122819050 14.8343324954 13.1369837916 -14.6139463227 +4.3062802041 -24.0825069287 -72.4440687328 -41.2102877162 +4.3312694024 27.0982780566 -1.1550260315 -37.7347985842 +4.7130966518 -72.8065824547 -89.9017265651 -22.9583093939 +4.7905129814 -76.7309901977 -4.6207195377 -27.6024696595 +5.1674018262 59.2464925560 8.6987821368 -16.3900927522 +5.8216203606 60.5336204086 65.7851559145 -30.6871624265 +5.8516293989 16.0746898687 -25.5204496814 -36.5195929612 +5.8679857144 101.0591225479 -115.2958995476 -4.9306467996 +6.3009019979 -70.6139884250 6.6599839712 -34.5124507871 +6.4854720708 84.2162699020 -70.7442018120 -38.6762347804 +6.6050006743 -79.4430452815 57.9018055666 -32.9645513643 +6.9621599667 111.4458530146 -26.7126647000 -30.3918517740 +7.0696509566 29.6862547455 -28.6864406081 -35.3200123156 +7.2216480814 25.6521091224 98.2690991446 -21.9383926043 +7.3457715141 112.9341031518 -25.6870934507 -4.7118446363 +7.3783778729 68.8878513093 -36.2756214836 -39.5811893849 +7.4175668007 69.5801942613 -36.4752635140 -27.1479216460 +7.7327700965 -107.0174981110 -4.6162631274 -16.2692300355 +8.0127446521 -31.3712865373 -97.6098938997 -6.9572557531 +8.9171107045 -99.6252546803 11.2193779070 -2.0084697837 +9.3146385474 -73.5533804483 101.1425908881 -38.8236113070 +9.5626725484 -68.6719222339 15.1012955711 -21.8961978496 +9.7346027477 86.0740637198 58.5384497641 -7.1083772648 diff --git a/tests/data/wavekin_mdc_mean/water_grid.txt b/tests/data/wavekin_mdc_mean/water_grid.txt new file mode 100644 index 00000000..3bbf7f86 --- /dev/null +++ b/tests/data/wavekin_mdc_mean/water_grid.txt @@ -0,0 +1,9 @@ +--------------------- MoorDyn Waves grid File ---------------------------------- +List of grid points, in 3 blocks (x, y, z): 1 = list of coordinates, 2 = first last n +Grid points (m) +1 +-10.0 0.0 10.0 20.0 +1 +-5.0 0.0 5.0 +2 +-50.0 2.0 209 diff --git a/tests/data/wavekin_mdc_mean/wave_elevation.txt b/tests/data/wavekin_mdc_mean/wave_elevation.txt new file mode 100644 index 00000000..283569c2 --- /dev/null +++ b/tests/data/wavekin_mdc_mean/wave_elevation.txt @@ -0,0 +1,401 @@ +0.00 1.718208082664536e+00 +0.10 1.762704008141818e+00 +0.20 1.799678040328327e+00 +0.30 1.828476159110647e+00 +0.40 1.848526490966111e+00 +0.50 1.859349053471077e+00 +0.60 1.860564334648381e+00 +0.70 1.851900565627129e+00 +0.80 1.833199563982629e+00 +0.90 1.804421046001700e+00 +1.00 1.765645328660035e+00 +1.10 1.717074365958081e+00 +1.20 1.659031089073121e+00 +1.30 1.591957045165761e+00 +1.40 1.516408355236895e+00 +1.50 1.433050036771228e+00 +1.60 1.342648761633289e+00 +1.70 1.246064143417894e+00 +1.80 1.144238670830897e+00 +1.90 1.038186424339796e+00 +2.00 9.289807319658721e-01 +2.10 8.177409363994919e-01 +2.20 7.056184593534809e-01 +2.30 5.937823600111510e-01 +2.40 4.834045924039918e-01 +2.50 3.756451714433925e-01 +2.60 2.716374590530468e-01 +2.70 1.724737803750243e-01 +2.80 7.919157537351584e-02 +2.90 -7.239716594134693e-03 +3.00 -8.593085241128595e-02 +3.10 -1.560838877777113e-01 +3.20 -2.170020296438120e-01 +3.30 -2.680980999659450e-01 +3.40 -3.089014928723690e-01 +3.50 -3.390635297198259e-01 +3.60 -3.583611403300571e-01 +3.70 -3.666988235572830e-01 +3.80 -3.641088658691121e-01 +3.90 -3.507498224317589e-01 +4.00 -3.269032908791569e-01 +4.10 -2.929690331198340e-01 +4.20 -2.494585248092800e-01 +4.30 -1.969870351168534e-01 +4.40 -1.362643607936423e-01 +4.50 -6.808435797490153e-02 +4.60 6.686567667538501e-03 +4.70 8.712183713106048e-02 +4.80 1.722477534041668e-01 +4.90 2.610570396160259e-01 +5.00 3.525226577152976e-01 +5.10 4.456117024911694e-01 +5.20 5.392991660182382e-01 +5.30 6.325813710197470e-01 +5.40 7.244888781383499e-01 +5.50 8.140986815699756e-01 +5.60 9.005455197977285e-01 +5.70 9.830321430591306e-01 +5.80 1.060838396450487e+00 +5.90 1.133328996939399e+00 +6.00 1.199959903710797e+00 +6.10 1.260283203876211e+00 +6.20 1.313950459271326e+00 +6.30 1.360714484477726e+00 +6.40 1.400429550945215e+00 +6.50 1.433050036771228e+00 +6.60 1.458627565924969e+00 +6.70 1.477306704105929e+00 +6.80 1.489319300632690e+00 +6.90 1.494977586421666e+00 +7.00 1.494666156915110e+00 +7.10 1.488832985461792e+00 +7.20 1.477979626885623e+00 +7.30 1.462650782579149e+00 +7.40 1.443423407254644e+00 +7.50 1.420895543344493e+00 +7.60 1.395675071880807e+00 +7.70 1.368368568465924e+00 +7.80 1.339570449683604e+00 +7.90 1.309852589056514e+00 +8.00 1.279754572537952e+00 +8.10 1.249774751688816e+00 +8.20 1.220362238332255e+00 +8.30 1.191909967835470e+00 +8.40 1.164748939514448e+00 +8.50 1.139143722298233e+00 +8.60 1.115289292056760e+00 +8.70 1.093309244244132e+00 +8.80 1.073255402106956e+00 +8.90 1.055108817034768e+00 +9.00 1.038782134070082e+00 +9.10 1.024123272530816e+00 +9.20 1.010920349500809e+00 +9.30 9.989077529740477e-01 +9.40 9.877732520341184e-01 +9.50 9.771660139262006e-01 +9.60 9.667053825181904e-01 +9.70 9.559902596990588e-01 +9.80 9.446089209363094e-01 +9.90 9.321490886783266e-01 +10.00 9.182080826645362e-01 +10.10 9.024028645730402e-01 +10.20 8.843797958200326e-01 +10.30 8.638239317077813e-01 +10.40 8.404676824300302e-01 +10.50 8.140986815699762e-01 +10.60 7.845667155060490e-01 +10.70 7.517895823710954e-01 +10.80 7.157577666486927e-01 +10.90 6.765378348575276e-01 +11.00 6.342744787615595e-01 +11.10 5.891911548139090e-01 +11.20 5.415892917391842e-01 +11.30 4.918460619097276e-01 +11.40 4.404107360945623e-01 +11.50 3.877996648701275e-01 +11.60 3.345899530972076e-01 +11.70 2.814119160150286e-01 +11.80 2.289404263226011e-01 +11.90 1.778852807710176e-01 +12.00 1.289807319658718e-01 +12.10 8.297434599526476e-02 +12.20 4.061535890955470e-02 +12.30 2.642714777733812e-03 +12.40 -3.022702513217346e-02 +12.50 -5.731170867356528e-02 +12.60 -7.797536050600984e-02 +12.70 -9.163949933549098e-02 +12.80 -9.779381829246447e-02 +12.90 -9.600605041001264e-02 +13.00 -8.593085241128573e-02 +13.10 -6.731755396183348e-02 +13.20 -4.001663597783311e-02 +13.30 -3.984820255430233e-03 +13.40 4.071132668668831e-02 +13.50 9.389335039713065e-02 +13.60 1.552704772061088e-01 +13.70 2.244408216761339e-01 +13.80 3.008942345748143e-01 +13.90 3.840167679724664e-01 +14.00 4.730967091208432e-01 +14.10 5.673321104489444e-01 +14.20 6.658397196990158e-01 +14.30 7.676651922860136e-01 +14.40 8.717944477424382e-01 +14.50 9.771660139262001e-01 +14.60 1.082684186809871e+00 +14.70 1.187232820387095e+00 +14.80 1.289689550738103e+00 +14.90 1.388940250760198e+00 +15.00 1.483893507613773e+00 +15.10 1.573494913635342e+00 +15.20 1.656740963352174e+00 +15.30 1.732692354275780e+00 +15.40 1.800486497280682e+00 +15.50 1.859349053471077e+00 +15.60 1.908604328333810e+00 +15.70 1.947684370461996e+00 +15.80 1.976136640958781e+00 +15.90 1.993630140508177e+00 +16.00 1.999959903710797e+00 +16.10 1.995049794280437e+00 +16.20 1.978953559715253e+00 +16.30 1.951854129711143e+00 +16.40 1.914061168481379e+00 +16.50 1.866006916888186e+00 +16.60 1.808240385484025e+00 +16.70 1.741419983816445e+00 +16.80 1.666304694298670e+00 +16.90 1.583743920237542e+00 +17.00 1.494666156915110e+00 +17.10 1.400066651645913e+00 +17.20 1.300994233219645e+00 +17.30 1.198537502868634e+00 +17.40 1.093810587695587e+00 +17.50 9.879386632275369e-01 +17.60 8.820434543446402e-01 +17.70 7.772289232325095e-01 +17.80 6.745673492396782e-01 +17.90 5.750859986522876e-01 +18.00 4.797545725379525e-01 +18.10 3.894736081200377e-01 +18.20 3.050639938239620e-01 +18.30 2.272577404326039e-01 +18.40 1.566901309783664e-01 +18.50 9.389335039713143e-02 +18.60 3.929167291442792e-02 +18.70 -6.801739011900620e-03 +18.80 -4.418639522697965e-02 +18.90 -7.277439410940423e-02 +19.00 -9.258871582839379e-02 +19.10 -1.037599386133559e-01 +19.20 -1.065214478331279e-01 +19.30 -1.012032302819853e-01 +19.40 -8.822436710821391e-02 +19.50 -6.808435797490098e-02 +19.60 -4.135342601788983e-02 +19.70 -8.661967703806561e-03 +19.80 2.931067642801466e-02 +19.90 7.184794510954795e-02 +20.00 1.182080826645358e-01 +20.10 1.676362741688131e-01 +20.20 2.193766953761087e-01 +20.30 2.726842864743650e-01 +20.40 3.268360648938661e-01 +20.50 3.811418014530171e-01 +20.60 4.349538959469935e-01 +20.70 4.876763026605828e-01 +20.80 5.387723729827130e-01 +20.90 5.877715010416499e-01 +21.00 6.342744787615591e-01 +21.10 6.779574886297879e-01 +21.20 7.185746854051641e-01 +21.30 7.559593416202430e-01 +21.40 7.900235556536204e-01 +21.50 8.207565449870841e-01 +21.60 8.482215706333733e-01 +21.70 8.725515612484467e-01 +21.80 8.939435267665271e-01 +21.90 9.126518711752442e-01 +22.00 9.289807319658714e-01 +22.10 9.432754895640439e-01 +22.20 9.559136034178499e-01 +22.30 9.672949421806001e-01 +22.40 9.778317834039071e-01 +22.50 9.879386632275360e-01 +22.60 9.980222586363209e-01 +22.70 1.008471483920542e+00 +22.80 1.019647979041473e+00 +22.90 1.031877160734159e+00 +23.00 1.045439997487189e+00 +23.10 1.060565657182338e+00 +23.20 1.077425161356104e+00 +23.30 1.096126163000603e+00 +23.40 1.116708945829020e+00 +23.50 1.139143722298233e+00 +23.60 1.163329285742189e+00 +23.70 1.189093049079000e+00 +23.80 1.216192479083108e+00 +23.90 1.244317911541246e+00 +24.00 1.273096709120843e+00 +24.10 1.302098700853171e+00 +24.20 1.330842820142941e+00 +24.30 1.358804837519430e+00 +24.40 1.385426065278605e+00 +24.50 1.410122894043157e+00 +24.60 1.432297006368927e+00 +24.70 1.451346100097609e+00 +24.80 1.466674944404083e+00 +24.90 1.477706584576075e+00 +25.00 1.483893507613774e+00 +25.10 1.484728579819463e+00 +25.20 1.479755569686195e+00 +25.30 1.468579074565264e+00 +25.40 1.450873677721624e+00 +25.50 1.426392173354119e+00 +25.60 1.394972710797645e+00 +25.70 1.356544725228577e+00 +25.80 1.311133540514855e+00 +25.90 1.258863550103950e+00 +26.00 1.199959903710796e+00 +26.10 1.134748650711657e+00 +26.20 1.063655315206956e+00 +26.30 9.872019023082792e-01 +26.40 9.060023599453010e-01 +26.50 8.207565449870852e-01 +26.60 7.322427663416944e-01 +26.70 6.413090005604112e-01 +26.80 5.488628969647337e-01 +26.90 4.558607090933731e-01 +27.00 3.632953070166364e-01 +27.10 2.721834405017455e-01 +27.20 1.835524358857059e-01 +27.30 9.842651961260440e-02 +27.40 1.781296855325532e-02 +27.50 -5.731170867356516e-02 +27.60 -1.260153541914385e-01 +27.70 -1.874233041703577e-01 +27.80 -2.407308952686149e-01 +27.90 -2.852151449164910e-01 +28.00 -3.202454274620470e-01 +28.10 -3.452929822841879e-01 +28.20 -3.599391066199640e-01 +28.30 -3.638819048008120e-01 +28.40 -3.569414865577969e-01 +28.50 -3.390635297198270e-01 +28.60 -3.103211466446281e-01 +28.70 -2.709150187224150e-01 +28.80 -2.211717888929610e-01 +28.90 -1.615407279252827e-01 +29.00 -9.258871582839556e-02 +29.10 -1.499360479747935e-02 +29.20 7.046394583285243e-02 +29.30 1.629100494285302e-01 +29.40 2.613884524508442e-01 +29.50 3.648725221420525e-01 +29.60 4.722781915182733e-01 +29.70 5.824776775296143e-01 +29.80 6.943137768719386e-01 +29.90 8.066145355137733e-01 +30.00 9.182080826645320e-01 +30.10 1.027937417737594e+00 +30.20 1.134674939884403e+00 +30.30 1.237336513877227e+00 +30.40 1.334894873429943e+00 +30.50 1.426392173354119e+00 +30.60 1.510951515089324e+00 +30.70 1.587787285916614e+00 +30.80 1.656214170316650e+00 +30.90 1.715654712185822e+00 +31.00 1.765645328660034e+00 +31.10 1.805840699773959e+00 +31.20 1.836016482739101e+00 +31.30 1.856070324876276e+00 +31.40 1.866021174795951e+00 +31.50 1.866006916888186e+00 +31.60 1.856280379169455e+00 +31.70 1.837203788651312e+00 +31.80 1.809241771274823e+00 +31.90 1.772953014744021e+00 +32.00 1.728980731965874e+00 +32.10 1.678042079968272e+00 +32.20 1.620916703861778e+00 +32.30 1.558434587414016e+00 +32.40 1.491463400940076e+00 +32.50 1.420895543344497e+00 +32.60 1.347635078195379e+00 +32.70 1.272584763631057e+00 +32.80 1.196633372707450e+00 +32.90 1.120643494550039e+00 +33.00 1.045439997487191e+00 +33.10 9.717993233664612e-01 +33.20 9.004397676901231e-01 +33.30 8.320128832900872e-01 +33.40 7.670961262699646e-01 +33.50 7.061868421812761e-01 +33.60 6.496976682060241e-01 +33.70 5.979534038455805e-01 +33.80 5.511893786391809e-01 +33.90 5.095513211370195e-01 +34.00 4.730967091208418e-01 +34.10 4.417975572843921e-01 +34.20 4.155445756346444e-01 +34.30 3.941526101165623e-01 +34.40 3.773672567425205e-01 +34.50 3.648725221420565e-01 +34.60 3.562993872265944e-01 +34.70 3.512351168415740e-01 +34.80 3.492331470701444e-01 +34.90 3.498233734319032e-01 +35.00 3.525226577152972e-01 +35.10 3.568453686752899e-01 +35.20 3.623137723522581e-01 +35.30 3.684680913092310e-01 +35.40 3.748760585792926e-01 +35.50 3.811418014530182e-01 +35.60 3.869139022615631e-01 +35.70 3.918924978257127e-01 +35.80 3.958352960065604e-01 +35.90 3.985624065351722e-01 +36.00 3.999599037107969e-01 +36.10 3.999820603074322e-01 +36.20 3.986522147630324e-01 +36.30 3.960622570748610e-01 +36.40 3.923707424091348e-01 +36.50 3.877996648701282e-01 +36.60 3.826299467826382e-01 +36.70 3.771957208498978e-01 +36.80 3.718775032987539e-01 +36.90 3.670943752774961e-01 +37.00 3.632953070166355e-01 +37.10 3.609497743176229e-01 +37.20 3.605378295516872e-01 +37.30 3.625397993231170e-01 +37.40 3.674257881123139e-01 +37.50 3.756451714433943e-01 +37.60 3.876162633447260e-01 +37.70 4.037163410630595e-01 +37.80 4.242722051753109e-01 +37.90 4.495514454877368e-01 +38.00 4.797545725379507e-01 +38.10 5.150081612845899e-01 +38.20 5.553591378883311e-01 +38.30 6.007703226020549e-01 +38.40 6.511173219782851e-01 +38.50 7.061868421812745e-01 +38.60 7.656764724977027e-01 +38.70 8.291959645336169e-01 +38.80 8.962700084409769e-01 +38.90 9.663424832188934e-01 +39.00 1.038782134070079e+00 +39.10 1.112889606346691e+00 +39.20 1.187905743166789e+00 +39.30 1.263021032684563e+00 +39.40 1.337386071593176e+00 +39.50 1.410122894043157e+00 +39.60 1.480337000054355e+00 +39.70 1.547129904932475e+00 +39.80 1.609612021380234e+00 +39.90 1.666915679082554e+00 +40.00 1.718208082664534e+00 diff --git a/tests/data/wavekin_mdc_mean/wave_frequencies.txt b/tests/data/wavekin_mdc_mean/wave_frequencies.txt new file mode 100644 index 00000000..50223d20 --- /dev/null +++ b/tests/data/wavekin_mdc_mean/wave_frequencies.txt @@ -0,0 +1,7 @@ +0.000000 0.000000 0.800000 0.000000 +0.450000 -0.360000 -0.360000 0.300000 +0.550000 0.180000 -0.140000 2.400000 +0.650000 0.080000 -0.470000 -1.000000 +0.650000 0.300000 0.200000 3.700000 +0.800000 -0.200000 0.250000 5.100000 +1.100000 0.100000 0.120000 -2.200000 diff --git a/tests/data/wavekin_mdc_mean/wavekin3.dat b/tests/data/wavekin_mdc_mean/wavekin3.dat new file mode 100644 index 00000000..ea3b2947 --- /dev/null +++ b/tests/data/wavekin_mdc_mean/wavekin3.dat @@ -0,0 +1,27 @@ +--------------------- MoorDyn Input File ------------------------------------ +Mean-level water-kinematics parity deck (MoorDyn-C WaveKin 3, Currents 0) +----------------------- LINE TYPES ------------------------------------------ +TypeName Diam Mass/m EA BA/-zeta EI Cd Ca CdAx CaAx +(name) (m) (kg/m) (N) (N-s/-) (N-m^2) (-) (-) (-) (-) +chain 0.1 50.0 1.0e7 0.0 0 1.2 1.0 0.2 0.0 +---------------------- POINT PROPERTIES -------------------------------- +ID Type X Y Z Mass Volume CdA Ca +(#) (-) (m) (m) (m) (kg) (m^3) (m^2) (-) +1 Fixed -30.0 0.0 -20.0 0 0 0 0 +2 Vessel 30.0 0.0 -20.0 0 0 0 0 +---------------------- LINES ---------------------------------------- +ID LineType AttachA AttachB UnstrLen NumSegs LineOutputs +(#) (name) (#) (#) (m) (-) (-) +1 chain 2 1 70.0 20 - +---------------------- OPTIONS ----------------------------------------- +0.002 dtM time step to use in mooring integration (s) +10.0 TMax simulation length (s) +3.0e6 kBot bottom stiffness (Pa/m) +3.0e5 cBot bottom damping (Pa-s/m) +1025.0 WtrDnsty water density (kg/m^3) +50 WtrDpth water depth (m) +0.0 TmaxIC max time for ic gen (s) +3 WaveKin MoorDyn-C water kinematics mode (-) +0.1 dtWave wave time step (s) +0 Currents MoorDyn-C current mode (-) +------------------------- need this line -------------------------------------- diff --git a/tests/data/wavekin_mdc_mean/wavekin7.dat b/tests/data/wavekin_mdc_mean/wavekin7.dat new file mode 100644 index 00000000..20a6f40b --- /dev/null +++ b/tests/data/wavekin_mdc_mean/wavekin7.dat @@ -0,0 +1,27 @@ +--------------------- MoorDyn Input File ------------------------------------ +Mean-level water-kinematics parity deck (MoorDyn-C WaveKin 7, Currents 0) +----------------------- LINE TYPES ------------------------------------------ +TypeName Diam Mass/m EA BA/-zeta EI Cd Ca CdAx CaAx +(name) (m) (kg/m) (N) (N-s/-) (N-m^2) (-) (-) (-) (-) +chain 0.1 50.0 1.0e7 0.0 0 1.2 1.0 0.2 0.0 +---------------------- POINT PROPERTIES -------------------------------- +ID Type X Y Z Mass Volume CdA Ca +(#) (-) (m) (m) (m) (kg) (m^3) (m^2) (-) +1 Fixed -30.0 0.0 -20.0 0 0 0 0 +2 Vessel 30.0 0.0 -20.0 0 0 0 0 +---------------------- LINES ---------------------------------------- +ID LineType AttachA AttachB UnstrLen NumSegs LineOutputs +(#) (name) (#) (#) (m) (-) (-) +1 chain 2 1 70.0 20 - +---------------------- OPTIONS ----------------------------------------- +0.002 dtM time step to use in mooring integration (s) +10.0 TMax simulation length (s) +3.0e6 kBot bottom stiffness (Pa/m) +3.0e5 cBot bottom damping (Pa-s/m) +1025.0 WtrDnsty water density (kg/m^3) +50 WtrDpth water depth (m) +0.0 TmaxIC max time for ic gen (s) +7 WaveKin MoorDyn-C water kinematics mode (-) +0.1 dtWave wave time step (s) +0 Currents MoorDyn-C current mode (-) +------------------------- need this line -------------------------------------- diff --git a/tests/test_bend_stiffener.f90 b/tests/test_bend_stiffener.f90 index f4fcc374..c7a9b6b6 100644 --- a/tests/test_bend_stiffener.f90 +++ b/tests/test_bend_stiffener.f90 @@ -11,15 +11,21 @@ PROGRAM test_bend_stiffener !! for bit. !! 2. A cone: the element values follow a 20 000-interval midpoint integration of the same !! averages to 1e-10. - !! 3. Deck grammar: malformed rows, a stiffener on an EI = 0 line and stiffeners longer than + !! 3. The stiffness at the nodes (CD_Stiffener_Node_EI) against closed forms: both limits are + !! the same number where EI(s) is continuous (a cone, a junction of rows that match) and + !! differ only at a real step: the bare tip, rows that do not match, two line types. A tip + !! inside an element leaves the node past it bare; End B mirrors End A; a tip on a node + !! whose arc sums off it by round-off is still on it. + !! 4. Deck grammar: malformed rows, a stiffener on an EI = 0 line and stiffeners longer than !! the line fail closed by name; on a stock-order LINES row the stiffener sleeves the same !! physical end, and the static profile is the native deck's; StfMom on a line without a !! stiffener and AnchDefl at a pinned end fail closed, FairDefl at the clamped end runs. - !! 4. Coupled route (the OpenFAST aggregate): the cable carries the stiffened root element, - !! and its initial fairlead load is the standalone static solve's. + !! 5. Coupled route (the OpenFAST aggregate): the cable carries the stiffened root element and + !! the node stiffness on the two sides of the tip, its initial fairlead load is the + !! standalone static solve's, and a line without a stiffener has no node stiffness. !! The static equilibrium against the planar elastica is tests/check_bend_stiffener.py. USE CableDyn_Precision, ONLY: wp - USE CableDyn_DeckDriver, ONLY: CD_Run_Deck_Driver, CD_DECKDRV_OK, CD_Stiffener_Overlay + USE CableDyn_DeckDriver, ONLY: CD_Run_Deck_Driver, CD_DECKDRV_OK, CD_Stiffener_Overlay, CD_Stiffener_Node_EI USE CableDyn_OpenFAST_Aggregate, ONLY: CD_AGG_ModuleType, CD_AGG_Init_From_Deck, CD_AGG_GetMovingPointMesh, & CD_AGG_End, CD_AGG_OK IMPLICIT NONE @@ -30,6 +36,7 @@ PROGRAM test_bend_stiffener CALL case_cylinder() CALL case_cone() + CALL case_node_stiffness() CALL case_grammar() CALL case_coupled() @@ -136,6 +143,65 @@ SUBROUTINE case_cone() 'cone: bare elements unchanged') END SUBROUTINE case_cone + SUBROUTINE case_node_stiffness() + !! Ten 1 m elements, line type EI_C on the first six and 2 EI_C beyond, under a cylinder over + !! 0-2 m (OD 0.6), a cone over 2-5 m (0.6 -> 0.3) and a cone over 5-7.5 m (0.25 -> 0.22), + !! whose tip lies inside element 8. + REAL(wp), PARAMETER :: EI_C = 2.0e4_wp, ID = 0.2_wp, EMOD = 5.0e7_wp, RHO = 1150.0_wp + REAL(wp), PARAMETER :: TOL = 1.0e-13_wp + REAL(wp) :: stf(8, 3), l0(10), eic(10), en(2, 11), eb(2, 11), od7, tip(8, 1), l1(40), e1(40), n1(2, 41) + INTEGER :: j + l0 = 1.0_wp + eic(1:6) = EI_C + eic(7:10) = 2.0_wp*EI_C + stf(:, 1) = [1.0_wp, 0.0_wp, 2.0_wp, 0.6_wp, 0.6_wp, ID, EMOD, RHO] + stf(:, 2) = [1.0_wp, 2.0_wp, 5.0_wp, 0.6_wp, 0.3_wp, ID, EMOD, RHO] + stf(:, 3) = [1.0_wp, 5.0_wp, 7.5_wp, 0.25_wp, 0.22_wp, ID, EMOD, RHO] + CALL CD_Stiffener_Node_EI(stf, l0, eic, en) + CALL require(en(1, 1) == 0.0_wp .AND. en(2, 11) == 0.0_wp, 'node EI: no limit beyond a line end') + CALL require(close_rel(en(2, 1), EI_C + sleeve(0.6_wp), TOL), 'node EI: the root') + CALL require(en(1, 3) == en(2, 3) .AND. close_rel(en(1, 3), EI_C + sleeve(0.6_wp), TOL), & + 'node EI: rows that match are continuous, to the bit') + CALL require(en(1, 4) == en(2, 4) .AND. close_rel(en(1, 4), EI_C + sleeve(0.5_wp), TOL), & + 'node EI: EI(s) at a node inside a cone') + CALL require(close_rel(en(1, 6), EI_C + sleeve(0.3_wp), TOL) .AND. & + close_rel(en(2, 6), EI_C + sleeve(0.25_wp), TOL), & + 'node EI: a step between rows keeps both sides') + od7 = 0.25_wp + 0.4_wp*(0.22_wp - 0.25_wp) + CALL require(close_rel(en(1, 7), EI_C + sleeve(od7), TOL) .AND. & + close_rel(en(2, 7), 2.0_wp*EI_C + sleeve(od7), TOL), & + 'node EI: a junction of line types keeps both sides') + CALL require(en(1, 9) == 2.0_wp*EI_C .AND. en(2, 9) == 2.0_wp*EI_C, & + 'node EI: the node past a tip inside an element is bare') + CALL require(en(1, 11) == 2.0_wp*EI_C, 'node EI: the bare line end') + ! the same stiffener from End B, on the mirrored line: the limits mirror, each side swapping + stf(1, :) = 2.0_wp + CALL CD_Stiffener_Node_EI(stf, l0, eic(10:1:-1), eb) + DO j = 1, 11 + CALL require(close_rel(eb(1, 12 - j), en(2, j), TOL) .AND. close_rel(eb(2, 12 - j), en(1, j), TOL), & + 'node EI: End B mirrors End A') + END DO + ! a section without bending stiffness: the sleeve alone, and nothing past it + CALL CD_Stiffener_Node_EI(stf(:, 1:1), l0, [(0.0_wp, j=1, 10)], eb) + CALL require(close_rel(eb(1, 10), sleeve(0.6_wp), TOL) .AND. eb(1, 9) == 0.0_wp .AND. eb(2, 1) == 0.0_wp, & + 'node EI: an EI = 0 section takes the sleeve alone') + ! a 3 m tip off node 31 of forty 0.1 m elements by round-off, either way, is on the node + l1 = 0.1_wp + e1 = EI_C + DO j = -1, 1, 2 + tip(:, 1) = [1.0_wp, 0.0_wp, 3.0_wp*(1.0_wp + REAL(j, wp)*1.0e-14_wp), 0.5_wp, 0.3_wp, ID, EMOD, RHO] + CALL CD_Stiffener_Node_EI(tip, l1, e1, n1) + CALL require(close_rel(n1(1, 31), EI_C + sleeve(0.3_wp), 1.0e-12_wp) .AND. n1(2, 31) == EI_C, & + 'node EI: a tip on a node is a step there, whatever the round-off of the arc') + END DO + END SUBROUTINE case_node_stiffness + + PURE REAL(wp) FUNCTION sleeve(od) + !! Bending stiffness of the 50 MPa sleeve of case_node_stiffness, bored to 0.2 m. + REAL(wp), INTENT(IN) :: od + sleeve = 5.0e7_wp*PI/64.0_wp*(od**4 - 0.2_wp**4) + END FUNCTION sleeve + SUBROUTINE write_deck(path, stiffener_rows, ei, stock, channel) !! A 60 m cable clamped horizontal at a 20 m deep hang-off and fixed 30 m lower; stock: the !! 7-column LINES row written anchor first, so the parser swaps the ends. @@ -257,6 +323,7 @@ SUBROUTINE case_grammar() CALL require(na == nb .AND. na > 0, 'grammar: both decks write a static profile') IF (na == nb .AND. na > 0) THEN CALL require(MAXVAL(ABS(pa(2:4, :na) - pb(2:4, :nb))) < 1.0e-6_wp .AND. & + MAXVAL(ABS(pa(6, :na) - pb(6, :nb))) < 1.0e-6_wp*MAXVAL(ABS(pa(6, :na))) .AND. & MAXVAL(ABS(pa(7, :na) - pb(7, :nb))) < 1.0e-6_wp*MAXVAL(ABS(pa(7, :na))), & 'grammar: on a stock-order row the stiffener sleeves the same physical end') END IF @@ -309,14 +376,16 @@ END SUBROUTINE case_grammar SUBROUTINE case_coupled() !! The native deck of case_grammar on the coupled route: its root element, wholly inside the - !! 0.5 m cylindrical root (OD 0.6 m, ID 0.2 m), has EI_c + E pi/64 (OD^4 - ID^4), and the - !! initial fairlead load matches the standalone static fairlead tension. + !! 0.5 m cylindrical root (OD 0.6 m, ID 0.2 m), has EI_c + E pi/64 (OD^4 - ID^4), the 5 m tip + !! is a step between its two node limits, and the initial fairlead load matches the + !! standalone static fairlead tension. Without a stiffener the cable has no node stiffness. TYPE(CD_AGG_ModuleType), SAVE :: agg REAL(wp), PARAMETER :: EI_ROOT = 2.0e4_wp + 5.0e7_wp*PI/64.0_wp*(0.6_wp**4 - 0.2_wp**4) + REAL(wp), PARAMETER :: EI_TIP = 2.0e4_wp + 5.0e7_wp*PI/64.0_wp*(0.22_wp**4 - 0.2_wp**4) REAL(wp) :: r0(3, 1), vel(3, 1), acc(3, 1), load(3, 1) REAL(wp), ALLOCATABLE :: prof(:, :) CHARACTER(512) :: em - INTEGER :: es, ne, n + INTEGER :: es, ne, n, tip CALL CD_AGG_Init_From_Deck(agg, 'stf_native.dat', 0.05_wp, es, em) CALL require(es == CD_AGG_OK, 'coupled: the stiffener deck initializes: '//TRIM(em)) IF (es /= CD_AGG_OK) RETURN @@ -324,11 +393,26 @@ SUBROUTINE case_coupled() ne = SIZE(agg%cables(1)%line%EI) CALL require(close_rel(agg%cables(1)%line%EI(ne), EI_ROOT, 1.0e-12_wp), & 'coupled: the root element carries the stiffener') + ! the tip, 5 m from End A on 0.5 m elements: the internal element before the node is bare + tip = ne + 1 - 10 + CALL require(ALLOCATED(agg%cables(1)%line%EI_node), 'coupled: the cable has the node stiffness') + IF (ALLOCATED(agg%cables(1)%line%EI_node)) THEN + CALL require(close_rel(agg%cables(1)%line%EI_node(1, ne + 1), EI_ROOT, 1.0e-12_wp), & + 'coupled: the stiffness at the root node, on its element') + CALL require(agg%cables(1)%line%EI_node(1, tip) == 2.0e4_wp .AND. & + close_rel(agg%cables(1)%line%EI_node(2, tip), EI_TIP, 1.0e-12_wp), & + 'coupled: the two limits at the tip, each on its own side') + END IF CALL CD_AGG_GetMovingPointMesh(agg, r0, vel, acc, load, es, em) CALL read_static('stf_native.static.out', prof, n) IF (n > 0) CALL require(close_rel(NORM2(load(:, 1)), prof(5, 1), 1.0e-6_wp), & 'coupled: the initial fairlead load is the standalone static tension') CALL CD_AGG_End(agg, es, em) + CALL CD_AGG_Init_From_Deck(agg, 'stf_none.dat', 0.05_wp, es, em) + CALL require(es == CD_AGG_OK, 'coupled: the deck without a stiffener initializes: '//TRIM(em)) + IF (es /= CD_AGG_OK) RETURN + CALL require(.NOT. ALLOCATED(agg%cables(1)%line%EI_node), 'coupled: no stiffener, no node stiffness') + CALL CD_AGG_End(agg, es, em) END SUBROUTINE case_coupled END PROGRAM test_bend_stiffener diff --git a/tests/test_c_api.f90 b/tests/test_c_api.f90 index 03decc8b..f324badb 100644 --- a/tests/test_c_api.f90 +++ b/tests/test_c_api.f90 @@ -27,6 +27,7 @@ PROGRAM test_c_api CALL case_deck_init() CALL case_deck_dynamic_point_fluid() CALL case_deck_current_in_time() + CALL case_deck_range_flag() CALL case_deck_bodies_rods() CALL case_init_update_output_step() CALL case_multiline_shared_map() @@ -58,7 +59,7 @@ SUBROUTINE case_version() abi_version = -1_C_INT msg = 'x' CALL CableDyn_GetVersion(major, minor, patch, abi_version) - CALL require(major == 0_C_INT .AND. minor == 4_C_INT .AND. patch == 0_C_INT .AND. abi_version == 1_C_INT, & + CALL require(major == 0_C_INT .AND. minor == 4_C_INT .AND. patch == 1_C_INT .AND. abi_version == 1_C_INT, & 'c-api-version:tuple') CALL CableDyn_GetVersionString(C_LOC(msg), INT(SIZE(msg), C_INT)) CALL require(INDEX(c_text(msg), 'CableDyn') == 1 .AND. INDEX(c_text(msg), 'C-ABI 1') > 0, & @@ -233,11 +234,41 @@ SUBROUTINE case_deck_current_in_time() CALL require(es == 0_C_INT .AND. .NOT. C_ASSOCIATED(handle), 'c-api-currents2:close') END SUBROUTINE case_deck_current_in_time - SUBROUTINE write_capi_currents_deck(path, row_later) + SUBROUTINE case_deck_range_flag() + !! The C ABI writes no result files: the LINES range-graph flag is refused by name, not + !! accepted and left without a file. + CHARACTER(*), PARAMETER :: DECK_PATH = 'capi_range_flag_deck.dat' + CHARACTER(KIND=C_CHAR), TARGET :: c_path(LEN(DECK_PATH)) + CHARACTER(KIND=C_CHAR), TARGET :: msg(300) + TYPE(C_PTR) :: handle + INTEGER(C_INT) :: es + INTEGER :: i + + DO i = 1, LEN(DECK_PATH) + c_path(i) = DECK_PATH(i:i) + END DO + CALL write_capi_currents_deck(DECK_PATH, '0.1 0.1 0.0 0.0', line_outputs='r') + CALL CableDyn_Create(handle, es) + CALL require(es == 0_C_INT .AND. C_ASSOCIATED(handle), 'c-api-range-flag:create') + CALL CableDyn_InitDeck(handle, C_LOC(c_path), INT(LEN(DECK_PATH), C_INT), es) + CALL require(es == 3_C_INT, 'c-api-range-flag:rejected') + CALL CableDyn_GetLastError(handle, C_LOC(msg), INT(SIZE(msg), C_INT)) + CALL require(INDEX(c_text(msg), 'flag r') > 0 .AND. INDEX(c_text(msg), 'C API') > 0, 'c-api-range-flag:message') + CALL CableDyn_Close(handle, es) + CALL require(es == 0_C_INT .AND. .NOT. C_ASSOCIATED(handle), 'c-api-range-flag:close') + END SUBROUTINE case_deck_range_flag + + SUBROUTINE write_capi_currents_deck(path, row_later, line_outputs) !! The smoke deck with Currents 2 and its current_profile_dynamic.txt: x velocities - !! 0.1 m/s at the two depths at t = 0 and the given row at t = 5 s. + !! 0.1 m/s at the two depths at t = 0 and the given row at t = 5 s. line_outputs gives the + !! LINES Outputs flags. CHARACTER(*), INTENT(IN) :: path, row_later + CHARACTER(*), INTENT(IN), OPTIONAL :: line_outputs INTEGER :: u, ios + CHARACTER(8) :: flags + + flags = '-' + IF (PRESENT(line_outputs)) flags = line_outputs OPEN (NEWUNIT=u, FILE='current_profile_dynamic.txt', STATUS='REPLACE', ACTION='WRITE', IOSTAT=ios) CALL require(ios == 0, 'c-api-currents2:create-table') @@ -267,7 +298,7 @@ SUBROUTINE write_capi_currents_deck(path, row_later) WRITE (u, '(A)') '--- LINES ---' WRITE (u, '(A)') 'ID NodeA NodeB Outputs' WRITE (u, '(A)') '(-) (-) (-) (-)' - WRITE (u, '(A)') '1 2 1 -' + WRITE (u, '(A)') '1 2 1 '//TRIM(flags) WRITE (u, '(A)') '--- SECTIONS ---' WRITE (u, '(A)') 'LineID LineType Length NumSegs' WRITE (u, '(A)') '(-) (-) (m) (-)' diff --git a/tests/test_c_api_host_rotation.c b/tests/test_c_api_host_rotation.c index 963e5fb6..f8058cab 100644 --- a/tests/test_c_api_host_rotation.c +++ b/tests/test_c_api_host_rotation.c @@ -25,6 +25,7 @@ * - a handle initialised without rotations refuses CableDyn_UpdateRotations; * the EI = 0 point route (box_points.dat) takes rotations and ignores them, * and its coupled moments are zero. + * - the host fluid calls of extension 4 link and run through the public header. * The decks this test writes are removed on every exit path. */ #include "CableDyn_CAPI.h" @@ -381,6 +382,41 @@ static int check_point_route(void) return failed; } +static int check_host_fluid(void) +{ + enum { MAX_NODES = NSEG + 1 }; + static double xyz[3 * MAX_NODES], vel[3 * MAX_NODES], acc[3 * MAX_NODES], eta[MAX_NODES]; + const double q[3] = {0.0, 0.0, -50.0}, z[3] = {0.0, 0.0, 0.0}; + bool converged = false, stalled = false; + void *h = NULL; + int err = -1, failed = 1, n, n_iter = 0; + CHECK(CableDyn_GetAbiMinor() >= 4, "the library declares ABI minor 4"); + CableDyn_Create(&h, &err); + REQUIRE(err == CD_C_OK, "create"); + CableDyn_SetHostFluid(h, 1, &err); + REQUIRE(err == CD_C_OK, "SetHostFluid"); + CableDyn_InitDeckRotating(h, TORSION_DECK, (int)strlen(TORSION_DECK), NULL, 0, &err); + REQUIRE(err == CD_C_OK, "InitDeckRotating with a host fluid field"); + n = CableDyn_NFluidNodes(h, &err); + REQUIRE(err == CD_C_OK && n == MAX_NODES, "one fluid node per line node"); + CableDyn_GetFluidNodePositions(h, xyz, n, &err); + REQUIRE(err == CD_C_OK && xyz[3 * (n - 1) + 2] < 0.0, "the node positions"); + CableDyn_GetFluidNodePositions(h, xyz, n - 1, &err); + REQUIRE(err == CD_C_BAD_INPUT, "a wrong node count is refused"); + CableDyn_Step(h, DT, q, z, z, 3, &converged, &stalled, &n_iter, &err); + REQUIRE(err == CD_C_BAD_INPUT && last_error_has(h, "before the first step"), + "a step before the first field is refused"); + CableDyn_UpdateFluidFields(h, vel, acc, eta, NULL, n, &err); + REQUIRE(err == CD_C_OK, "UpdateFluidFields"); + CableDyn_UpdateFluidFields(h, NULL, acc, eta, NULL, n, &err); + REQUIRE(err == CD_C_BAD_INPUT, "a null field is refused"); + REQUIRE(step_at_deck(h) == 0, "a step in the held field"); + failed = 0; +done: + close_handle(&h); + return failed; +} + int main(int argc, char **argv) { int failed; @@ -396,7 +432,7 @@ int main(int argc, char **argv) return 1; } failed = check_init() || check_feedthrough() || check_march() || check_save_restore() || check_rejections() || - check_point_route(); + check_point_route() || check_host_fluid(); remove_written_decks(); if (failed) return 1; puts("PASS: CableDyn C API host rotations"); diff --git a/tests/test_c_api_init_notes.c b/tests/test_c_api_init_notes.c new file mode 100644 index 00000000..6c9074c2 --- /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_c_api_threads.c b/tests/test_c_api_threads.c index ecd3f935..9e1e6e6f 100644 --- a/tests/test_c_api_threads.c +++ b/tests/test_c_api_threads.c @@ -19,13 +19,14 @@ * 3. 8 threads initialise the same Syrope deck, which shares one OWC table file. * 4. A missing deck reports "file not found". * 5. 24 threads step distinct handles concurrently while other threads keep - * failing to open a missing deck; every trajectory matches a serial reference. + * failing to open a missing deck; every trajectory, and the line values each thread + * reads at every step, match a serial reference. * * test_c_api_threads --mixed [ ...] instead steps every deck on * several threads at once, all decks concurrently (each handle its own model: EI = 0 * lines, finite-EI cables, Syrope and viscoelastic segments, points, bodies and rods take - * their own code paths), n_steps steps each, and checks every trajectory against the - * deck's serial reference. A procedure whose locals or callbacks live in storage that + * their own code paths), n_steps steps each, and checks every trajectory and the line + * values read at every step against the deck's serial reference. A procedure whose locals or callbacks live in storage that * every thread shares (static locals, closure trampolines) fails it. * * CABLEDYN_THREAD_TEST_LOOPS= repeats checks 2-5 n times in one process (stress @@ -259,8 +260,37 @@ static double private_mb(void) #endif } -/* Initialises `path`, takes n_step steps, and records the final coupled loads. */ -static int step_deck(const char *path, int n_step, double *loads, int *ndof_out, char *msg, size_t msg_len) +/* Reads every node quantity of every line of the handle and adds up their magnitudes. */ +static double read_lines(void *h, int *err) +{ + static const int width[8] = {3, 3, 1, 3, 1, 1, 1, 1}; /* CD_C_LINE_POSITION .. _AZIMUTH */ + double sum = 0.0; + int n_lines = CableDyn_NObjects(h, CD_C_OBJ_LINE, err), line, quantity, i; + + for (line = 0; line < n_lines && *err == CD_C_OK; ++line) { + int id, nn, sub; + double *buf; + CableDyn_GetObjectInfo(h, CD_C_OBJ_LINE, line, &id, &nn, &sub, err); + if (*err != CD_C_OK) break; + buf = (double *)malloc(3 * (size_t)nn * sizeof *buf); + if (buf == NULL) { + *err = CD_C_ALLOC_FAIL; + break; + } + for (quantity = 1; quantity <= 8 && *err == CD_C_OK; ++quantity) { + int n = width[quantity - 1] * nn; + CableDyn_GetLineValues(h, line, quantity, buf, n, err); + for (i = 0; i < n && *err == CD_C_OK; ++i) sum += fabs(buf[i]); + } + free(buf); + } + return sum; +} + +/* Initialises `path`, takes n_step steps reading its lines at each, and records the final + * coupled loads and the sum of the line values read. */ +static int step_deck(const char *path, int n_step, double *loads, double *lines, int *ndof_out, char *msg, + size_t msg_len) { void *h = NULL; int err = -1, ndof, k, n_iter = 0; @@ -280,6 +310,7 @@ static int step_deck(const char *path, int n_step, double *loads, int *ndof_out, return 1; } CableDyn_GetCoupledMotion(h, q, v, a, ndof, &err); + *lines = 0.0; for (k = 0; k < n_step && err == CD_C_OK; ++k) { /* A slow surge of every coupled x DOF keeps the lines working. */ int i; @@ -290,6 +321,7 @@ static int step_deck(const char *path, int n_step, double *loads, int *ndof_out, CableDyn_Close(&h, &err); return 1; } + if (err == CD_C_OK) *lines += read_lines(h, &err); } if (err == CD_C_OK) CableDyn_CalcOutput(h, loads, ndof, &err); if (err != CD_C_OK) CableDyn_GetLastError(h, msg, (int)msg_len); @@ -337,13 +369,14 @@ struct step_job { int ndof; int failed; double loads[MAX_DOF]; + double lines; char msg[512]; }; static void step_worker(void *raw) { struct step_job *job = (struct step_job *)raw; - job->failed = step_deck(job->path, job->n_step, job->loads, &job->ndof, job->msg, sizeof job->msg); + job->failed = step_deck(job->path, job->n_step, job->loads, &job->lines, &job->ndof, job->msg, sizeof job->msg); } static void missing_worker(void *raw) @@ -482,11 +515,11 @@ static int check_concurrent_steps(const char *deck) enum { N_STEPPERS = 24, N_MISSING = 4 }; static struct step_job jobs[N_STEPPERS + N_MISSING]; struct task tasks[N_STEPPERS + N_MISSING]; - double reference[MAX_DOF]; + double reference[MAX_DOF], reference_lines = 0.0; char msg[512] = {0}; int i, j, ndof = 0, failed = 0; - if (step_deck(deck, N_STEP, reference, &ndof, msg, sizeof msg) != 0) { + if (step_deck(deck, N_STEP, reference, &reference_lines, &ndof, msg, sizeof msg) != 0) { fprintf(stderr, "FAIL serial reference run: %s\n", msg); return 1; } @@ -522,6 +555,10 @@ static int check_concurrent_steps(const char *deck) break; } } + if (j == ndof && fabs(jobs[i].lines - reference_lines) > 1.0e-9 * (1.0 + fabs(reference_lines))) { + fprintf(stderr, " thread %d: line values %.17g, reference %.17g\n", i, jobs[i].lines, reference_lines); + ++failed; + } } printf("concurrent steps: %d threads x %d steps on distinct handles, %d missing-deck threads: failed=%d\n", N_STEPPERS, N_STEP, N_MISSING, failed); @@ -539,6 +576,7 @@ static int check_mixed_steps(int n_decks, char **decks, int n_step) enum { MAX_DECKS = 8 }; static struct step_job jobs[MAX_THREADS]; static double reference[MAX_DECKS][MAX_DOF]; + double reference_lines[MAX_DECKS]; struct task tasks[MAX_THREADS]; int ndof[MAX_DECKS]; char msg[512] = {0}; @@ -549,7 +587,7 @@ static int check_mixed_steps(int n_decks, char **decks, int n_step) return 1; } for (d = 0; d < n_decks; ++d) { - if (step_deck(decks[d], n_step, reference[d], &ndof[d], msg, sizeof msg) != 0) { + if (step_deck(decks[d], n_step, reference[d], &reference_lines[d], &ndof[d], msg, sizeof msg) != 0) { fprintf(stderr, "FAIL serial reference run of %s: %s\n", decks[d], msg); return 1; } @@ -583,6 +621,11 @@ static int check_mixed_steps(int n_decks, char **decks, int n_step) break; } } + if (j == ndof[d] && fabs(jobs[i].lines - reference_lines[d]) > 1.0e-9 * (1.0 + fabs(reference_lines[d]))) { + fprintf(stderr, " thread %d (%s): line values %.17g, reference %.17g\n", i, decks[d], jobs[i].lines, + reference_lines[d]); + ++failed; + } } printf("mixed concurrent steps: %d decks x %d threads x %d steps: failed=%d\n", n_decks, per_deck, n_step, failed); diff --git a/tests/test_check_example_fallbacks.py b/tests/test_check_example_fallbacks.py new file mode 100644 index 00000000..059b3933 --- /dev/null +++ b/tests/test_check_example_fallbacks.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""The example scan of the round-off CI job must fail where it is meant to. + +A stand-in driver, whose behaviour each deck selects, exercises tests/check_example_fallbacks.py +as a child process would: every fallback note fails the scan and names its deck, also when wrapped +over two lines; a non-zero exit and a timeout fail it; a clean deck passes. With a forced kernel, +a run that names another kernel or none at all fails it. The built driver then shows the same on +the real OpenBLAS: an unknown kernel name is not reported as taken. + +Usage: test_check_example_fallbacks.py +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("scan", HERE / "check_example_fallbacks.py") +assert spec is not None and spec.loader is not None +scan = importlib.util.module_from_spec(spec) +sys.modules["scan"] = scan # dataclasses look the module up by name +spec.loader.exec_module(scan) + +# Each deck holds what the driver prints to stderr, then a last line with its exit code. +FAKE_DRIVER = """ +import sys +import time +text = open(sys.argv[1], encoding="utf-8").read().split("|") +if text[-1] == "hang": + time.sleep(60) +sys.stderr.write(text[0]) +sys.exit(int(text[-1])) +""" + +NOTES = { + "newton": " Note: line 1: the discrete attachment equilibrium was reached after a failed " + "Newton solve (pseudo-transient continuation).\n", + "wrapped": " Note: line 1: reached after a failed\n Newton solve.\n", + "stalled": " Note: the Newton iteration stalled at the load fraction 0.5.\n", + "looser": " Note: a looser tolerance is needed.\n", + "floor": " Note: line 1: static residual floor above the dynamic tolerance 1.00E-08; " + "equilibrium accepted at the relative residual 2.00E-07.\n", + "torsion_floor": " Note: line 1: torsion static residual floor above the tolerance " + "1.00E-08; twisted equilibrium accepted at 2.00E-07.\n", + "substep": " Note: line 1: 3 step(s) kept the whole step because a resolving sub-step did " + "not converge.\n", +} +# Notes of a healthy run that must not trip the scan. +CLEAN = ( + " Note: LINE 1: dtM = 0.1 s resolves its axial period of 1 s in 10 steps.\n" + " Note: line 1: 2 attachment(s) lumped at the nearest nodes.\n" + " CableDyn initialization completed.\n" +) + + +def write_deck(directory: Path, name: str, console: str, status: str = "0") -> Path: + deck = directory / f"{name}.dat" + deck.write_text(f"{console}|{status}", encoding="utf-8") + return deck + + +def run_scan(command, decks, out, *, coretype=None, timeout=20.0): + """The exit status and the printed report of a scan.""" + text = io.StringIO() + with contextlib.redirect_stdout(text): + runs = scan.scan(command, decks, out, jobs=4, timeout=timeout, coretype=coretype) + status = scan.report(runs, coretype) + return status, text.getvalue() + + +def check(condition: bool, what: str, failures: list[str], detail: str = "") -> None: + print(f"{'ok' if condition else 'FAIL'}: {what}") + if not condition: + failures.append(f"{what}\n{detail}") + + +def main() -> int: + driver, examples = Path(sys.argv[1]), Path(sys.argv[2]) + failures: list[str] = [] + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + decks = root / "decks" + decks.mkdir() + fake = root / "fake_driver.py" + fake.write_text(FAKE_DRIVER, encoding="utf-8") + command = [sys.executable, str(fake)] + + clean = write_deck(decks, "clean", CLEAN) + status, text = run_scan(command, [clean], root / "clean") + check(status == 0 and "OFFENDS" not in text, "a clean deck passes", failures, text) + + for name, note in NOTES.items(): + deck = write_deck(decks, name, CLEAN + note) + status, text = run_scan(command, [clean, deck], root / name) + listed = f" {deck.name} (exit 0)" in text and f" {clean.name} (" not in text + check(status == 1 and listed, f"the note {name} fails its deck", failures, text) + + failed = write_deck(decks, "failed", CLEAN, "3") + status, text = run_scan(command, [failed], root / "failed") + check(status == 1 and "failed.dat (exit 3)" in text, "an exit status fails", failures, text) + + hung = write_deck(decks, "hung", CLEAN, "hang") + status, text = run_scan(command, [hung], root / "hung", timeout=2.0) + check(status == 1 and "hung.dat (no exit)" in text, "a timeout fails", failures, text) + + taken = write_deck(decks, "taken", "Core: Prescott\n" + CLEAN) + status, text = run_scan(command, [taken], root / "taken", coretype="Prescott") + check(status == 0, "the forced kernel passes", failures, text) + status, text = run_scan(command, [taken], root / "case", coretype="PRESCOTT") + check(status == 0, "kernel names match in any case", failures, text) + + other = write_deck(decks, "other", "Core not found: Prescott\nCore: Haswell\n" + CLEAN) + status, text = run_scan(command, [other], root / "other", coretype="Prescott") + check(status == 1 and "ran Haswell" in text, "another kernel fails", failures, text) + + status, text = run_scan(command, [clean], root / "none", coretype="Prescott") + check(status == 1 and "named no kernel" in text, "no kernel fails", failures, text) + + status, text = run_scan(command, [taken, other], root / "mixed", coretype="Prescott") + check(status == 1 and "ran Haswell" in text, "one wrong run fails", failures, text) + + # The real driver and OpenBLAS: a kernel that does not exist cannot be confirmed. + real = io.StringIO() + with contextlib.redirect_stdout(real): + status = scan.main( + [ + "--driver", + str(driver), + "--examples", + str(examples), + "--out", + str(root / "real"), + "--coretype", + "NoSuchKernel", + "--kernel-only", + ] + ) + check(status == 1, "an unknown kernel fails on the real driver", failures, real.getvalue()) + + for failure in failures: + print(f"\nFAILED: {failure}") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 51f6dab9..41ff57c6 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -185,6 +185,13 @@ def test_windows_release_is_publicly_reproducible(self) -> None: farm_patch, f"FAST.Farm host environment propagation lost: {token}", ) + # both hosts tell CableDyn that a run completed, which gates its whole-run outputs + for hook in ("0014-fast-subs.patch", "0023-fast-farm-end.patch"): + self.assertIn( + "%CD%m%HostComplete = .true.", + (ROOT / "integration" / "openfast" / "patches" / hook).read_text(encoding="utf-8"), + f"run-completion signal lost: {hook}", + ) self.assertNotIn("CD_InitInp%g = 9.81", farm_patch) self.assertNotIn("CD_InitInp%rhoW = 1025.0", farm_patch) farm_registry = ( diff --git a/tests/test_driver_channels.f90 b/tests/test_driver_channels.f90 index 41bba17e..a5758b3e 100644 --- a/tests/test_driver_channels.f90 +++ b/tests/test_driver_channels.f90 @@ -279,7 +279,7 @@ SUBROUTINE test_static_config_file() WRITE (*, '(A,I0,A,F10.5)') 'static.out rows=', nrow, ' node3 curvature=', cv3 END SUBROUTINE test_static_config_file - ! ---- Part 4: finite-EI initialised deck; non-zero bend moment = EI*curvature ---- + ! ---- Part 4: finite-EI initialised deck; non-zero bend moment = EI*curvature/stretch ---- SUBROUTINE test_finite_dynamic_channels() INTEGER :: es, ok @@ -310,10 +310,12 @@ SUBROUTINE test_finite_dynamic_channels() ! element-interior maximum and is retained only for EI=0 nodal lines. CALL require(ABS(v(4) - ref_curv(n2, n3, n4)) > 5.0e-6_wp, & 'finite-EI Curv1N3 uses exact Hermite traces rather than Menger curvature') - ! BendMom = EI(node) * |kvec_current - kvec_reference|; straight reference => EI * geometric, - ! EI = 2.0e5 for this homogeneous line (the required straight-reference agreement check). - CALL require(ABS(v(5) - EI*v(4)) <= 1.0e-6_wp*ABS(v(5)) + 1.0e-9_wp, & - 'BendMom1N3 = EI * geometric curvature for the straight reference') + ! BendMom = EI(node) * |kvec_current - kvec_reference|/|r'|; straight reference => EI * geometric + ! over the stretch, EI = 2.0e5 for this homogeneous line (the required straight-reference + ! agreement check). The channels do not give |r'|, within 1e-4 of 1 on this stiff line: the + ! exact relation is gated in test_openfast_aggregate and check_curvature_stretch.py. + CALL require(ABS(v(5) - EI*v(4)) <= 1.0e-4_wp*ABS(v(5)) + 1.0e-9_wp, & + 'BendMom1N3 = EI * geometric curvature, over the stretch, for the straight reference') CALL require(v(5) > 1.0_wp, 'finite-EI BendMom1N3 is non-zero (bent vs straight reference)') CALL check_continuous_element_file('chan_fin.elements.out', 20, EI) ! tensile_safety True judges the element-mean axial force (the pointwise resultant of a diff --git a/tests/test_line_values.f90 b/tests/test_line_values.f90 new file mode 100644 index 00000000..4bd63252 --- /dev/null +++ b/tests/test_line_values.f90 @@ -0,0 +1,780 @@ +! SPDX-License-Identifier: Apache-2.0 +! Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +PROGRAM test_line_values + !! CableDyn_GetLineValues evaluates a line at once; every value must be the bit pattern of the + !! output channel of its node, whatever the route. Each deck runs twice, at the static start and + !! after a few moving steps, so velocity and acceleration are not zero: + !! + !! * through the C API, every value at a deck node against CableDyn_EvalChannel (the row of deck + !! node J comes from CD_C_LINE_DECK_NODE), on the point and the aggregate routes; + !! * on the aggregate, every value at every node of the final mesh, including the nodes a + !! refinement added, against CD_Eval_Aggregate_Channel with mesh_node = .TRUE. + !! + !! The set: a cable on +x (no frame heading), with a heading, just below +x (the signed azimuth + !! range), on -x, OutputRecovery element, a bend stiffener under both recoveries, restrained in + !! torsion with a rolling host, beside an EI = 0 line, EI = 0 lines alone, a mesh the static solve + !! refines, and decks with bodies or rods. A state with a NaN then fails at the same value on + !! both paths. + !! + !! Lines on headings on both sides of +x, EI = 0 alone and beside cables, must report the azimuth + !! range of their deck chord (doc/outputs.rst) through the line values, the channels, the + !! aggregate's channels (the OpenFAST shell's) and its initialisation summary. + !! + !! usage: test_line_values + USE, INTRINSIC :: ISO_C_BINDING, ONLY: C_BOOL, C_CHAR, C_DOUBLE, C_INT, C_LOC, C_NULL_PTR, C_PTR + USE, INTRINSIC :: ISO_FORTRAN_ENV, ONLY: INT64 + USE, INTRINSIC :: IEEE_ARITHMETIC, ONLY: IEEE_IS_NAN, IEEE_QUIET_NAN, IEEE_VALUE + USE CableDyn_Precision, ONLY: wp + USE CableDyn_CAPI, ONLY: CableDyn_Create, CableDyn_Close, CableDyn_InitDeck, CableDyn_InitDeckRotating, & + CableDyn_Step, CableDyn_GetCoupledMotion, CableDyn_GetLastError, CableDyn_EvalChannel, & + CableDyn_GetLineValues, CableDyn_GetObjectInfo, CableDyn_NCoupledDOF, CableDyn_NObjects, & + CableDyn_UpdateRotations + USE CableDyn_DeckDriver, ONLY: CD_Deck_Query_dtM, CD_DECKDRV_OK, CD_Eval_Aggregate_Channel, & + CD_Eval_Aggregate_Line_Values + USE CableDyn_OpenFAST_Aggregate, ONLY: CD_AGG_ModuleType, CD_AGG_End, CD_AGG_EvalChannel, CD_AGG_GetInitLine, & + CD_AGG_GetMovingPointMesh, CD_AGG_Init_From_Deck, CD_AGG_NMovingPoints, & + CD_AGG_NumChannels, CD_AGG_OK, CD_AGG_Step_Moving + USE CableDyn_System, ONLY: CD_System_Line_NDOF + IMPLICIT NONE + + INTEGER, PARAMETER :: NSTEPS = 4, N_QUANTITY = 8 + INTEGER(C_INT), PARAMETER :: LINE_KIND = 1_C_INT, DECK_NODE = 10_C_INT + INTEGER, PARAMETER :: QUANTITY(N_QUANTITY) = [1, 2, 4, 3, 5, 6, 7, 8] + REAL(wp), PARAMETER :: SWAY = 0.05_wp, RATE = 4.0_wp, ROLL = 0.02_wp, PI = 4.0_wp*ATAN(1.0_wp) + ! Line headings [deg]: within 45 deg of +x on either side, just outside it, and elsewhere. + REAL(wp), PARAMETER :: HEADINGS(12) = [-46.0_wp, -44.0_wp, -30.0_wp, -10.0_wp, -1.0_wp, 1.0_wp, 10.0_wp, 30.0_wp, & + 44.0_wp, 46.0_wp, 180.0_wp, 270.0_wp] + INTEGER :: nfail + INTEGER(INT64) :: n_capi, n_mesh, n_added + CHARACTER(1024) :: arg + + nfail = 0 + n_capi = 0 + n_mesh = 0 + n_added = 0 + IF (COMMAND_ARGUMENT_COUNT() /= 1) ERROR STOP 'usage: test_line_values ' + + CALL write_cable_deck('lv_plain.dat', 0.0_wp) + CALL check_deck('lv_plain.dat') + CALL write_cable_deck('lv_heading.dat', 40.0_wp) + CALL check_deck('lv_heading.dat', corrupt=.TRUE.) + CALL write_cable_deck('lv_signed.dat', 340.0_wp) + CALL check_deck('lv_signed.dat') + CALL write_cable_deck('lv_reversed.dat', 180.0_wp) + CALL check_deck('lv_reversed.dat') + CALL write_cable_deck('lv_element.dat', 130.0_wp, element_recovery=.TRUE.) + CALL check_deck('lv_element.dat', corrupt=.TRUE.) + CALL write_cable_deck('lv_stiffener.dat', 250.0_wp, stiffener=.TRUE.) + CALL check_deck('lv_stiffener.dat') + CALL write_cable_deck('lv_stiffener_element.dat', 310.0_wp, stiffener=.TRUE., element_recovery=.TRUE.) + CALL check_deck('lv_stiffener_element.dat') + CALL write_cable_deck('lv_torsion.dat', 40.0_wp, torsion=.TRUE.) + CALL check_deck('lv_torsion.dat', rotating=.TRUE.) + CALL write_cable_deck('lv_torsion_element.dat', 220.0_wp, torsion=.TRUE., element_recovery=.TRUE.) + CALL check_deck('lv_torsion_element.dat', rotating=.TRUE.) + CALL write_cable_deck('lv_mixed.dat', 40.0_wp, chain=.TRUE.) + CALL check_deck('lv_mixed.dat') + CALL write_chain_deck('lv_chains.dat') + CALL check_deck('lv_chains.dat', corrupt=.TRUE.) + CALL GET_COMMAND_ARGUMENT(1, arg) + CALL write_refined_deck(TRIM(arg), 'lv_refined.dat') + CALL check_deck('lv_refined.dat', refined=.TRUE.) + CALL write_object_deck('lv_body.dat', body=.TRUE.) + CALL check_deck('lv_body.dat') + CALL write_object_deck('lv_rod.dat', body=.FALSE.) + CALL check_deck('lv_rod.dat') + CALL write_heading_deck('lv_range_chains.dat', cables=.FALSE.) + CALL check_deck('lv_range_chains.dat') + CALL check_azimuth_range('lv_range_chains.dat') + CALL write_heading_deck('lv_range_mixed.dat', cables=.TRUE.) + CALL check_deck('lv_range_mixed.dat') + CALL check_azimuth_range('lv_range_mixed.dat') + + WRITE (*, '(A,I0,A,I0,A,I0,A)') 'compared ', n_capi, ' values with CableDyn_EvalChannel and ', n_mesh, & + ' with the mesh-node channels (', n_added, ' at added nodes)' + CALL require(n_capi > 10000 .AND. n_mesh > n_capi .AND. n_added > 0, 'the set reaches every route') + IF (nfail > 0) THEN + WRITE (*, '(A,I0,A)') 'FAIL: ', nfail, ' assertion(s) failed' + ERROR STOP 1 + END IF + WRITE (*, '(A)') 'PASS: the whole-line values are the channels bit for bit' + +CONTAINS + + SUBROUTINE require(cond, msg) + LOGICAL, INTENT(IN) :: cond + CHARACTER(*), INTENT(IN) :: msg + IF (cond) RETURN + nfail = nfail + 1 + WRITE (*, '(2A)') 'FAIL: ', msg + END SUBROUTINE require + + LOGICAL FUNCTION same_bits(a, b) RESULT(same) + !! The same bit pattern, or both NaN (a NaN's sign and payload carry no value). + REAL(wp), INTENT(IN) :: a, b + same = TRANSFER(a, 0_INT64) == TRANSFER(b, 0_INT64) + IF (.NOT. same) same = IEEE_IS_NAN(a) .AND. IEEE_IS_NAN(b) + END FUNCTION same_bits + + PURE FUNCTION text(i) RESULT(s) + INTEGER, INTENT(IN) :: i + CHARACTER(:), ALLOCATABLE :: s + CHARACTER(16) :: b + WRITE (b, '(I0)') i + s = TRIM(b) + END FUNCTION text + + PURE INTEGER FUNCTION width(kind) RESULT(n) + INTEGER, INTENT(IN) :: kind + n = MERGE(3, 1, kind == 1 .OR. kind == 2 .OR. kind == 4) + END FUNCTION width + + PURE FUNCTION token(kind, line_id, node, comp) RESULT(s) + !! The channel of a quantity at a node, component comp of a vector. + INTEGER, INTENT(IN) :: kind, line_id, node, comp + CHARACTER(:), ALLOCATABLE :: s, stem + CHARACTER(1), PARAMETER :: xyz(3) = ['x', 'y', 'z'] + stem = text(line_id)//'N'//text(node) + SELECT CASE (kind) + CASE (1) + s = 'L'//stem//'p'//xyz(comp) + CASE (2) + s = 'L'//stem//'v'//xyz(comp) + CASE (4) + s = 'L'//stem//'a'//xyz(comp) + CASE (3) + s = 'Ten'//stem + CASE (5) + s = 'Curv'//stem + CASE (6) + s = 'BendMom'//stem + CASE (7) + s = 'L'//stem//'Dec' + CASE DEFAULT + s = 'L'//stem//'Azi' + END SELECT + END FUNCTION token + + SUBROUTINE check_deck(path, rotating, refined, corrupt) + CHARACTER(*), INTENT(IN) :: path + LOGICAL, INTENT(IN), OPTIONAL :: rotating, refined, corrupt + LOGICAL :: rot + REAL(wp) :: dt + LOGICAL :: has_dt + INTEGER :: es + CHARACTER(512) :: em + + rot = .FALSE. + IF (PRESENT(rotating)) rot = rotating + CALL CD_Deck_Query_dtM(path, dt, has_dt, es, em) + CALL require(es == CD_DECKDRV_OK .AND. has_dt, path//': dtM: '//TRIM(em)) + IF (es /= CD_DECKDRV_OK .OR. .NOT. has_dt) RETURN + CALL check_capi(path, dt, rot, refined) + CALL check_aggregate(path, dt, rot, corrupt) + END SUBROUTINE check_deck + + SUBROUTINE check_capi(path, dt, rot, refined) + CHARACTER(*), INTENT(IN) :: path + REAL(wp), INTENT(IN) :: dt + LOGICAL, INTENT(IN) :: rot + LOGICAL, INTENT(IN), OPTIONAL :: refined + TYPE(C_PTR) :: h + INTEGER(C_INT) :: es, n, n_iter + LOGICAL(C_BOOL) :: conv, stall + CHARACTER(C_CHAR), TARGET :: c_path(1024), msg(512) + REAL(C_DOUBLE), ALLOCATABLE, TARGET :: q0(:), q(:), v(:), a(:), dcm(:), om(:), al(:) + INTEGER :: k, s, j + REAL(wp) :: t, phi + + c_path = ' ' + DO k = 1, LEN(path) + c_path(k) = path(k:k) + END DO + CALL CableDyn_Create(h, es) + IF (rot) THEN + CALL CableDyn_InitDeckRotating(h, C_LOC(c_path), INT(LEN(path), C_INT), C_NULL_PTR, 0_C_INT, es) + ELSE + CALL CableDyn_InitDeck(h, C_LOC(c_path), INT(LEN(path), C_INT), es) + END IF + CALL CableDyn_GetLastError(h, C_LOC(msg), INT(SIZE(msg), C_INT)) + CALL require(es == 0_C_INT, path//': init: '//last_error(msg)) + IF (es /= 0_C_INT) THEN + CALL CableDyn_Close(h, es) + RETURN + END IF + CALL compare_capi(h, path//' at rest', refined) + n = CableDyn_NCoupledDOF(h, es) + IF (n > 0_C_INT) THEN + ALLOCATE (q0(n), q(n), v(n), a(n), dcm(3*n), om(n), al(n)) + CALL CableDyn_GetCoupledMotion(h, C_LOC(q0), C_LOC(v), C_LOC(a), n, es) + DO s = 1, NSTEPS + t = dt*REAL(s, wp) + q = q0 + v = 0.0_C_DOUBLE + a = 0.0_C_DOUBLE + q(1:n:3) = q0(1:n:3) + SWAY*SIN(RATE*t) + v(1:n:3) = SWAY*RATE*COS(RATE*t) + a(1:n:3) = -SWAY*RATE**2*SIN(RATE*t) + IF (rot) THEN + phi = ROLL*REAL(s, wp)/REAL(NSTEPS, wp) + om = 0.0_C_DOUBLE + al = 0.0_C_DOUBLE + om(1:n:3) = ROLL/(REAL(NSTEPS, wp)*dt) + DO j = 0, INT(n)/3 - 1 + dcm(9*j + 1:9*j + 9) = RESHAPE(TRANSPOSE(roll_dcm(phi)), [9]) + END DO + CALL CableDyn_UpdateRotations(h, C_LOC(dcm), C_LOC(om), C_LOC(al), n/3_C_INT, es) + CALL require(es == 0_C_INT, path//': rotations') + END IF + CALL CableDyn_Step(h, REAL(dt, C_DOUBLE), C_LOC(q), C_LOC(v), C_LOC(a), n, conv, stall, n_iter, es) + CALL CableDyn_GetLastError(h, C_LOC(msg), INT(SIZE(msg), C_INT)) + CALL require(es == 0_C_INT, path//': step: '//last_error(msg)) + IF (es /= 0_C_INT) EXIT + END DO + CALL compare_capi(h, path//' stepped') + END IF + CALL CableDyn_Close(h, es) + END SUBROUTINE check_capi + + FUNCTION last_error(msg) RESULT(s) + CHARACTER(C_CHAR), INTENT(IN) :: msg(:) + CHARACTER(:), ALLOCATABLE :: s + INTEGER :: k + s = '' + DO k = 1, SIZE(msg) + IF (IACHAR(msg(k)) == 0) EXIT + s = s//msg(k) + END DO + END FUNCTION last_error + + PURE FUNCTION roll_dcm(phi) RESULT(r) + !! The global-to-body DCM of a roll phi about +x. + REAL(wp), INTENT(IN) :: phi + REAL(wp) :: r(3, 3) + r = RESHAPE([1.0_wp, 0.0_wp, 0.0_wp, 0.0_wp, COS(phi), -SIN(phi), 0.0_wp, SIN(phi), COS(phi)], [3, 3]) + END FUNCTION roll_dcm + + SUBROUTINE compare_capi(h, label, refined) + !! Every value at a deck node of every line against its channel. + TYPE(C_PTR), INTENT(IN) :: h + CHARACTER(*), INTENT(IN) :: label + LOGICAL, INTENT(IN), OPTIONAL :: refined + INTEGER(C_INT) :: es, n_lines, il, id, nn, sub + REAL(C_DOUBLE), ALLOCATABLE, TARGET :: vals(:), deck(:) + REAL(C_DOUBLE) :: val + CHARACTER(C_CHAR), TARGET :: tok(64) + CHARACTER(:), ALLOCATABLE :: name + INTEGER :: iq, w, node, c, j, k, bad, n_added_here + + n_lines = CableDyn_NObjects(h, LINE_KIND, es) + CALL require(es == 0_C_INT .AND. n_lines > 0_C_INT, label//': lines') + n_added_here = 0 + DO il = 0_C_INT, n_lines - 1_C_INT + CALL CableDyn_GetObjectInfo(h, LINE_KIND, il, id, nn, sub, es) + CALL require(es == 0_C_INT, label//': line info') + IF (ALLOCATED(deck)) DEALLOCATE (deck) + ALLOCATE (deck(nn)) + CALL CableDyn_GetLineValues(h, il, DECK_NODE, C_LOC(deck), nn, es) + CALL require(es == 0_C_INT, label//': deck nodes') + n_added_here = n_added_here + COUNT(NINT(deck) == 0) + DO iq = 1, N_QUANTITY + w = width(QUANTITY(iq)) + IF (ALLOCATED(vals)) DEALLOCATE (vals) + ALLOCATE (vals(w*nn)) + CALL CableDyn_GetLineValues(h, il, INT(QUANTITY(iq), C_INT), C_LOC(vals), INT(w*nn, C_INT), es) + CALL require(es == 0_C_INT, label//': line values') + IF (es /= 0_C_INT) CYCLE + bad = 0 + DO node = 1, INT(nn) + j = NINT(deck(node)) + IF (j == 0) CYCLE + DO c = 1, w + name = token(QUANTITY(iq), INT(id), j, c) + DO k = 1, LEN(name) + tok(k) = name(k:k) + END DO + CALL CableDyn_EvalChannel(h, C_LOC(tok), INT(LEN(name), C_INT), val, es) + n_capi = n_capi + 1 + IF (es /= 0_C_INT) THEN + bad = bad + 1 + ELSE IF (.NOT. same_bits(vals(w*(node - 1) + c), val)) THEN + bad = bad + 1 + IF (bad == 1) WRITE (*, '(A,1X,A,2ES25.16)') label, name, vals(w*(node - 1) + c), val + END IF + END DO + END DO + CALL require(bad == 0, label//': quantity '//text(QUANTITY(iq))//' of line '//text(INT(id))//': '// & + text(bad)//' values differ from their channels') + END DO + END DO + IF (PRESENT(refined)) CALL require(n_added_here > 0, label//': the static solve refines the mesh') + ! the entry's own checks + CALL CableDyn_GetLineValues(h, n_lines, 1_C_INT, C_LOC(deck), 3_C_INT, es) + CALL require(es /= 0_C_INT, label//': an index past the lines is rejected') + CALL CableDyn_GetLineValues(h, -1_C_INT, 1_C_INT, C_LOC(deck), 3_C_INT, es) + CALL require(es /= 0_C_INT, label//': a negative index is rejected') + END SUBROUTINE compare_capi + + SUBROUTINE check_aggregate(path, dt, rot, corrupt) + CHARACTER(*), INTENT(IN) :: path + REAL(wp), INTENT(IN) :: dt + LOGICAL, INTENT(IN) :: rot + LOGICAL, INTENT(IN), OPTIONAL :: corrupt + TYPE(CD_AGG_ModuleType) :: agg + INTEGER :: es, n, s, nit, id, k + LOGICAL :: cv, st, bad_state + REAL(wp) :: t, phi + REAL(wp), ALLOCATABLE :: p0(:, :), p(:, :), pv(:, :), pa(:, :), pl(:, :), dcm(:, :, :), om(:, :), al(:, :) + CHARACTER(512) :: em + + IF (rot) THEN + CALL CD_AGG_Init_From_Deck(agg, path, dt, es, em, host_orientation=.TRUE.) + ELSE + CALL CD_AGG_Init_From_Deck(agg, path, dt, es, em) + END IF + CALL require(es == CD_AGG_OK, path//': aggregate: '//TRIM(em)) + IF (es /= CD_AGG_OK) RETURN + CALL compare_mesh(agg, path//' at rest') + n = CD_AGG_NMovingPoints(agg, es, em) + IF (n > 0) THEN + ALLOCATE (p0(3, n), p(3, n), pv(3, n), pa(3, n), pl(3, n), dcm(3, 3, n), om(3, n), al(3, n)) + CALL CD_AGG_GetMovingPointMesh(agg, p0, pv, pa, pl, es, em) + DO s = 1, NSTEPS + t = dt*REAL(s, wp) + p = p0 + pv = 0.0_wp + pa = 0.0_wp + p(1, :) = p0(1, :) + SWAY*SIN(RATE*t) + pv(1, :) = SWAY*RATE*COS(RATE*t) + pa(1, :) = -SWAY*RATE**2*SIN(RATE*t) + IF (rot) THEN + phi = ROLL*REAL(s, wp)/REAL(NSTEPS, wp) + om = 0.0_wp + al = 0.0_wp + om(1, :) = ROLL/(REAL(NSTEPS, wp)*dt) + DO k = 1, n + dcm(:, :, k) = roll_dcm(phi) + END DO + CALL CD_AGG_Step_Moving(agg, dt, p, pv, pa, cv, st, nit, es, em, t_committed=t, orientation=dcm, & + angular_velocity=om, angular_acceleration=al) + ELSE + CALL CD_AGG_Step_Moving(agg, dt, p, pv, pa, cv, st, nit, es, em, t_committed=t) + END IF + CALL require(es == CD_AGG_OK, path//': aggregate step: '//TRIM(em)) + IF (es /= CD_AGG_OK) EXIT + END DO + CALL compare_mesh(agg, path//' stepped') + END IF + bad_state = .FALSE. + IF (PRESENT(corrupt)) bad_state = corrupt + IF (bad_state) THEN + ! a NaN at mid-line: both paths give the same values up to the same first failure + DO id = 1, SIZE(agg%line_obj_index) + k = agg%line_obj_index(id) + IF (k < 1) CYCLE + IF (agg%line_is_cable(id)) THEN + agg%cables(k)%line%q(6*(agg%cables(k)%line%nn/2) + 1) = IEEE_VALUE(t, IEEE_QUIET_NAN) + ELSE + agg%sys%fast%system%lines(k)%q(3*(SIZE(agg%sys%fast%system%lines(k)%q)/6) + 1) = & + IEEE_VALUE(t, IEEE_QUIET_NAN) + END IF + END DO + CALL compare_mesh(agg, path//' with a NaN', failing=.TRUE.) + END IF + CALL CD_AGG_End(agg, es, em) + END SUBROUTINE check_aggregate + + SUBROUTINE compare_mesh(agg, label, failing) + !! Every value at every node of the final mesh against the channel of that mesh node. + TYPE(CD_AGG_ModuleType), INTENT(INOUT) :: agg + CHARACTER(*), INTENT(IN) :: label + LOGICAL, INTENT(IN), OPTIONAL :: failing + REAL(wp), ALLOCATABLE :: vals(:), work(:) + REAL(wp) :: val + INTEGER :: id, obj, nn, iq, w, n_done, es, es_ch, node, c, i, bad + CHARACTER(512) :: em, em_ch + CHARACTER(:), ALLOCATABLE :: name + LOGICAL :: allow_failure + + allow_failure = .FALSE. + IF (PRESENT(failing)) allow_failure = failing + DO id = 1, SIZE(agg%line_obj_index) + obj = agg%line_obj_index(id) + IF (obj < 1) CYCLE + IF (agg%line_is_cable(id)) THEN + nn = agg%cables(obj)%line%nn + IF (ALLOCATED(agg%cables(obj)%deck_node)) n_added = n_added + nn - SIZE(agg%cables(obj)%deck_node) + ELSE + nn = CD_System_Line_NDOF(agg%sys%fast%system, obj, es, em)/3 + END IF + IF (ALLOCATED(work)) DEALLOCATE (work) + ALLOCATE (work(nn)) + DO iq = 1, N_QUANTITY + w = width(QUANTITY(iq)) + IF (ALLOCATED(vals)) DEALLOCATE (vals) + ALLOCATE (vals(w*nn)) + CALL CD_Eval_Aggregate_Line_Values(id, QUANTITY(iq), agg%has_sys, agg%sys%fast%system, agg%cables, & + agg%line_is_cable, agg%line_obj_index, vals, work, n_done, es, em) + IF (.NOT. allow_failure) CALL require(es == CD_DECKDRV_OK .AND. n_done == w*nn, label//': '//TRIM(em)) + bad = 0 + DO i = 1, MIN(n_done + 1, w*nn) + node = (i - 1)/w + 1 + c = i - w*(node - 1) + name = token(QUANTITY(iq), id, node, c) + CALL CD_Eval_Aggregate_Channel(name, agg%has_sys, agg%sys%fast%system, agg%cables, agg%line_is_cable, & + agg%line_obj_index, val, es_ch, em_ch, mesh_node=.TRUE.) + IF (i > n_done) THEN + ! the first value the line evaluation did not reach: the channel fails there too + IF (es_ch == CD_DECKDRV_OK .OR. es == CD_DECKDRV_OK) bad = bad + 1 + ELSE + n_mesh = n_mesh + 1 + IF (es_ch /= CD_DECKDRV_OK) THEN + bad = bad + 1 + ELSE IF (.NOT. same_bits(vals(i), val)) THEN + bad = bad + 1 + IF (bad == 1) WRITE (*, '(A,1X,A,2ES25.16)') label, name, vals(i), val + END IF + END IF + END DO + CALL require(bad == 0, label//': quantity '//text(QUANTITY(iq))//' of line '//text(id)//': '//text(bad)// & + ' values differ from the mesh-node channels') + END DO + ! a bad code or size is refused, with nothing written + CALL CD_Eval_Aggregate_Line_Values(id, 9, agg%has_sys, agg%sys%fast%system, agg%cables, agg%line_is_cable, & + agg%line_obj_index, vals, work, n_done, es, em) + CALL require(es /= CD_DECKDRV_OK .AND. n_done == 0, label//': a code past the node channels is refused') + CALL CD_Eval_Aggregate_Line_Values(id, 3, agg%has_sys, agg%sys%fast%system, agg%cables, agg%line_is_cable, & + agg%line_obj_index, vals(1:nn - 1), work, n_done, es, em) + CALL require(es /= CD_DECKDRV_OK .AND. n_done == 0, label//': a wrongly sized output is refused') + END DO + END SUBROUTINE compare_mesh + + PURE REAL(wp) FUNCTION range_azimuth(heading) RESULT(azimuth) + !! The azimuth [deg] of a line on heading in its range: (-180, 180] within 45 deg of +x, + !! otherwise [0, 360). + REAL(wp), INTENT(IN) :: heading + azimuth = heading + IF (ABS(heading) > 45.0_wp) azimuth = MODULO(heading, 360.0_wp) + END FUNCTION range_azimuth + + SUBROUTINE check_azimuth_range(path) + !! Line 2 k of the deck runs on HEADINGS(k), in its vertical plane, at the static start. + CHARACTER(*), INTENT(IN) :: path + REAL(wp), PARAMETER :: TOL = 1.0e-6_wp + TYPE(C_PTR) :: h + TYPE(CD_AGG_ModuleType) :: agg + INTEGER(C_INT) :: es, n_lines, il, id, nn, sub + CHARACTER(C_CHAR), TARGET :: c_path(1024) + REAL(C_DOUBLE), ALLOCATABLE, TARGET :: azi(:) + REAL(wp) :: val, tension, force(3), inclination, declination, azimuth + INTEGER :: k, n, line_id, ies + CHARACTER(512) :: em + + c_path = ' ' + DO k = 1, LEN(path) + c_path(k) = path(k:k) + END DO + CALL CableDyn_Create(h, es) + CALL CableDyn_InitDeck(h, C_LOC(c_path), INT(LEN(path), C_INT), es) + CALL require(es == 0_C_INT, path//': range: init') + n_lines = CableDyn_NObjects(h, LINE_KIND, es) + CALL require(n_lines == SIZE(HEADINGS), path//': range: line count') + DO il = 0_C_INT, n_lines - 1_C_INT + CALL CableDyn_GetObjectInfo(h, LINE_KIND, il, id, nn, sub, es) + IF (ALLOCATED(azi)) DEALLOCATE (azi) + ALLOCATE (azi(nn)) + CALL CableDyn_GetLineValues(h, il, 8_C_INT, C_LOC(azi), nn, es) + CALL require(es == 0_C_INT .AND. MAXVAL(ABS(azi - range_azimuth(HEADINGS(id/2)))) <= TOL, & + path//': range: the azimuths of line '//text(INT(id))//' read through the C API') + END DO + CALL CableDyn_Close(h, es) + + CALL CD_AGG_Init_From_Deck(agg, path, 0.02_wp, ies, em) + CALL require(ies == CD_AGG_OK, path//': range: aggregate: '//TRIM(em)) + IF (ies /= CD_AGG_OK) RETURN + n = CD_AGG_NumChannels(agg) + CALL require(n == SIZE(HEADINGS), path//': range: channel count') + DO k = 1, n + CALL CD_AGG_EvalChannel(agg, k, val, ies, em) + CALL require(ies == CD_AGG_OK .AND. ABS(val - range_azimuth(HEADINGS(k))) <= TOL, & + path//': range: aggregate channel of line '//text(2*k)) + CALL CD_AGG_GetInitLine(agg, k, line_id, tension, force, inclination, declination, azimuth, ies, em) + CALL require(ies == CD_AGG_OK .AND. ABS(azimuth - range_azimuth(HEADINGS(line_id/2))) <= TOL, & + path//': range: initialisation summary of line '//text(line_id)) + END DO + CALL CD_AGG_End(agg, ies, em) + END SUBROUTINE check_azimuth_range + + SUBROUTINE write_heading_deck(path, cables) + !! Line 2 k from its own coupled point to its own anchor on HEADINGS(k): an EI = 0 chain, or + !! with cables every second line a finite-EI cable. + CHARACTER(*), INTENT(IN) :: path + LOGICAL, INTENT(IN) :: cables + REAL(wp) :: cx, sy, reach + INTEGER :: u, k + LOGICAL :: cable + + OPEN (NEWUNIT=u, FILE=path, STATUS='REPLACE', ACTION='WRITE') + WRITE (u, '(A)') 'Line values against the channels, lines on both sides of +x' + WRITE (u, '(A)') '--- LINE TYPES ---' + WRITE (u, '(A)') 'Name Diam Mass EA BA EI Cdn Cdt Can Cat' + WRITE (u, '(A)') '(-) (m) (kg/m) (N) (-) (Nm2) (-) (-) (-) (-)' + WRITE (u, '(A)') 'cable 0.16 36.7 4.69e8 -1.0 1.99e4 1.2 0.1 1.0 0.0' + WRITE (u, '(A)') 'chain 0.10 150.0 8.0e8 -1.0 0.0 1.2 0.4 1.0 0.5' + WRITE (u, '(A)') '--- POINTS ---' + WRITE (u, '(A)') 'ID Type X Y Z' + WRITE (u, '(A)') '(-) (-) (m) (m) (m)' + DO k = 1, SIZE(HEADINGS) + cable = cables .AND. MOD(k, 2) == 0 + reach = MERGE(70.0_wp, 240.0_wp, cable) + cx = COS(HEADINGS(k)*PI/180.0_wp) + sy = SIN(HEADINGS(k)*PI/180.0_wp) + WRITE (u, '(I0,A,2(1X,ES23.15),A)') 2*k - 1, ' Coupled', 5.0_wp*cx, 5.0_wp*sy, ' -10.0' + WRITE (u, '(I0,A,2(1X,ES23.15),A)') 2*k, ' Fixed', (5.0_wp + reach)*cx, (5.0_wp + reach)*sy, ' -60.0' + END DO + WRITE (u, '(A)') '--- LINES ---' + WRITE (u, '(A)') 'ID NodeA NodeB Outputs' + WRITE (u, '(A)') '(-) (-) (-) (-)' + DO k = 1, SIZE(HEADINGS) + WRITE (u, '(I0,1X,I0,1X,I0,A)') 2*k, 2*k - 1, 2*k, ' -' + END DO + WRITE (u, '(A)') '--- SECTIONS ---' + WRITE (u, '(A)') 'LineID LineType Length NumSegs' + WRITE (u, '(A)') '(-) (-) (m) (-)' + DO k = 1, SIZE(HEADINGS) + IF (cables .AND. MOD(k, 2) == 0) THEN + WRITE (u, '(I0,A)') 2*k, ' cable 100.0 20' + ELSE + WRITE (u, '(I0,A)') 2*k, ' chain 260.0 13' + END IF + END DO + WRITE (u, '(A)') '--- OPTIONS ---' + WRITE (u, '(A)') '9.80665 g' + WRITE (u, '(A)') '1025.0 rhoW' + WRITE (u, '(A)') '60.0 WtrDpth' + WRITE (u, '(A)') '1.0e6 kBot' + WRITE (u, '(A)') '1.0e5 cBot' + WRITE (u, '(A)') '0.02 dtM' + WRITE (u, '(A)') '0.0 TMax' + WRITE (u, '(A)') '--- OUTPUTS ---' + DO k = 1, SIZE(HEADINGS) + WRITE (u, '(A,I0,A)') 'L', 2*k, 'N1Azi' + END DO + WRITE (u, '(A)') '--- END ---' + CLOSE (u) + END SUBROUTINE write_heading_deck + + SUBROUTINE write_cable_deck(path, heading, element_recovery, stiffener, torsion, chain) + !! A 100 m finite-EI cable from a coupled point at z = -10 to an anchor 70 m away along + !! heading [deg], at z = -60. Line ids are not consecutive. torsion restrains a level cable at + !! z = -50 (both ends Rigid); chain adds an EI = 0 line from a second coupled point, heading + !! just below +x (the signed azimuth range). + CHARACTER(*), INTENT(IN) :: path + REAL(wp), INTENT(IN) :: heading + LOGICAL, INTENT(IN), OPTIONAL :: element_recovery, stiffener, torsion, chain + LOGICAL :: rec, stf, tor, chn + REAL(wp) :: cx, sy + INTEGER :: u + + rec = .FALSE. + IF (PRESENT(element_recovery)) rec = element_recovery + stf = .FALSE. + IF (PRESENT(stiffener)) stf = stiffener + tor = .FALSE. + IF (PRESENT(torsion)) tor = torsion + chn = .FALSE. + IF (PRESENT(chain)) chn = chain + cx = COS(heading*PI/180.0_wp) + sy = SIN(heading*PI/180.0_wp) + OPEN (NEWUNIT=u, FILE=path, STATUS='REPLACE', ACTION='WRITE') + WRITE (u, '(A)') 'Line values against the channels' + WRITE (u, '(A)') '--- LINE TYPES ---' + WRITE (u, '(A)') 'Name Diam Mass EA BA EI GAs GJ Irt Irn Cdn Cdt Can Cat' + WRITE (u, '(A)') '(-) (m) (kg/m) (N) (-) (Nm2) (N) (Nm2) (kgm) (kgm) (-) (-) (-) (-)' + WRITE (u, '(A)') 'cable 0.16 36.7 4.69e8 -1.0 1.99e4 9.02e7 5.0e4 0.0587 0.1174 1.2 0.1 1.0 0.0' + WRITE (u, '(A)') 'chain 0.10 150.0 8.0e8 0.0 0.0 0 0 0 0 1.2 0.4 1.0 0.5' + WRITE (u, '(A)') '--- POINTS ---' + WRITE (u, '(A)') 'ID Type X Y Z' + WRITE (u, '(A)') '(-) (-) (m) (m) (m)' + IF (tor) THEN + WRITE (u, '(A)') '1 Coupled 0.0 0.0 -50.0' + WRITE (u, '(A,2(1X,ES23.15),A)') '2 Fixed', 99.5_wp*cx, 99.5_wp*sy, ' -50.0' + ELSE + WRITE (u, '(A)') '1 Coupled 0.0 0.0 -10.0' + WRITE (u, '(A,2(1X,ES23.15),A)') '2 Fixed', 70.0_wp*cx, 70.0_wp*sy, ' -60.0' + END IF + IF (chn) THEN + WRITE (u, '(A)') '3 Coupled -20.0 5.0 -10.0' + WRITE (u, '(A)') '4 Fixed 200.0 -30.0 -60.0' + END IF + WRITE (u, '(A)') '--- LINES ---' + WRITE (u, '(A)') 'ID NodeA NodeB Outputs' + WRITE (u, '(A)') '(-) (-) (-) (-)' + WRITE (u, '(A)') '3 1 2 -' + IF (chn) WRITE (u, '(A)') '6 3 4 -' + WRITE (u, '(A)') '--- SECTIONS ---' + WRITE (u, '(A)') 'LineID LineType Length NumSegs' + WRITE (u, '(A)') '(-) (-) (m) (-)' + WRITE (u, '(A)') '3 cable 100.0 40' + IF (chn) WRITE (u, '(A)') '6 chain 240.0 24' + IF (stf) THEN + WRITE (u, '(A)') '--- STIFFENERS ---' + WRITE (u, '(A)') 'LineID End Length RootOD TipOD InnerD E Density' + WRITE (u, '(A)') '(-) (-) (m) (m) (m) (m) (Pa) (kg/m^3)' + WRITE (u, '(A)') '3 A 0.5 0.45 0.45 0.17 5.0e7 1150' + WRITE (u, '(A)') '3 A 6.5 0.45 0.18 0.17 5.0e7 1150' + END IF + IF (tor) THEN + WRITE (u, '(A)') '--- END CONNECTIONS ---' + WRITE (u, '(A)') 'LineID End Stiffness EzX EzY EzZ TorsStiffness NxX NxY NxZ Pretwist' + WRITE (u, '(A)') '(-) (-) (N-m/rad) (-) (-) (-) (N-m/rad) (-) (-) (-) (deg)' + WRITE (u, '(A,2(1X,ES23.15),A,2(1X,ES23.15),A)') '3 A Rigid', cx, sy, ' 0 Rigid', -sy, cx, ' 0 0' + WRITE (u, '(A,2(1X,ES23.15),A,2(1X,ES23.15),A)') '3 B Rigid', cx, sy, ' 0 Rigid', -sy, cx, ' 0 0' + END IF + WRITE (u, '(A)') '--- OPTIONS ---' + WRITE (u, '(A)') '9.80665 g' + WRITE (u, '(A)') '1025.0 rhoW' + WRITE (u, '(A)') '60.0 WtrDpth' + WRITE (u, '(A)') '1.0e5 kBot' + WRITE (u, '(A)') '1.0e4 cBot' + WRITE (u, '(A)') '0.02 dtM' + WRITE (u, '(A)') '0.0 TMax' + IF (rec) WRITE (u, '(A)') 'element OutputRecovery' + WRITE (u, '(A)') '--- OUTPUTS ---' + WRITE (u, '(A)') 'FairTen3' + WRITE (u, '(A)') '--- END ---' + CLOSE (u) + END SUBROUTINE write_cable_deck + + SUBROUTINE write_chain_deck(path) + !! EI = 0 lines alone (the point route), on three headings. + CHARACTER(*), INTENT(IN) :: path + INTEGER :: u + OPEN (NEWUNIT=u, FILE=path, STATUS='REPLACE', ACTION='WRITE') + WRITE (u, '(A)') 'Line values against the channels, EI = 0 lines' + WRITE (u, '(A)') '--- LINE TYPES ---' + WRITE (u, '(A)') 'Name Diam Mass EA BA EI Cdn Cdt Can Cat' + WRITE (u, '(A)') '(-) (m) (kg/m) (N) (-) (Nm2) (-) (-) (-) (-)' + WRITE (u, '(A)') 'chain 0.10 150.0 8.0e8 -1.0 0.0 1.2 0.4 1.0 0.5' + WRITE (u, '(A)') '--- POINTS ---' + WRITE (u, '(A)') 'ID Type X Y Z' + WRITE (u, '(A)') '(-) (-) (m) (m) (m)' + WRITE (u, '(A)') '1 Coupled 0.0 0.0 -10.0' + WRITE (u, '(A)') '2 Fixed 240.0 -40.0 -60.0' + WRITE (u, '(A)') '3 Fixed -240.0 -40.0 -60.0' + WRITE (u, '(A)') '4 Fixed -30.0 240.0 -60.0' + WRITE (u, '(A)') '--- LINES ---' + WRITE (u, '(A)') 'ID NodeA NodeB Outputs' + WRITE (u, '(A)') '(-) (-) (-) (-)' + WRITE (u, '(A)') '2 1 2 -' + WRITE (u, '(A)') '5 1 3 -' + WRITE (u, '(A)') '9 1 4 -' + WRITE (u, '(A)') '--- SECTIONS ---' + WRITE (u, '(A)') 'LineID LineType Length NumSegs' + WRITE (u, '(A)') '(-) (-) (m) (-)' + WRITE (u, '(A)') '2 chain 260.0 26' + WRITE (u, '(A)') '5 chain 260.0 20' + WRITE (u, '(A)') '9 chain 260.0 13' + WRITE (u, '(A)') '--- OPTIONS ---' + WRITE (u, '(A)') '9.80665 g' + WRITE (u, '(A)') '1025.0 rhoW' + WRITE (u, '(A)') '60.0 WtrDpth' + WRITE (u, '(A)') '1.0e6 kBot' + WRITE (u, '(A)') '1.0e5 cBot' + WRITE (u, '(A)') '0.02 dtM' + WRITE (u, '(A)') '0.0 TMax' + WRITE (u, '(A)') '--- OUTPUTS ---' + WRITE (u, '(A)') 'FairTen2' + WRITE (u, '(A)') '--- END ---' + CLOSE (u) + END SUBROUTINE write_chain_deck + + SUBROUTINE write_object_deck(path, body) + !! EI = 0 lines on a coupled Rigid6 body, or on a free rod, between an anchor and a coupled + !! point in still water (the aggregate route with a body or rod runtime). + CHARACTER(*), INTENT(IN) :: path + LOGICAL, INTENT(IN) :: body + INTEGER :: u + OPEN (NEWUNIT=u, FILE=path, STATUS='REPLACE', ACTION='WRITE') + WRITE (u, '(A)') 'Line values against the channels, a body or a rod' + WRITE (u, '(A)') '--- LINE TYPES ---' + WRITE (u, '(A)') 'Name Diam Mass EA BA EI Cdn Cdt Can Cat' + WRITE (u, '(A)') '(-) (m) (kg/m) (N) (-) (Nm2) (-) (-) (-) (-)' + WRITE (u, '(A)') 'line 0.10 80.0 1.0e5 0.0 0.0 1.0 0.0 1.0 0.0' + IF (body) THEN + WRITE (u, '(A)') '--- BODIES ---' + WRITE (u, '(A)') 'ID Type X Y Z Rot1 Rot2 Rot3 Mass Vol C33 C44 C55 CdA Ca Ixx Iyy Izz' + WRITE (u, '(A)') '(-) (-) (m) (m) (m) (deg) (deg) (deg) (kg) (m3) (N/m) (Nm/rad) (Nm/rad) (m2) (-) '// & + '(kgm2) (kgm2) (kgm2)' + WRITE (u, '(A)') '1 Rigid6 0.0 0.0 -4.0 0.0 0.0 0.0 100.0 0.05 0.0 100.0 100.0 0.0 0.0 20.0 20.0 20.0' + ELSE + WRITE (u, '(A)') '--- ROD TYPES ---' + WRITE (u, '(A)') 'Name Diam Mass Cd Ca CdEnd CaEnd' + WRITE (u, '(A)') '(-) (m) (kg/m) (-) (-) (-) (-)' + WRITE (u, '(A)') 'rodmat 0.20 50.0 1.0 1.0 0.0 0.0' + WRITE (u, '(A)') '--- RODS ---' + WRITE (u, '(A)') 'ID RodType Type XA YA ZA XB YB ZB NumSegs Outputs' + WRITE (u, '(A)') '(-) (-) (-) (m) (m) (m) (m) (m) (m) (-) (-)' + WRITE (u, '(A)') '1 rodmat Free 0.0 0.0 -4.0 0.0 0.0 -2.0 2 -' + END IF + WRITE (u, '(A)') '--- POINTS ---' + WRITE (u, '(A)') 'ID Type X Y Z Mass Vol CdA Ca' + WRITE (u, '(A)') '(-) (-) (m) (m) (m) (kg) (m3) (m2) (-)' + WRITE (u, '(A)') '1 Fixed 2.0 0.0 -5.0 0.0 0.0 0.0 0.0' + IF (body) THEN + WRITE (u, '(A)') '2 Body1 0.0 0.0 0.0 0.0 0.0 0.0 0.0' + WRITE (u, '(A)') '3 Body1 0.0 0.0 0.0 0.0 0.0 0.0 0.0' + ELSE + WRITE (u, '(A)') '2 Rod1A 0.0 0.0 -4.0 0.0 0.0 0.0 0.0' + WRITE (u, '(A)') '3 Rod1B 0.0 0.0 -2.0 0.0 0.0 0.0 0.0' + END IF + WRITE (u, '(A)') '4 Coupled 0.0 0.0 -1.0 0.0 0.0 0.0 0.0' + WRITE (u, '(A)') '--- LINES ---' + WRITE (u, '(A)') 'ID NodeA NodeB Outputs' + WRITE (u, '(A)') '(-) (-) (-) (-)' + WRITE (u, '(A)') '1 2 1 -' + WRITE (u, '(A)') '2 3 4 -' + WRITE (u, '(A)') '--- SECTIONS ---' + WRITE (u, '(A)') 'LineID LineType Length NumSegs' + WRITE (u, '(A)') '(-) (-) (m) (-)' + WRITE (u, '(A)') '1 line 3.5 3' + WRITE (u, '(A)') '2 line 3.2 3' + WRITE (u, '(A)') '--- OPTIONS ---' + WRITE (u, '(A)') '9.80665 g' + WRITE (u, '(A)') '1025.0 rhoW' + WRITE (u, '(A)') '10.0 WtrDpth' + WRITE (u, '(A)') '1.0e5 kBot' + WRITE (u, '(A)') '0.005 dtM' + WRITE (u, '(A)') '0.0 TMax' + WRITE (u, '(A)') '--- OUTPUTS ---' + WRITE (u, '(A)') 'FairTen1' + WRITE (u, '(A)') '--- END ---' + CLOSE (u) + END SUBROUTINE write_object_deck + + SUBROUTINE write_refined_deck(source, path) + !! The coarse lazy-wave deck with its top section cut from 20 to 4 elements: the deck mesh + !! cannot resolve the curvature of the equilibrium and the static solve refines it. + CHARACTER(*), INTENT(IN) :: source, path + CHARACTER(512) :: row + INTEGER :: u, w, ios + LOGICAL :: found + + found = .FALSE. + OPEN (NEWUNIT=u, FILE=source, STATUS='OLD', ACTION='READ') + OPEN (NEWUNIT=w, FILE=path, STATUS='REPLACE', ACTION='WRITE') + DO + READ (u, '(A)', IOSTAT=ios) row + IF (ios /= 0) EXIT + IF (TRIM(row) == '4 bare 80.0 20') THEN + row = '4 bare 80.0 4' + found = .TRUE. + END IF + WRITE (w, '(A)') TRIM(row) + END DO + CLOSE (u) + CLOSE (w) + CALL require(found, 'the coarse deck has its top section') + END SUBROUTINE write_refined_deck + +END PROGRAM test_line_values diff --git a/tests/test_model.f90 b/tests/test_model.f90 index 35b1a7b2..a16f32e5 100644 --- a/tests/test_model.f90 +++ b/tests/test_model.f90 @@ -718,9 +718,11 @@ SUBROUTINE case_copy_model_preserves_state() CALL require(es == CD_MODEL_OK .AND. CD_Model_Is_Initialized(src), 'copy:init-src: '//TRIM(em)) CALL CD_Calc_Model_CoupledLoads(src, load_src, es, em) CALL require(es == CD_MODEL_OK .AND. ALLOCATED(src%dynamic_workspace%R), 'copy:source-loads: '//TRIM(em)) + src%azimuth_signed = .TRUE. CALL CD_Copy_Model(src, dst, es, em) CALL require(es == CD_MODEL_OK .AND. CD_Model_Is_Initialized(dst), 'copy:model: '//TRIM(em)) + CALL require(dst%azimuth_signed, 'copy:azimuth-range') CALL require(.NOT. ALLOCATED(dst%dynamic_workspace%R), 'copy:workspace-not-shared') CALL CD_Get_Model_State(src, q_src, v_src, a_src, es, em) CALL require(es == CD_MODEL_OK, 'copy:get-src: '//TRIM(em)) diff --git a/tests/test_openfast_aggregate.f90 b/tests/test_openfast_aggregate.f90 index 90d71e7a..89809f58 100644 --- a/tests/test_openfast_aggregate.f90 +++ b/tests/test_openfast_aggregate.f90 @@ -23,7 +23,7 @@ PROGRAM test_openfast_aggregate !! 8) line control -- CONTROL on EI=0 lines and finite-EI cables (alone and mixed), its !! fail-closed bounds, rewind and bit-identical checkpoint restart (--line-control-only). USE CableDyn_Precision, ONLY: wp - USE CableDyn_DeckDriver, ONLY: CD_Deck_Query_dtM, CD_DECKDRV_OK + USE CableDyn_DeckDriver, ONLY: CD_Deck_Query_dtM, CD_DECKDRV_OK, CD_INIT_NOTE_LEN USE CableDyn_OpenFAST_Aggregate, ONLY: CD_AGG_ModuleType, CD_AGG_Init_From_Deck, CD_AGG_NMovingPoints, & CD_AGG_GetMovingPointMesh, CD_AGG_UpdateStates_Moving, & CD_AGG_Step_Moving, CD_AGG_CalcOutput, & @@ -38,8 +38,12 @@ PROGRAM test_openfast_aggregate CD_AGG_NCtrlChans, CD_AGG_Apply_LineControl, & CD_AGG_Refresh_PointMesh, CD_AGG_HasRigid6, CD_AGG_HasRod, & CD_AGG_Rigid6_MirrorSize, CD_AGG_Get_Rigid6_States, & - CD_AGG_Set_Rigid6_States, CD_AGG_Rod_MirrorSize, & - CD_AGG_Get_Rod_States, CD_AGG_Set_Rod_States + CD_AGG_Set_Rigid6_States, CD_AGG_Reopen_Start, CD_AGG_Rod_MirrorSize, & + CD_AGG_Get_Rod_States, CD_AGG_Set_Rod_States, & + CD_AGG_Range_Sample, CD_AGG_Range_Write, CD_AGG_Range_Discard, & + CD_AGG_Range_Graphs, CD_AGG_Range_MirrorSize, CD_AGG_Range_Get_State, & + CD_AGG_Range_Set_State + USE CableDyn_RangeOutput, ONLY: CD_RQ_TENSION, CD_RQ_CURVATURE, CD_RQ_BEND, CD_RQ_DECLINATION USE CableDyn_OpenFAST_FMF, ONLY: CD_FMF_ModuleType, CD_FMF_Init_From_Deck, CD_FMF_NMovingPoints, & CD_FMF_GetMovingPointMesh, CD_FMF_Step_Moving, CD_FMF_CalcOutput, & CD_FMF_End, CD_FMF_OK @@ -131,6 +135,10 @@ PROGRAM test_openfast_aggregate CALL case_touchdown_cable_end_tension() CALL case_anchor_uplift_channels() CALL case_output_channels() + CALL case_range_graph_host() + CALL case_range_discard_at_parse() + CALL case_reopen_start() + CALL case_long_init_notes() CALL case_pure_cable_deck() CALL case_cable_load_feedback() CALL case_end_connection_orientation_and_moment() @@ -156,6 +164,7 @@ PROGRAM test_openfast_aggregate CALL case_step_atomicity() CALL case_ptfm_init_equivalence() CALL case_correction_rewind() + CALL case_retry_after_probe() CALL case_external_fluid() CALL case_still_water_deck_ambient() CALL case_external_fluid_report() @@ -826,7 +835,7 @@ SUBROUTINE case_output_channels() CALL require(vFairTen2 > 1.0e3_wp, 'chan:FairTen2-physical') ! (b) cable Curv1N5 and the public HFMF nodal curvature share one definition at deck node 5: - ! the bend moment recovered from nodal equilibrium over EI by default, and under + ! the recovered bend moment times the stretch over EI by default, and under ! OutputRecovery element the exact one-sided Hermite curvature, the larger adjacent-element ! trace (CD_HFMF_Curvature_Exact). The channel follows the cable's setting. nnode = agg%cables(1)%line%nn @@ -859,10 +868,14 @@ SUBROUTINE case_output_channels() CALL require(ABS(vFairIncl1 - (decexp - 90.0_wp)) <= 1.0e-9_wp + 1.0e-6_wp*ABS(decexp), & 'chan:FairIncl1-signed-below-horizontal') - ! (d) cable BendMom1N5 == EI(node) * geometric curvature; the cable EI is uniform (bare/buoy - ! both 1.99e4), so nodal EI = EI_BARE and BendMom reduces to EI_BARE * Curv1N5. - CALL require(ABS(vBendMom1N5 - EI_BARE*vCurv1N5) <= 1.0e-6_wp*MAX(1.0_wp, ABS(EI_BARE*vCurv1N5)), & - 'chan:BendMom1N5-equals-EI-times-curv') + ! (d) cable BendMom1N5 == EI(node) * geometric curvature/|r'|, the stretch of the node's tangent; + ! the cable EI is uniform (bare/buoy both 1.99e4), so nodal EI = EI_BARE. + BLOCK + INTEGER :: i0 + i0 = 6*(nnode - 5) + CALL require(ABS(vBendMom1N5*NORM2(agg%cables(1)%line%q(i0 + 4:i0 + 6)) - EI_BARE*vCurv1N5) <= & + 1.0e-9_wp*MAX(1.0_wp, ABS(EI_BARE*vCurv1N5)), 'chan:BendMom1N5-equals-EI-times-curv-over-stretch') + END BLOCK WRITE (*, '(A,ES12.4,A,ES12.4,A,ES12.4)') 'channels: FairTen2 = ', vFairTen2, ' N; Curv1N5 = ', & vCurv1N5, ' 1/m; FairAngle1 = ', vFairAngle1 @@ -935,6 +948,349 @@ SUBROUTINE case_output_channels() IF (es == CD_AGG_OK) CALL CD_AGG_End(agg, es, em) END SUBROUTINE case_output_channels + SUBROUTINE case_range_graph_host() + !! The range graphs (LINES flag r) as the OpenFAST module drives them: one sample per committed + !! step from RangeStart, against the run's own node channels; a correction rewind that takes + !! the sample of the abandoned step back; the state mirror a checkpoint carries; the files of + !! a completed run, and the removal of an earlier run's; the refusal on a host that writes no + !! file; RangeStart against the host's run time, not the deck's standalone TMax. + INTEGER, PARAMETER :: NSTEP = 6, NCH = 9, CHK = 3 + REAL(wp), PARAMETER :: DT = 0.05_wp, RUN_T = NSTEP*DT, T_START = 0.1_wp + CHARACTER(*), PARAMETER :: ROW = 'Ten1N1 Ten1N10 Curv1N5 BendMom1N5 L1N5Dec Ten2N1 Ten2N5 Curv2N5 L2N5Dec' + ! the channels above: deck line, public node, range-graph row + INTEGER, PARAMETER :: CH_LINE(NCH) = [1, 1, 1, 1, 1, 2, 2, 2, 2], CH_NODE(NCH) = [1, 10, 5, 5, 5, 1, 5, 5, 5] + INTEGER, PARAMETER :: CH_ROW(NCH) = [CD_RQ_TENSION, CD_RQ_TENSION, CD_RQ_CURVATURE, CD_RQ_BEND, & + CD_RQ_DECLINATION, CD_RQ_TENSION, CD_RQ_TENSION, CD_RQ_CURVATURE, & + CD_RQ_DECLINATION] + TYPE(CD_AGG_ModuleType) :: agg, whole, back, plain + REAL(wp) :: r0(3, 2), vel(3, 2), acc(3, 2), lds(3, 2), cval, lo(NCH), hi(NCH), total(NCH), t + REAL(wp), ALLOCATABLE :: state(:), other(:), saved(:) + INTEGER :: es, ic, s, nsamp, u, ios, n + LOGICAL :: there + CHARACTER(512) :: em + CHARACTER(256) :: title + + ! a host that writes no file refuses the flag by name + CALL write_mixed_channels_deck('agg_range.dat', outputs_row=ROW, line_outputs='r', extra_option='0.0 TMax', & + extra_option2='0.1 RangeStart') + CALL CD_AGG_Init_From_Deck(agg, 'agg_range.dat', DT, es, em, run_tmax=RUN_T) + CALL require(es /= CD_AGG_OK .AND. INDEX(em, 'flag r') > 0 .AND. INDEX(em, 'FAST.Farm') > 0 .AND. & + INDEX(em, 'C API') > 0, & + 'range:host-without-files-refused: '//TRIM(em)) + + ! RangeStart holds against the host's run time, and the deck's own TMax = 0 does not clamp it + CALL CD_AGG_Init_From_Deck(agg, 'agg_range.dat', DT, es, em, run_tmax=0.05_wp, range_files=.TRUE.) + CALL require(es /= CD_AGG_OK .AND. INDEX(em, 'RangeStart exceeds TMax') > 0, & + 'range:window-beyond-host-run-refused: '//TRIM(em)) + CALL CD_AGG_Init_From_Deck(agg, 'agg_range.dat', DT, es, em, run_tmax=RUN_T, range_files=.TRUE.) + CALL require(es == CD_AGG_OK, 'range:init: '//TRIM(em)) + IF (es /= CD_AGG_OK) RETURN + CALL require(ABS(agg%ranges%t_start - T_START) <= 0.0_wp, 'range:host-window-start') + CALL require(CD_AGG_Range_Graphs(agg), 'range:graphs-requested') + CALL CD_AGG_Init_From_Deck(whole, 'agg_range.dat', DT, es, em, run_tmax=RUN_T, range_files=.TRUE.) + CALL require(es == CD_AGG_OK, 'range:whole-init: '//TRIM(em)) + CALL CD_AGG_Init_From_Deck(back, 'agg_range.dat', DT, es, em, run_tmax=RUN_T, range_files=.TRUE.) + CALL require(es == CD_AGG_OK, 'range:restart-init: '//TRIM(em)) + IF (es /= CD_AGG_OK) RETURN + + n = CD_AGG_Range_MirrorSize(agg) + ! four head reals, the arc and three seven-row envelopes per node, per line + CALL require(n == 2*4 + 22*(agg%ranges%lines(1)%nn + agg%ranges%lines(2)%nn), 'range:mirror-size') + ALLOCATE (state(n), other(n), saved(n)) + + CALL CD_AGG_GetMovingPointMesh(agg, r0, vel, acc, lds, es, em) + CALL require(es == CD_AGG_OK, 'range:mesh0: '//TRIM(em)) + lo = HUGE(1.0_wp) + hi = -HUGE(1.0_wp) + total = 0.0_wp + nsamp = 0 + CALL CD_AGG_Range_Sample(agg, 0.0_wp, es, em) + CALL require(es == CD_AGG_OK, 'range:sample0: '//TRIM(em)) + CALL CD_AGG_Range_Sample(whole, 0.0_wp, es, em) + CALL require(es == CD_AGG_OK, 'range:whole-sample0: '//TRIM(em)) + DO s = 1, NSTEP + t = REAL(s, wp)*DT + IF (s == CHK) THEN + ! a correction: an abandoned advance with other motion, sampled, then rewound + CALL CD_AGG_Snapshot(agg, es, em) + CALL require(es == CD_AGG_OK, 'range:snapshot: '//TRIM(em)) + CALL range_march(agg, r0, DT, s, 40.0_wp, 'range:abandoned-step') + CALL CD_AGG_Range_Sample(agg, t, es, em) + CALL require(es == CD_AGG_OK, 'range:abandoned-sample: '//TRIM(em)) + CALL CD_AGG_Range_Get_State(agg, state, es, em) + CALL CD_AGG_Range_Get_State(whole, other, es, em) + CALL require(nan_max_abs(state - other) > 0.0_wp, 'range:abandoned-sample-counts') + CALL CD_AGG_Restore(agg, es, em) + CALL require(es == CD_AGG_OK, 'range:restore: '//TRIM(em)) + CALL CD_AGG_Range_Get_State(agg, state, es, em) + CALL require(nan_max_abs(state - other) <= 0.0_wp, 'range:rewind-takes-the-sample-back') + END IF + CALL range_march(agg, r0, DT, s, 1.0_wp, 'range:step') + CALL CD_AGG_Range_Sample(agg, t, es, em) + CALL require(es == CD_AGG_OK, 'range:sample: '//TRIM(em)) + CALL range_march(whole, r0, DT, s, 1.0_wp, 'range:whole-step') + CALL CD_AGG_Range_Sample(whole, t, es, em) + CALL require(es == CD_AGG_OK, 'range:whole-sample: '//TRIM(em)) + IF (s <= CHK) CALL range_march(back, r0, DT, s, 1.0_wp, 'range:restart-step') + IF (s == CHK) THEN + CALL CD_AGG_Range_Get_State(whole, saved, es, em) + CALL require(es == CD_AGG_OK, 'range:mirror-get: '//TRIM(em)) + CALL CD_AGG_Range_Set_State(back, saved, es, em) + CALL require(es == CD_AGG_OK, 'range:mirror-set: '//TRIM(em)) + ELSE IF (s > CHK) THEN + CALL range_march(back, r0, DT, s, 1.0_wp, 'range:restart-step') + CALL CD_AGG_Range_Sample(back, t, es, em) + CALL require(es == CD_AGG_OK, 'range:restart-sample: '//TRIM(em)) + END IF + IF (t < T_START - 1.0e-9_wp) CYCLE + nsamp = nsamp + 1 + DO ic = 1, NCH + CALL CD_AGG_EvalChannel(agg, ic, cval, es, em) + CALL require(es == CD_AGG_OK, 'range:channel: '//TRIM(em)) + lo(ic) = MIN(lo(ic), cval) + hi(ic) = MAX(hi(ic), cval) + total(ic) = total(ic) + cval + END DO + END DO + + ! the envelopes are the node channels' own extremes and mean over the window + DO ic = 1, NCH + ASSOCIATE (rl => agg%ranges%lines(CH_LINE(ic))) + CALL require(rl%nsample == nsamp, 'range:one-sample-per-committed-step-in-the-window') + CALL require(ABS(rl%vmin(CH_ROW(ic), CH_NODE(ic)) - lo(ic)) <= 1.0e-12_wp*MAX(ABS(lo(ic)), 1.0e-12_wp), & + 'range:min-equals-channel') + CALL require(ABS(rl%vmax(CH_ROW(ic), CH_NODE(ic)) - hi(ic)) <= 1.0e-12_wp*MAX(ABS(hi(ic)), 1.0e-12_wp), & + 'range:max-equals-channel') + CALL require(ABS(rl%vsum(CH_ROW(ic), CH_NODE(ic)) - total(ic)) <= 1.0e-12_wp*MAX(ABS(total(ic)), 1.0e-12_wp), & + 'range:mean-equals-channel') + CALL require(ABS(rl%t_first - T_START) <= 1.0e-12_wp .AND. ABS(rl%t_last - RUN_T) <= 1.0e-12_wp, & + 'range:window-times') + END ASSOCIATE + END DO + CALL require(hi(2) > lo(2), 'range:channel-moved') + ! the rewound and the restored runs match the uninterrupted one bit for bit + CALL CD_AGG_Range_Get_State(agg, state, es, em) + CALL CD_AGG_Range_Get_State(whole, other, es, em) + CALL require(nan_max_abs(state - other) <= 0.0_wp, 'range:corrected-equals-uninterrupted') + CALL CD_AGG_Range_Get_State(back, state, es, em) + CALL require(nan_max_abs(state - other) <= 0.0_wp, 'range:restored-equals-uninterrupted') + CALL CD_AGG_Range_Set_State(back, saved(1:n - 1), es, em) + CALL require(es /= CD_AGG_OK .AND. INDEX(em, 'do not match') > 0, & + 'range:mirror-of-another-deck-refused: '//TRIM(em)) + + ! a file of an earlier run goes at the start of the run; the completed run writes its own + OPEN (NEWUNIT=u, FILE='agg_range.Line2.range.out', STATUS='REPLACE', ACTION='WRITE', IOSTAT=ios) + WRITE (u, '(A)') 'stale' + CLOSE (u) + CALL CD_AGG_Range_Discard(agg, 'agg_range', es, em) + CALL require(es == CD_AGG_OK, 'range:discard: '//TRIM(em)) + INQUIRE (FILE='agg_range.Line2.range.out', EXIST=there) + CALL require(.NOT. there, 'range:earlier-file-removed') + CALL CD_AGG_Range_Discard(agg, 'agg_range', es, em) + CALL require(es == CD_AGG_OK, 'range:discard-without-a-file: '//TRIM(em)) + CALL CD_AGG_Range_Write(agg, 'agg_range', es, em) + CALL require(es == CD_AGG_OK, 'range:write: '//TRIM(em)) + OPEN (NEWUNIT=u, FILE='agg_range.Line2.range.out', STATUS='OLD', ACTION='READ', IOSTAT=ios) + CALL require(ios == 0, 'range:file-at-root') + IF (ios == 0) THEN + READ (u, '(A)', IOSTAT=ios) title + CLOSE (u) + CALL require(ios == 0 .AND. INDEX(title, 'line 2; 5 samples') > 0, 'range:file-title: '//TRIM(title)) + END IF + CALL CD_AGG_End(agg, es, em) + CALL CD_AGG_End(whole, es, em) + CALL CD_AGG_End(back, es, em) + + ! a deck without the flag has nothing to sample or mirror, also with range_files + CALL write_mixed_channels_deck('agg_range_none.dat', outputs_row=ROW) + CALL CD_AGG_Init_From_Deck(plain, 'agg_range_none.dat', DT, es, em, run_tmax=RUN_T, range_files=.TRUE.) + CALL require(es == CD_AGG_OK, 'range:plain-init: '//TRIM(em)) + IF (es /= CD_AGG_OK) RETURN + CALL require(.NOT. CD_AGG_Range_Graphs(plain) .AND. CD_AGG_Range_MirrorSize(plain) == 0, 'range:plain-empty') + CALL CD_AGG_End(plain, es, em) + END SUBROUTINE case_range_graph_host + + SUBROUTINE case_range_discard_at_parse() + !! The range files of an earlier run go once the deck has named them, so an initialisation that + !! fails after the parse leaves none. A rebuild of a run under way passes no root and leaves + !! them, and a deck that does not parse names none: the earlier run's outputs stand whole. + TYPE(CD_AGG_ModuleType) :: a + INTEGER :: es, u, ios + LOGICAL :: there + CHARACTER(512) :: em + + CALL write_mixed_channels_deck('agg_discard.dat', outputs_row='Ten1N1', line_outputs='r', & + extra_option='0.0 TMax', extra_option2='0.1 RangeStart') + ! refused after the parse: RangeStart beyond the host's run time + CALL seed_range_file('agg_discard.Line1.range.out') + CALL seed_range_file('agg_discard.Line2.range.out') + CALL CD_AGG_Init_From_Deck(a, 'agg_discard.dat', 0.05_wp, es, em, run_tmax=0.05_wp, range_files=.TRUE.) + CALL require(es /= CD_AGG_OK, 'range-discard:refused-without-root') + INQUIRE (FILE='agg_discard.Line1.range.out', EXIST=there) + CALL require(there, 'range-discard:no-root-leaves-the-file') + CALL CD_AGG_Init_From_Deck(a, 'agg_discard.dat', 0.05_wp, es, em, run_tmax=0.05_wp, range_files=.TRUE., & + range_discard_root='agg_discard') + CALL require(es /= CD_AGG_OK .AND. INDEX(em, 'RangeStart exceeds TMax') > 0, 'range-discard:refused: '//TRIM(em)) + INQUIRE (FILE='agg_discard.Line1.range.out', EXIST=there) + CALL require(.NOT. there, 'range-discard:line1-removed') + INQUIRE (FILE='agg_discard.Line2.range.out', EXIST=there) + CALL require(.NOT. there, 'range-discard:line2-removed') + ! a static solve that fails: the deck parsed, the equilibrium is not found + CALL write_coupled_rigid6_deck('agg_discard_st.dat', 0.0_wp, 0.0_wp, .FALSE., overloaded=.TRUE.) + CALL seed_range_file('agg_discard.Line1.range.out') + CALL seed_range_file('agg_discard.Line2.range.out') + CALL CD_AGG_Init_From_Deck(a, 'agg_discard_st.dat', 0.05_wp, es, em, run_tmax=0.05_wp, range_files=.TRUE., & + range_discard_root='agg_discard') + CALL require(es /= CD_AGG_OK .AND. INDEX(em, 'equilibrium not found') > 0, 'range-discard:statics-fail: '//TRIM(em)) + INQUIRE (FILE='agg_discard.Line1.range.out', EXIST=there) + CALL require(.NOT. there, 'range-discard:statics-fail-line1-removed') + INQUIRE (FILE='agg_discard.Line2.range.out', EXIST=there) + CALL require(.NOT. there, 'range-discard:statics-fail-line2-removed') + ! a deck that does not parse + OPEN (NEWUNIT=u, FILE='agg_discard_bad.dat', STATUS='REPLACE', ACTION='WRITE', IOSTAT=ios) + WRITE (u, '(A)') 'not a deck' + CLOSE (u) + CALL seed_range_file('agg_discard.Line1.range.out') + CALL CD_AGG_Init_From_Deck(a, 'agg_discard_bad.dat', 0.05_wp, es, em, run_tmax=0.05_wp, range_files=.TRUE., & + range_discard_root='agg_discard') + CALL require(es /= CD_AGG_OK, 'range-discard:bad-deck-refused') + INQUIRE (FILE='agg_discard.Line1.range.out', EXIST=there) + CALL require(there, 'range-discard:bad-deck-leaves-the-file') + OPEN (NEWUNIT=u, FILE='agg_discard.Line1.range.out', STATUS='OLD', IOSTAT=ios) + IF (ios == 0) CLOSE (u, STATUS='DELETE') + END SUBROUTINE case_range_discard_at_parse + + SUBROUTINE case_reopen_start() + !! A state mirror restored from before the first commit keeps the body's start open: the host + !! fluid fields of the first step re-solve the start accelerations, as in the run that was + !! never restored (a checkpoint inside the first mooring step). Without CD_AGG_Reopen_Start + !! they do not. + REAL(wp), PARAMETER :: DT = 0.005_wp + TYPE(CD_AGG_ModuleType) :: a, b, c + REAL(wp) :: r0(3, 1), vel(3, 1), acc(3, 1), lds(3, 1) + REAL(wp), ALLOCATABLE :: sa(:), sb(:), sc(:), fv(:, :), fa(:, :), fw(:) + INTEGER :: es, n, ia, nf + LOGICAL :: cv, st + CHARACTER(512) :: em + + CALL write_coupled_rigid6_deck('agg_reopen.dat', 1.0_wp, 0.5_wp, .FALSE.) + CALL CD_AGG_Init_From_Deck(a, 'agg_reopen.dat', DT, es, em, external_fluid=.TRUE.) + CALL require(es == CD_AGG_OK, 'reopen:init: '//TRIM(em)) + IF (es /= CD_AGG_OK) RETURN + CALL CD_AGG_Init_From_Deck(b, 'agg_reopen.dat', DT, es, em, external_fluid=.TRUE.) + CALL CD_AGG_Init_From_Deck(c, 'agg_reopen.dat', DT, es, em, external_fluid=.TRUE.) + n = CD_AGG_Rigid6_MirrorSize(a) + nf = CD_AGG_NFluidNodes(a, es, em) + ALLOCATE (fv(3, nf), fa(3, nf), fw(nf)) + fv = 0.0_wp + fv(1, :) = 0.4_wp + fa = 0.0_wp + fa(1, :) = 0.3_wp + fw = 0.0_wp + ALLOCATE (sa(n), sb(n), sc(n)) + CALL CD_AGG_Get_Rigid6_States(b, sb, es, em) + CALL CD_AGG_Set_Rigid6_States(b, sb, es, em) + CALL CD_AGG_Reopen_Start(b) + CALL CD_AGG_Get_Rigid6_States(c, sc, es, em) + CALL CD_AGG_Set_Rigid6_States(c, sc, es, em) + ! the first step: new fields, then an advance that moves the fairlead + CALL CD_AGG_SetFluidFields(a, fv, fa, fw, es, em) + CALL require(es == CD_AGG_OK, 'reopen:fields-a: '//TRIM(em)) + CALL CD_AGG_SetFluidFields(b, fv, fa, fw, es, em) + CALL require(es == CD_AGG_OK, 'reopen:fields-b: '//TRIM(em)) + CALL CD_AGG_SetFluidFields(c, fv, fa, fw, es, em) + CALL require(es == CD_AGG_OK, 'reopen:fields-c: '//TRIM(em)) + CALL CD_AGG_GetMovingPointMesh(a, r0, vel, acc, lds, es, em) + r0(1, 1) = r0(1, 1) + 0.01_wp + vel = 0.0_wp + vel(1, 1) = 0.2_wp + acc = 0.0_wp + acc(1, 1) = 1.0_wp + CALL CD_AGG_Step_Moving(a, DT, r0, vel, acc, cv, st, ia, es, em, t_committed=DT) + CALL require(es == CD_AGG_OK, 'reopen:step-a: '//TRIM(em)) + CALL CD_AGG_Step_Moving(b, DT, r0, vel, acc, cv, st, ia, es, em, t_committed=DT) + CALL require(es == CD_AGG_OK, 'reopen:step-b: '//TRIM(em)) + CALL CD_AGG_Step_Moving(c, DT, r0, vel, acc, cv, st, ia, es, em, t_committed=DT) + CALL require(es == CD_AGG_OK, 'reopen:step-c: '//TRIM(em)) + CALL CD_AGG_Get_Rigid6_States(a, sa, es, em) + CALL CD_AGG_Get_Rigid6_States(b, sb, es, em) + CALL CD_AGG_Get_Rigid6_States(c, sc, es, em) + CALL require(nan_max_abs(sb - sa) <= 0.0_wp, 'reopen:restored-equals-never-restored') + CALL require(nan_max_abs(sc - sa) > 0.0_wp, 'reopen:closed-start-differs') + CALL CD_AGG_End(a, es, em) + CALL CD_AGG_End(b, es, em) + CALL CD_AGG_End(c, es, em) + END SUBROUTINE case_reopen_start + + SUBROUTINE seed_range_file(path) + CHARACTER(*), INTENT(IN) :: path + INTEGER :: u, ios + OPEN (NEWUNIT=u, FILE=path, STATUS='REPLACE', ACTION='WRITE', IOSTAT=ios) + WRITE (u, '(A)') 'stale' + CLOSE (u) + END SUBROUTINE seed_range_file + + SUBROUTINE range_march(a, r0, dt, step, amplitude, label) + !! Advance to step's committed time with a small surge and heave of the fairleads about r0, + !! scaled by amplitude: the same motion for every instance at amplitude 1. + TYPE(CD_AGG_ModuleType), INTENT(INOUT) :: a + REAL(wp), INTENT(IN) :: r0(3, 2), dt, amplitude + INTEGER, INTENT(IN) :: step + CHARACTER(*), INTENT(IN) :: label + REAL(wp), PARAMETER :: OMEGA = 2.0_wp*PI/1.2_wp + REAL(wp) :: tc, tgt(3, 2), v(3, 2), ac(3, 2), wave + INTEGER :: ni, es + LOGICAL :: cv, st + CHARACTER(512) :: em + + tc = REAL(step, wp)*dt + wave = 0.2_wp*amplitude + tgt = r0 + tgt(1, :) = r0(1, :) + wave*SIN(OMEGA*tc) + tgt(3, :) = r0(3, :) + 0.5_wp*wave*SIN(OMEGA*tc) + v = 0.0_wp + v(1, :) = wave*OMEGA*COS(OMEGA*tc) + v(3, :) = 0.5_wp*wave*OMEGA*COS(OMEGA*tc) + ac = 0.0_wp + ac(1, :) = -wave*OMEGA**2*SIN(OMEGA*tc) + ac(3, :) = -0.5_wp*wave*OMEGA**2*SIN(OMEGA*tc) + CALL CD_AGG_Step_Moving(a, dt, tgt, v, ac, cv, st, ni, es, em, t_committed=tc) + CALL require(es == CD_AGG_OK .AND. cv, label//': '//TRIM(em)) + END SUBROUTINE range_march + + SUBROUTINE case_long_init_notes() + !! A cable that collects a long list of initialisation notes: the two stiffener notes of each + !! end, the friction resolution note and, appended after them on the line summary, the dtM + !! advisory. They run past the 1024 characters of a cable's note and of the OpenFAST shell's + !! old buffer; every note arrives whole, in a buffer of CD_INIT_NOTE_LEN. + TYPE(CD_AGG_ModuleType) :: agg + REAL(wp) :: ten, force(3), incl, decl, azi + INTEGER :: es, line_id, nlines, k + CHARACTER(512) :: em + CHARACTER(CD_INIT_NOTE_LEN) :: note + + CALL write_touchdown_cable_deck('agg_long_notes.dat', 'FairTen2', stiffeners=.TRUE.) + CALL CD_AGG_Init_From_Deck(agg, 'agg_long_notes.dat', 0.1_wp, es, em) + CALL require(es == CD_AGG_OK, 'long-notes:init: '//TRIM(em)) + IF (es /= CD_AGG_OK) RETURN + CALL CD_AGG_GetInitLine(agg, 2, line_id, ten, force, incl, decl, azi, es, em, note=note) + CALL require(es == CD_AGG_OK .AND. line_id == 2, 'long-notes:line') + WRITE (*, '(A,I0)') ' initialisation notes of the cable, characters: ', LEN_TRIM(note) + CALL require(LEN_TRIM(note) > 1024, 'long-notes:longer-than-the-old-buffer') + CALL require(INDEX(note, 'stiffener at End A spans') > 0 .AND. INDEX(note, 'stiffener at End A sleeves') > 0 .AND. & + INDEX(note, 'stiffener at End B spans') > 0 .AND. INDEX(note, 'stiffener at End B sleeves') > 0, & + 'long-notes:the-four-stiffener-notes') + CALL require(INDEX(note, 'seabed friction acts at the nodes') > 0, 'long-notes:the-friction-note') + CALL require(INDEX(note, 'axial period') > 0, 'long-notes:the-dtM-advisory-appended-last') + CALL require(INDEX(note, 'notes omitted') == 0, 'long-notes:nothing-omitted') + nlines = 1 + DO k = 1, LEN_TRIM(note) + IF (note(k:k) == NEW_LINE('a')) nlines = nlines + 1 + END DO + CALL require(nlines >= 6, 'long-notes:one-text-line-per-note') + CALL CD_AGG_End(agg, es, em) + END SUBROUTINE case_long_init_notes + SUBROUTINE check_static_profile(path, expected_rows) CHARACTER(*), INTENT(IN) :: path INTEGER, INTENT(IN) :: expected_rows @@ -1570,17 +1926,21 @@ SUBROUTINE case_coupled_point3_fluid() DEALLOCATE (xyz, fv, fa, wl) END SUBROUTINE case_coupled_point3_fluid - SUBROUTINE write_coupled_rigid6_deck(path, cda, ca, extra_free, deck_pose) + SUBROUTINE write_coupled_rigid6_deck(path, cda, ca, extra_free, deck_pose, overloaded) !! A submerged Rigid6 body hung between a Fixed anchor and a Coupled fairlead by two !! chain lines (still water). cda/ca parametrise the body fluid coefficients (0/0 is the !! supported pure-inertia+buoyancy+restoring case); extra_free adds a stray Free point to !! exercise the scope guard; deck_pose starts the body at its deck pose (bodyIC deck) - !! instead of its static equilibrium. + !! instead of its static equilibrium; overloaded flags both lines r and makes the body far too + !! heavy for them, so that the static equilibrium is not found. CHARACTER(*), INTENT(IN) :: path REAL(wp), INTENT(IN) :: cda, ca LOGICAL, INTENT(IN) :: extra_free - LOGICAL, INTENT(IN), OPTIONAL :: deck_pose + LOGICAL, INTENT(IN), OPTIONAL :: deck_pose, overloaded INTEGER :: u, ios + LOGICAL :: heavy + heavy = .FALSE. + IF (PRESENT(overloaded)) heavy = overloaded OPEN (NEWUNIT=u, FILE=path, STATUS='REPLACE', ACTION='WRITE', IOSTAT=ios) WRITE (u, '(A)') 'coupled rigid6 body deck' WRITE (u, '(A)') '--- LINE TYPES ---' @@ -1591,8 +1951,13 @@ SUBROUTINE write_coupled_rigid6_deck(path, cda, ca, extra_free, deck_pose) WRITE (u, '(A)') 'ID Type X Y Z Rot1 Rot2 Rot3 Mass Vol C33 C44 C55 CdA Ca Ixx Iyy Izz' WRITE (u, '(A)') '(-) (-) (m) (m) (m) (deg) (deg) (deg) (kg) (m3) (N/m) (Nm/rad) (Nm/rad) (m2) (-) '// & '(kgm2) (kgm2) (kgm2)' - WRITE (u, '(A,F6.3,1X,F6.3,A)') '1 Rigid6 0.0 0.0 -4.0 0.0 0.0 0.0 100.0 0.05 0.0 100.0 100.0 ', & - cda, ca, ' 20.0 20.0 20.0' + IF (heavy) THEN + WRITE (u, '(A,F6.3,1X,F6.3,A)') '1 Rigid6 0.0 0.0 -4.0 0.0 0.0 0.0 1.0e7 0.05 0.0 100.0 100.0 ', & + cda, ca, ' 20.0 20.0 20.0' + ELSE + WRITE (u, '(A,F6.3,1X,F6.3,A)') '1 Rigid6 0.0 0.0 -4.0 0.0 0.0 0.0 100.0 0.05 0.0 100.0 100.0 ', & + cda, ca, ' 20.0 20.0 20.0' + END IF WRITE (u, '(A)') '--- POINTS ---' WRITE (u, '(A)') 'ID Type X Y Z Mass Vol CdA Ca' WRITE (u, '(A)') '(-) (-) (m) (m) (m) (kg) (m3) (m2) (-)' @@ -1603,8 +1968,13 @@ SUBROUTINE write_coupled_rigid6_deck(path, cda, ca, extra_free, deck_pose) WRITE (u, '(A)') '--- LINES ---' WRITE (u, '(A)') 'ID NodeA NodeB Outputs' WRITE (u, '(A)') '(-) (-) (-) (-)' - WRITE (u, '(A)') '1 2 1 -' - WRITE (u, '(A)') '2 3 2 -' + IF (heavy) THEN + WRITE (u, '(A)') '1 2 1 r' + WRITE (u, '(A)') '2 3 2 r' + ELSE + WRITE (u, '(A)') '1 2 1 -' + WRITE (u, '(A)') '2 3 2 -' + END IF WRITE (u, '(A)') '--- SECTIONS ---' WRITE (u, '(A)') 'LineID LineType Length NumSegs' WRITE (u, '(A)') '(-) (-) (m) (-)' @@ -2582,6 +2952,77 @@ PURE FUNCTION test_zyx_dcm(theta) RESULT(m) m(3, 3) = cx*cy END FUNCTION test_zyx_dcm + SUBROUTINE case_retry_after_probe() + !! A host that retries a step after an output probe of it (the OpenFAST shell at dtM = DT): the + !! probe's setters clear the cached committed force, but the retried step must take the path + !! the first pass took, which reused that cache. A fresh evaluation differs from it in the last + !! bits where a friction anchor slid. The run with retries matches one without bit for bit. + TYPE(CD_AGG_ModuleType) :: agg, once + REAL(wp), PARAMETER :: DT = 0.05_wp, OMEGA = 2.0_wp*PI/8.0_wp, AMP = 12.0_wp + INTEGER, PARAMETER :: NSTEP = 160 + REAL(wp) :: r0(3, 2), tgt(3, 2), v(3, 2), a(3, 2), lds(3, 2), tc + INTEGER :: es, ni, s + LOGICAL :: cv, st + CHARACTER(512) :: em + + CALL write_touchdown_cable_deck('agg_retry_probe.dat', 'FairTen2') + CALL CD_AGG_Init_From_Deck(agg, 'agg_retry_probe.dat', DT, es, em) + CALL require(es == CD_AGG_OK, 'retry-probe:init: '//TRIM(em)) + IF (es /= CD_AGG_OK) RETURN + CALL CD_AGG_Init_From_Deck(once, 'agg_retry_probe.dat', DT, es, em) + CALL require(es == CD_AGG_OK, 'retry-probe:init-once: '//TRIM(em)) + IF (es /= CD_AGG_OK) RETURN + CALL CD_AGG_GetMovingPointMesh(agg, r0, v, a, lds, es, em) + DO s = 1, NSTEP + tc = REAL(s, wp)*DT + tgt = r0 + tgt(1, 2) = r0(1, 2) + AMP*SIN(OMEGA*tc) + v = 0.0_wp + v(1, 2) = AMP*OMEGA*COS(OMEGA*tc) + a = 0.0_wp + a(1, 2) = -AMP*OMEGA**2*SIN(OMEGA*tc) + CALL CD_AGG_Snapshot(agg, es, em) + CALL CD_AGG_Step_Moving(agg, DT, tgt, v, a, cv, st, ni, es, em, t_committed=tc) + CALL require(es == CD_AGG_OK .AND. cv, 'retry-probe:first-pass: '//TRIM(em)) + CALL retry_probe_cycle(agg, tgt, v, a, tc) + CALL CD_AGG_Restore(agg, es, em) + CALL require(es == CD_AGG_OK, 'retry-probe:restore: '//TRIM(em)) + CALL CD_AGG_Step_Moving(agg, DT, tgt, v, a, cv, st, ni, es, em, t_committed=tc) + CALL require(es == CD_AGG_OK .AND. cv, 'retry-probe:second-pass: '//TRIM(em)) + CALL retry_probe_cycle(agg, tgt, v, a, tc) + CALL CD_AGG_Snapshot(once, es, em) + CALL CD_AGG_Step_Moving(once, DT, tgt, v, a, cv, st, ni, es, em, t_committed=tc) + CALL require(es == CD_AGG_OK .AND. cv, 'retry-probe:once: '//TRIM(em)) + IF (es /= CD_AGG_OK) EXIT + CALL retry_probe_cycle(once, tgt, v, a, tc) + END DO + WRITE (*, '(A,ES10.3)') ' retried against single steps, largest cable state difference: ', & + nan_max_abs(agg%cables(1)%line%q - once%cables(1)%line%q) + CALL require(nan_max_abs(agg%cables(1)%line%q - once%cables(1)%line%q) <= 0.0_wp, 'retry-probe:positions') + CALL require(nan_max_abs(agg%cables(1)%line%v - once%cables(1)%line%v) <= 0.0_wp, 'retry-probe:velocities') + CALL require(nan_max_abs(agg%cables(1)%line%a - once%cables(1)%line%a) <= 0.0_wp, 'retry-probe:accelerations') + CALL CD_AGG_End(agg, es, em) + CALL CD_AGG_End(once, es, em) + END SUBROUTINE case_retry_after_probe + + SUBROUTINE retry_probe_cycle(a, tgt, v, acc, t) + !! An output probe at the committed kinematics and the cable's state mirror back after it, as + !! the OpenFAST shell's CalcOutput does at dtM = DT. + TYPE(CD_AGG_ModuleType), INTENT(INOUT) :: a + REAL(wp), INTENT(IN) :: tgt(:, :), v(:, :), acc(:, :), t + REAL(wp), ALLOCATABLE :: buf(:) + INTEGER :: es + CHARACTER(512) :: em + + ALLOCATE (buf(CD_HFMF_MirrorSize(a%cables(1)))) + CALL CD_HFMF_PackMirror(a%cables(1), buf, es, em) + CALL require(es == CD_HFMF_OK, 'retry-probe:pack: '//TRIM(em)) + CALL CD_AGG_UpdateStates_Moving(a, tgt, v, acc, es, em, output_probe=.TRUE.) + CALL require(es == CD_AGG_OK, 'retry-probe:probe: '//TRIM(em)) + CALL CD_HFMF_UnpackMirror(a%cables(1), buf, t, es, em) + CALL require(es == CD_HFMF_OK, 'retry-probe:unpack: '//TRIM(em)) + END SUBROUTINE retry_probe_cycle + SUBROUTINE case_correction_rewind() !! Correction-iteration semantics at the aggregate level (the OpenFAST shell's !! NumCrctn > 0 building block): snapshot a committed state, advance with motion A, @@ -4560,7 +5001,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] @@ -4587,9 +5031,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') @@ -4619,6 +5079,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. @@ -5285,10 +5769,12 @@ SUBROUTINE case_anchor_uplift_channels() CALL CD_AGG_End(agg, es, em) END SUBROUTINE case_anchor_uplift_channels - SUBROUTINE write_touchdown_cable_deck(path, outputs_row) + SUBROUTINE write_touchdown_cable_deck(path, outputs_row, stiffeners) !! IEA-15MW mooring chain (line 1, Fixed anchor point 2) + touchdown power cable (line 2, - !! Fixed anchor point 4) on a 200 m seabed, with the given OUTPUTS row(s). + !! Fixed anchor point 4) on a 200 m seabed, with the given OUTPUTS row(s); stiffeners adds a + !! bend stiffener over each end of the cable, one deck element long. CHARACTER(*), INTENT(IN) :: path, outputs_row + LOGICAL, INTENT(IN), OPTIONAL :: stiffeners INTEGER :: u, ios OPEN (NEWUNIT=u, FILE=path, STATUS='REPLACE', ACTION='WRITE', IOSTAT=ios) @@ -5320,6 +5806,15 @@ SUBROUTINE write_touchdown_cable_deck(path, outputs_row) WRITE (u, '(A)') '2 bare 80.0 20' WRITE (u, '(A)') '2 buoy 100.0 25' WRITE (u, '(A)') '2 bare 420.0 42' + IF (PRESENT(stiffeners)) THEN + IF (stiffeners) THEN + WRITE (u, '(A)') '--- STIFFENERS ---' + WRITE (u, '(A)') 'LineID End Length RootOD TipOD InnerD E Density' + WRITE (u, '(A)') '(-) (-) (m) (m) (m) (m) (Pa) (kg/m^3)' + WRITE (u, '(A)') '2 A 4.0 0.45 0.18 0.17 5.0e7 1150' + WRITE (u, '(A)') '2 B 4.0 0.45 0.18 0.17 5.0e7 1150' + END IF + END IF WRITE (u, '(A)') '--- OPTIONS ---' WRITE (u, '(A)') '9.80665 g' WRITE (u, '(A)') '1025.0 rhoW' @@ -5422,18 +5917,22 @@ SUBROUTINE write_control_section(u, rows) END DO END SUBROUTINE write_control_section - SUBROUTINE write_mixed_channels_deck(path, point_channel_id, outputs_row) + SUBROUTINE write_mixed_channels_deck(path, point_channel_id, outputs_row, line_outputs, extra_option, extra_option2) !! The write_mixed_deck geometry (cable = line 1, mooring chain = line 2) plus an OUTPUTS !! section exercising both objects and multiple channel kinds: FairTen/AnchTen on the mooring, !! and curvature / fairlead-angle / bend-moment / nodal-tension on the cable. outputs_row - !! replaces the default OUTPUTS row. + !! replaces the default OUTPUTS row, line_outputs the LINES Outputs flags of both lines; + !! extra_option and extra_option2 add OPTIONS rows. CHARACTER(*), INTENT(IN) :: path INTEGER, INTENT(IN), OPTIONAL :: point_channel_id - CHARACTER(*), INTENT(IN), OPTIONAL :: outputs_row + CHARACTER(*), INTENT(IN), OPTIONAL :: outputs_row, line_outputs, extra_option, extra_option2 INTEGER :: u, ios INTEGER :: point_id + CHARACTER(8) :: flags point_id = 0 IF (PRESENT(point_channel_id)) point_id = point_channel_id + flags = '-' + IF (PRESENT(line_outputs)) flags = line_outputs OPEN (NEWUNIT=u, FILE=path, STATUS='REPLACE', ACTION='WRITE', IOSTAT=ios) WRITE (u, '(A)') 'MIXED deck with OUTPUTS: EI=0 chain mooring + finite-EI lazy-wave cable' WRITE (u, '(A)') '--- LINE TYPES ---' @@ -5453,8 +5952,8 @@ SUBROUTINE write_mixed_channels_deck(path, point_channel_id, outputs_row) WRITE (u, '(A)') '--- LINES ---' WRITE (u, '(A)') 'ID NodeA NodeB Outputs' WRITE (u, '(A)') '(-) (-) (-) (-)' - WRITE (u, '(A)') '1 1 2 -' - WRITE (u, '(A)') '2 3 4 -' + WRITE (u, '(A)') '1 1 2 '//TRIM(flags) + WRITE (u, '(A)') '2 3 4 '//TRIM(flags) WRITE (u, '(A)') '--- SECTIONS ---' WRITE (u, '(A)') 'LineID LineType Length NumSegs' WRITE (u, '(A)') '(-) (-) (m) (-)' @@ -5469,6 +5968,8 @@ SUBROUTINE write_mixed_channels_deck(path, point_channel_id, outputs_row) WRITE (u, '(A)') '3.0e6 kBot' WRITE (u, '(A)') '3.0e5 cBot' WRITE (u, '(A)') '0.5 rhoInf' + IF (PRESENT(extra_option)) WRITE (u, '(A)') extra_option + IF (PRESENT(extra_option2)) WRITE (u, '(A)') extra_option2 WRITE (u, '(A)') '--- OUTPUTS ---' IF (point_id > 0) THEN WRITE (u, '(A,I0,A)') 'Point', point_id, 'px' diff --git a/tests/test_seabed_drift_note.py b/tests/test_seabed_drift_note.py new file mode 100644 index 00000000..08ea1da7 --- /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 38036bcc..1f136916 100644 --- a/tests/test_static_check.py +++ b/tests/test_static_check.py @@ -13,7 +13,15 @@ 1 s run, with the range graph of that row; * RangeStart after a TMax > 0 is still refused; * without dtM and TMax the deck stops on its first dynamic input, and the message says to - give dtM with TMax = 0. + 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, 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; +* a positive TMax without dtM is still refused as a dynamic run. usage: test_static_check.py --exe --examples --output """ @@ -29,7 +37,43 @@ from pathlib import Path FAILURES: list[str] = [] -HINT = "to check a dynamic deck's static start only, give dtM with TMax = 0" +HINT = "to check a dynamic deck's static start only, give TMax = 0" +# one deck of each route of a static check +ROUTES = ("dynamic_chain_held", "dynamic_chain_current", "dynamic_chain_waves", "lazy_wave_buoyancy_modules", + "iea15mw_umaine_mixed_cabledyn", "mixed_body_rods_points", "rigid6_buoy", "rod_moored_spar", + "connect_weighted_point", "lazy_wave_vessel_motion", "lazy_wave_vessel_rao", + "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: @@ -116,7 +160,28 @@ def case(exe: str, examples: Path, out: Path, deck_name: str, record: str, keywo p_none = run(exe, work, "none", set_option(set_option(text, "TMax", None), "dtM", None)) check(p_none.returncode != 0 and HINT in p_none.stderr, - f"{deck_name}: without dtM and TMax the message says to give dtM with TMax = 0") + f"{deck_name}: without dtM and TMax the message says to give TMax = 0") + + +def no_dtm(exe: str, work: Path, deck_name: str) -> None: + """The deck at TMax = 0 with and without dtM, and at a positive TMax (past any RangeStart) without dtM.""" + text = (work / f"{deck_name}.dat").read_text(encoding="utf-8") + zero = set_option(text, "TMax", "0.0") + p_with = run(exe, work, f"{deck_name}_with", zero) + p_none = run(exe, work, f"{deck_name}_none", set_option(zero, "dtM", None)) + check(p_with.returncode == 0 and p_none.returncode == 0, + f"{deck_name}: TMax = 0 runs with and without dtM (exit {p_with.returncode}, {p_none.returncode})") + if p_none.returncode != 0: + print(p_none.stderr[-1500:]) + return + log = p_none.stdout + p_none.stderr + check("dynamic run" not in log and "dtM =" not in log, + f"{deck_name}: and says nothing of a dynamic run or a time step") + same = (work / f"{deck_name}_with.out").read_bytes() == (work / f"{deck_name}_none.out").read_bytes() + check(same, f"{deck_name}: and writes the same output byte for byte") + p_late = run(exe, work, f"{deck_name}_late", set_option(set_option(text, "TMax", "1000.0"), "dtM", None)) + check(p_late.returncode == 1 and "dynamic run requires both dtM and TMax" in p_late.stderr, + f"{deck_name}: a positive TMax without dtM is still refused (exit {p_late.returncode})") def main() -> int: @@ -134,6 +199,19 @@ def main() -> int: "vesselMotion") case(args.exe, examples, out, "lozon_gomex80_power_cable_motion", "data/lozon/gomex80_heave_3m_12s_dt005.txt", "motionFile") + work = out / "no_dtm" + shutil.copytree(examples, work) + # the mixed aggregate on a vessel record + mixed = (work / "iea15mw_umaine_mixed_cabledyn.dat").read_text(encoding="utf-8") + 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") + # 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 442bcd28..de5ee56a 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", diff --git a/tests/test_vessel_motion.f90 b/tests/test_vessel_motion.f90 index 2dd92d10..1f2616f6 100644 --- a/tests/test_vessel_motion.f90 +++ b/tests/test_vessel_motion.f90 @@ -25,6 +25,9 @@ PROGRAM test_vessel_motion !! 7. Fail-closed option and file grammar. !! 8. Deck, vesselRAO in a spread sea: each spread component drives the vessel with its own !! heading and phase; a nonlinear stream wave is rejected by name. + !! 9. Deck, vesselRAO in a WaveKin 7 sea of rows with different directions referred to a point + !! (WaveRefX, WaveRefY): each row drives the vessel with its own heading, and its phase + !! from the position of the vessel relative to that point. USE CableDyn_Precision, ONLY: wp USE CableDyn_Vessel, ONLY: CD_VesselRAOType, CD_Vessel_Euler_DCM, CD_Vessel_Euler_Angular, & CD_Vessel_Point_Kinematics, CD_Vessel_RAO_Set, CD_Vessel_RAO_Eval, & @@ -49,6 +52,7 @@ PROGRAM test_vessel_motion CALL case_deck_rao_regular() CALL case_deck_rao_irregular() CALL case_deck_rao_spread() + CALL case_deck_rao_wavekin7() CALL case_deck_rao_stream_rejected() CALL case_deck_rejections() @@ -701,6 +705,67 @@ SUBROUTINE case_deck_rao_spread() CALL require(err < 1.0e-5_wp, 'rao spread: every spread component drives the vessel with its own phase') END SUBROUTINE case_deck_rao_spread + SUBROUTINE case_deck_rao_wavekin7() + !! A heave-only RAO over the full circle of headings: the fairlead below the reference point + !! heaves with the elevation of the file's rows there. + INTEGER, PARAMETER :: NC = 3 + REAL(wp), PARAMETER :: W(NC) = [0.6_wp, 0.75_wp, 0.9_wp], RE(NC) = [0.5_wp, -0.2_wp, 0.3_wp] + REAL(wp), PARAMETER :: IM(NC) = [0.1_wp, 0.4_wp, -0.25_wp], BETA(NC) = [0.65_wp, 2.9_wp, -2.2_wp] + REAL(wp), PARAMETER :: X0 = 31.5_wp, Y0 = -12.0_wp + CHARACTER(40) :: opts(9) + CHARACTER(512) :: msg + LOGICAL :: ok + REAL(wp), ALLOCATABLE :: d(:, :) + REAL(wp) :: kk(NC), t, eta, err, dist, r, rd, rdd + INTEGER :: nr, n, i, es, u + CHARACTER(200) :: em + OPEN (NEWUNIT=u, FILE='vm_wk7/vm_rao.txt', STATUS='REPLACE', ACTION='WRITE') + DO i = 1, 2 + WRITE (u, '(A,F7.1)') 'HEADING ', MERGE(-180.0_wp, 179.0_wp, i == 1) + WRITE (u, '(A)') '0.5 0 0 0 0 1 0 0 0 0 0 0 0' + WRITE (u, '(A)') '100 0 0 0 0 1 0 0 0 0 0 0 0' + END DO + CLOSE (u) + OPEN (NEWUNIT=u, FILE='vm_wk7/wave_frequencies.txt', STATUS='REPLACE', ACTION='WRITE') + WRITE (u, '(A)') '0.0 0.0 0.0 0.0' + DO i = 1, NC + WRITE (u, '(4ES24.16)') W(i), RE(i), IM(i), BETA(i) + END DO + CLOSE (u) + opts(1) = '0.05 dtM' + opts(2) = '20.0 TMax' + opts(3) = '60.0 WtrDpth' + opts(4) = '7 WaveKin' + opts(5) = 'vm_rao.txt vesselRAO' + opts(6) = '90.0|0.0|0.0 vesselRef' + opts(7) = '31.5 WaveRefX' + opts(8) = '-12.0 WaveRefY' + opts(9) = '6.0 rampTime' + CALL write_deck('vm_wk7/vm_wk7.dat', opts) + CALL run('vm_wk7/vm_wk7.dat', 'vm_wk7/out', ok, msg) + CALL require(ok, 'rao WaveKin 7: deck runs: '//TRIM(msg)) + CALL read_out('vm_wk7/out.out', d, nr, 10) + CALL require(nr == 401, 'rao WaveKin 7: full record written') + IF (nr /= 401) RETURN + DO i = 1, NC + CALL CD_Solve_Dispersion_Wavenumber(W(i), 60.0_wp, 9.80665_wp, kk(i), es, em) + CALL require(es == 0, 'rao WaveKin 7: dispersion solve: '//TRIM(em)) + END DO + err = 0.0_wp + DO n = 1, nr + t = d(1, n) + CALL CD_Vessel_Ramp(6.0_wp, t, r, rd, rdd) + eta = 0.0_wp + DO i = 1, NC + dist = (90.0_wp - X0)*COS(BETA(i)) + (0.0_wp - Y0)*SIN(BETA(i)) + eta = eta + SQRT(RE(i)**2 + IM(i)**2)*SIN(W(i)*t - kk(i)*dist + ATAN2(IM(i), RE(i))) + END DO + err = MAX(err, ABS(d(7, n) - (-14.0_wp + r*eta))) + END DO + WRITE (*, '(A,ES12.4)') ' rao WaveKin 7: max fairlead heave error [m] = ', err + CALL require(err < 1.0e-5_wp, 'rao WaveKin 7: each row drives the vessel with its own heading and phase') + END SUBROUTINE case_deck_rao_wavekin7 + SUBROUTINE case_deck_rao_stream_rejected() CHARACTER(40) :: opts(6) CHARACTER(512) :: msg diff --git a/tests/test_wave_component_sum.f90 b/tests/test_wave_component_sum.f90 new file mode 100644 index 00000000..eddb3d3c --- /dev/null +++ b/tests/test_wave_component_sum.f90 @@ -0,0 +1,381 @@ +! File: tests/test_wave_component_sum.f90 +! SPDX-License-Identifier: Apache-2.0 +! Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +PROGRAM test_wave_component_sum + !! Gate for the fast sum of CD_Component_Wave_Kinematics against an independent reference, the + !! direct sum of the same linear components in quadruple precision with the intrinsics, across + !! wave heights (a calm sea to one near breaking in shallow water), depths (shallow, and deep + !! enough for exp(-2 k depth) to underflow), record lengths (one component to more than the + !! kernel's phase buffer) and points (near the surface, mid-depth, near and at the seabed, above + !! still water, above the crest), with and without the tables and the amplitude scale: + !! 1. elevation, velocity, acceleration and dynamic pressure within round-off of the reference: + !! 8 ulp of the sum of the term magnitudes, plus the rounding of the double phases (root + !! sum of squares: they are independent) and, for the stretched depth, of the elevation; + !! 2. the sum is a function of its arguments alone: the same call after others gives the same + !! bits, and so do the tables in any thread count (the sums keep a fixed order); + !! 3. amplitude_scale equals a scaled table to round-off; + !! 4. a short-crested table (a direction per component) equals the two long-crested tables it + !! is made of to round-off, for directions in every quadrant, one direction everywhere + !! equals the long-crested sum, and the stretched sum equals the unstretched one at the + !! stretched depth; half a table of cosines fails closed. + USE, INTRINSIC :: ISO_FORTRAN_ENV, ONLY: INT64 + USE CableDyn_Precision, ONLY: wp + USE CableDyn_Hydro, ONLY: CD_Component_Wave_Kinematics, CD_Solve_Dispersion_Wavenumber, CD_HYDRO_OK + IMPLICIT NONE + INTEGER, PARAMETER :: qp = SELECTED_REAL_KIND(30) + REAL(wp), PARAMETER :: GRAV = 9.80665_wp, CUTOFF = 3.6_wp, DIRECTION = 25.0_wp + REAL(qp), PARAMETER :: PI_Q = 3.14159265358979323846264338327950288_qp + REAL(wp), PARAMETER :: PI = 3.141592653589793_wp + INTEGER, PARAMETER :: NDEPTH = 4, NTABLE = 9, NHEIGHT = 3 + REAL(wp), PARAMETER :: DEPTHS(NDEPTH) = [8.0_wp, 60.0_wp, 400.0_wp, 3000.0_wp] + ! significant heights; a sea steeper than 0.8 of the depth is left out + REAL(wp), PARAMETER :: HEIGHTS(NHEIGHT) = [0.5_wp, 4.0_wp, 14.0_wp] + ! component counts: below, at and above a lane block and the buffer, and 300 s to 9000 s records + INTEGER, PARAMETER :: TABLES(NTABLE) = [1, 2, 63, 64, 65, 172, 2406, 4097, 5156] + INTEGER :: nfail, id, ih, it + REAL(wp) :: worst + + nfail = 0 + worst = 0.0_wp + DO id = 1, NDEPTH + DO ih = 1, NHEIGHT + IF (HEIGHTS(ih) > 0.8_wp*DEPTHS(id)) CYCLE + DO it = 1, NTABLE + ! the longest tables at the depths that bracket the deep-water branch + IF (TABLES(it) > 4096 .AND. (ih /= 2 .OR. (id /= 2 .AND. id /= 4))) CYCLE + CALL check_sea(DEPTHS(id), HEIGHTS(ih), TABLES(it)) + END DO + END DO + END DO + WRITE (*, '(A,F6.3)') ' largest error over its round-off bound: ', worst + CALL check_pure() + CALL check_short_crested() + + IF (nfail > 0) THEN + WRITE (*, '(A,I0,A)') 'FAIL: ', nfail, ' assertion(s) failed' + ERROR STOP 1 + END IF + WRITE (*, '(A)') 'PASS: fast component sum against the quadruple-precision reference' + +CONTAINS + + SUBROUTINE make_table(n, depth, hs, om, k, amp, ph, e2kd, tail) + !! A record of n components up to CUTOFF: a peaked sea of significant height hs plus the + !! leakage tail of a finite record, with spread phases. + INTEGER, INTENT(IN) :: n + REAL(wp), INTENT(IN) :: depth, hs + REAL(wp), INTENT(OUT) :: om(n), k(n), amp(n), ph(n), e2kd(n), tail(3, n) + REAL(wp) :: scale + INTEGER :: i, es + CHARACTER(200) :: em + DO i = 1, n + om(i) = CUTOFF*REAL(i, wp)/REAL(n, wp) + amp(i) = EXP(-0.5_wp*((om(i) - 0.63_wp)/0.12_wp)**2) + 1.0e-2_wp/REAL(i, wp) + ph(i) = MODULO(2.399963_wp*REAL(i, wp)**2, 2.0_wp*PI) + CALL CD_Solve_Dispersion_Wavenumber(om(i), depth, GRAV, k(i), es, em) + CALL require(es == CD_HYDRO_OK, 'dispersion solve: '//TRIM(em)) + e2kd(i) = EXP(-2.0_wp*(k(i)*depth)) + END DO + ! hs = 4 sqrt(sum a^2/2) + scale = hs/(4.0_wp*SQRT(0.5_wp*SUM(amp**2))) + amp = scale*amp + tail(:, n) = amp(n)*[om(n), om(n)**2, om(n)**2/k(n)] + DO i = n - 1, 1, -1 + tail(:, i) = tail(:, i + 1) + amp(i)*[om(i), om(i)**2, om(i)**2/k(i)] + END DO + END SUBROUTINE make_table + + SUBROUTINE reference(x, y, z, t, depth, stretch, scl, om, k, amp, ph, eta, vel, acc, pdyn, tol_eta, tol_u, tol_a, & + tol_p, dry) + !! The direct sum in quadruple precision, in the exponential form of the depth ratios (its + !! range holds them at any depth here), and the round-off bounds of the double sum. + REAL(wp), INTENT(IN) :: x, y, z, t, depth, scl, om(:), k(:), amp(:), ph(:) + LOGICAL, INTENT(IN) :: stretch + REAL(qp), INTENT(OUT) :: eta, vel(3), acc(3), pdyn, tol_eta, tol_u, tol_a, tol_p + LOGICAL, INTENT(OUT) :: dry + REAL(qp) :: cb, sb, xa, th(SIZE(om)), ci(SIZE(om)), si(SIZE(om)), a, w, ze, d, zq, e1, e2, e3, ch, sh, eps + REAL(qp) :: mag_e, rss_e, mag(3), rss(3), grad(3), term + INTEGER :: i + + eps = REAL(EPSILON(1.0_wp), qp) + cb = COS(REAL(DIRECTION, qp)*PI_Q/180.0_qp) + sb = SIN(REAL(DIRECTION, qp)*PI_Q/180.0_qp) + xa = REAL(x, qp)*cb + REAL(y, qp)*sb + eta = 0.0_qp + mag_e = 0.0_qp + rss_e = 0.0_qp + DO i = 1, SIZE(om) + th(i) = REAL(k(i), qp)*xa - REAL(om(i), qp)*REAL(t, qp) + REAL(ph(i), qp) + ci(i) = COS(th(i)) + si(i) = SIN(th(i)) + a = REAL(scl, qp)*REAL(amp(i), qp) + eta = eta + a*ci(i) + mag_e = mag_e + a + rss_e = rss_e + (a*(1.0_qp + ABS(th(i))))**2 + END DO + tol_eta = 8.0_qp*eps*mag_e + 8.0_qp*eps*SQRT(rss_e) + vel = 0.0_qp + acc = 0.0_qp + pdyn = 0.0_qp + tol_u = 0.0_qp + tol_a = 0.0_qp + tol_p = 0.0_qp + d = REAL(depth, qp) + zq = REAL(z, qp) + dry = stretch .AND. zq > eta + IF (dry) RETURN + ze = zq + IF (stretch) ze = (zq - eta)*d/(d + eta) + mag = 0.0_qp + rss = 0.0_qp + grad = 0.0_qp + DO i = 1, SIZE(om) + e1 = EXP(REAL(k(i), qp)*ze) + e2 = EXP(-REAL(k(i), qp)*(ze + 2.0_qp*d)) + e3 = 1.0_qp - EXP(-2.0_qp*REAL(k(i), qp)*d) + ch = (e1 + e2)/e3 + sh = (e1 - e2)/e3 + a = REAL(scl, qp)*REAL(amp(i), qp) + w = a*REAL(om(i), qp) + vel(1) = vel(1) + w*ch*ci(i)*cb + vel(2) = vel(2) + w*ch*ci(i)*sb + vel(3) = vel(3) + w*sh*si(i) + term = w*ch + mag(1) = mag(1) + term + rss(1) = rss(1) + (term*(1.0_qp + ABS(th(i))))**2 + grad(1) = grad(1) + term*REAL(k(i), qp) + w = w*REAL(om(i), qp) + acc(1) = acc(1) + w*ch*si(i)*cb + acc(2) = acc(2) + w*ch*si(i)*sb + acc(3) = acc(3) - w*sh*ci(i) + term = w*ch + mag(2) = mag(2) + term + rss(2) = rss(2) + (term*(1.0_qp + ABS(th(i))))**2 + grad(2) = grad(2) + term*REAL(k(i), qp) + w = w/REAL(k(i), qp) + pdyn = pdyn + w*ch*ci(i) + term = w*ch + mag(3) = mag(3) + term + rss(3) = rss(3) + (term*(1.0_qp + ABS(th(i))))**2 + grad(3) = grad(3) + term*REAL(k(i), qp) + END DO + ! the double elevation moves the stretched depth by about its error, and the double exponent + ! k z carries 1 ulp of its own: k |z| eps of each term + tol_u = 8.0_qp*eps*(mag(1) + ABS(ze)*grad(1)) + 8.0_qp*eps*SQRT(rss(1)) + tol_a = 8.0_qp*eps*(mag(2) + ABS(ze)*grad(2)) + 8.0_qp*eps*SQRT(rss(2)) + tol_p = 8.0_qp*eps*(mag(3) + ABS(ze)*grad(3)) + 8.0_qp*eps*SQRT(rss(3)) + IF (stretch) THEN + tol_u = tol_u + tol_eta*grad(1) + tol_a = tol_a + tol_eta*grad(2) + tol_p = tol_p + tol_eta*grad(3) + END IF + END SUBROUTINE reference + + SUBROUTINE check_sea(depth, hs, n) + REAL(wp), INTENT(IN) :: depth, hs + INTEGER, INTENT(IN) :: n + INTEGER, PARAMETER :: NPOINT = 7 + REAL(wp) :: om(n), k(n), amp(n), ph(n), e2kd(n), tail(3, n) + REAL(wp) :: zs(NPOINT), x, y, t, scl, eta, u(3), a(3), p, depth_eps + REAL(qp) :: eta_q, u_q(3), a_q(3), p_q, tol_e, tol_u, tol_a, tol_p + LOGICAL :: stretch(NPOINT), dry + INTEGER :: ip, variant, es + CHARACTER(200) :: em + CHARACTER(120) :: label + + CALL make_table(n, depth, hs, om, k, amp, ph, e2kd, tail) + depth_eps = 1.0e-3_wp*depth + ! near the surface, mid-depth, deep, near the seabed, at it; above still water (stretched, so + ! wet under a crest and dry above it), and a point above the surface that is not stretched + zs = [-0.02_wp*depth, -0.3_wp*depth, -0.7_wp*depth, -depth + depth_eps, -depth, 0.3_wp*hs + 0.01_wp, 0.01_wp*depth] + stretch = [.TRUE., .TRUE., .TRUE., .TRUE., .FALSE., .TRUE., .FALSE.] + DO ip = 1, NPOINT + x = 37.0_wp*REAL(ip, wp) - 120.0_wp + y = 11.0_wp*REAL(ip, wp) - 30.0_wp + t = 3600.0_wp + 173.3_wp*REAL(ip, wp) + ! the table variants share the reference when they share the scale + scl = MERGE(0.37_wp, 1.0_wp, MOD(ip, 2) == 0) + CALL reference(x, y, zs(ip), t, depth, stretch(ip), scl, om, k, amp, ph, eta_q, u_q, a_q, p_q, tol_e, tol_u, & + tol_a, tol_p, dry) + DO variant = 1, 3 + WRITE (label, '(A,F7.1,A,F5.1,A,I0,A,F9.3,A,I0)') 'depth ', depth, ' Hs ', hs, ' n ', n, ' z ', zs(ip), & + ' variant ', variant + SELECT CASE (variant) + CASE (1) + CALL CD_Component_Wave_Kinematics(x, y, zs(ip), t, depth, DIRECTION, stretch(ip), om, k, amp, ph, eta, u, a, & + es, em, amplitude_scale=scl, pdyn=p) + CASE (2) + CALL CD_Component_Wave_Kinematics(x, y, zs(ip), t, depth, DIRECTION, stretch(ip), om, k, amp, ph, eta, u, a, & + es, em, inputs_validated=.TRUE., amplitude_scale=scl, pdyn=p, exp_m2kd=e2kd) + CASE (3) + CALL CD_Component_Wave_Kinematics(x, y, zs(ip), t, depth, DIRECTION, stretch(ip), om, k, amp, ph, eta, u, a, & + es, em, inputs_validated=.TRUE., amplitude_scale=scl, pdyn=p, & + exp_m2kd=e2kd, tail_sums=tail) + END SELECT + CALL require(es == CD_HYDRO_OK, 'evaluated at '//TRIM(label)//': '//TRIM(em)) + CALL within(eta, eta_q, tol_e, 'elevation at '//TRIM(label)) + IF (dry) THEN + CALL require(ALL(u == 0.0_wp) .AND. ALL(a == 0.0_wp) .AND. p == 0.0_wp, & + 'dry above the crest at '//TRIM(label)) + CYCLE + END IF + CALL within(u(1), u_q(1), tol_u, 'velocity x at '//TRIM(label)) + CALL within(u(2), u_q(2), tol_u, 'velocity y at '//TRIM(label)) + CALL within(u(3), u_q(3), tol_u, 'velocity z at '//TRIM(label)) + CALL within(a(1), a_q(1), tol_a, 'acceleration x at '//TRIM(label)) + CALL within(a(2), a_q(2), tol_a, 'acceleration y at '//TRIM(label)) + CALL within(a(3), a_q(3), tol_a, 'acceleration z at '//TRIM(label)) + CALL within(p, p_q, tol_p, 'pressure at '//TRIM(label)) + END DO + END DO + END SUBROUTINE check_sea + + SUBROUTINE check_pure() + !! Purity and thread independence over a table past the buffer, and the amplitude scale. + INTEGER, PARAMETER :: N = 4200, NP = 64 + REAL(wp) :: om(N), k(N), amp(N), ph(N), e2kd(N), tail(3, N), amp_s(N) + REAL(wp) :: pts(3, NP), one(7, NP), team(7, NP), eta, u(3), a(3), eta_s, u_s(3), a_s(3), tol + INTEGER :: i, nthreads, es + CHARACTER(200) :: em + + CALL make_table(N, 60.0_wp, 4.0_wp, om, k, amp, ph, e2kd, tail) + DO i = 1, NP + pts(:, i) = [3.1_wp*REAL(i, wp), -1.7_wp*REAL(i, wp), -0.9_wp*REAL(i, wp) - 0.2_wp] + END DO + DO i = 1, NP + CALL sample(pts(:, i), 1234.5_wp, om, k, amp, ph, e2kd, tail, one(:, i)) + END DO + ! the same call after others, and on a team of threads, gives the same bits + DO nthreads = 1, 4 + team = 0.0_wp + !$OMP PARALLEL DO NUM_THREADS(nthreads) DEFAULT(SHARED) PRIVATE(i) SCHEDULE(dynamic) + DO i = NP, 1, -1 + CALL sample(pts(:, i), 1234.5_wp, om, k, amp, ph, e2kd, tail, team(:, i)) + END DO + !$OMP END PARALLEL DO + CALL require(ALL(TRANSFER(team, [0_INT64]) == TRANSFER(one, [0_INT64])), 'the sum depends on its arguments alone') + END DO + ! amplitude_scale against a scaled table + amp_s = 0.37_wp*amp + CALL CD_Component_Wave_Kinematics(5.0_wp, 2.0_wp, -3.0_wp, 700.0_wp, 60.0_wp, DIRECTION, .TRUE., om, k, amp, ph, & + eta, u, a, es, em, amplitude_scale=0.37_wp) + CALL require(es == CD_HYDRO_OK, 'scaled sum: '//TRIM(em)) + CALL CD_Component_Wave_Kinematics(5.0_wp, 2.0_wp, -3.0_wp, 700.0_wp, 60.0_wp, DIRECTION, .TRUE., om, k, amp_s, ph, & + eta_s, u_s, a_s, es, em) + CALL require(es == CD_HYDRO_OK, 'scaled table: '//TRIM(em)) + tol = 16.0_wp*EPSILON(1.0_wp)*SUM(amp_s*om*om) + CALL require(ABS(eta - eta_s) <= tol .AND. MAXVAL(ABS(u - u_s)) <= tol .AND. MAXVAL(ABS(a - a_s)) <= tol, & + 'amplitude_scale equals a scaled table to round-off') + END SUBROUTINE check_pure + + SUBROUTINE check_short_crested() + !! A table past the phase buffer in which every other component goes in direction a or b. + INTEGER, PARAMETER :: N = 4200, NH = N/2, NPAIR = 5 + REAL(wp), PARAMETER :: PAIRS(2, NPAIR) = RESHAPE([0.0_wp, 90.0_wp, 37.5_wp, 127.0_wp, 200.0_wp, 311.3_wp, & + -45.0_wp, 233.3_wp, 180.0_wp, 271.0_wp], [2, NPAIR]) + REAL(wp), PARAMETER :: X = 41.0_wp, Y = -17.5_wp, T = 1234.5_wp, D = 60.0_wp + REAL(wp) :: om(N), k(N), amp(N), ph(N), e2kd(N), tail(3, N), cb(N), sb(N), half(4, NH, 2) + REAL(wp) :: e0, u0(3), a0(3), p0, e1, u1(3), a1(3), p1, e2, u2(3), a2(3), p2, tol, ze + REAL(wp), PARAMETER :: ZS(3) = [-0.8_wp, -7.0_wp, -33.0_wp] + INTEGER :: ip, iz, es + CHARACTER(200) :: em + + CALL make_table(N, D, 4.0_wp, om, k, amp, ph, e2kd, tail) + tol = 32.0_wp*EPSILON(1.0_wp)*SUM(amp*MAX(1.0_wp, om*om, om*om/k)) + DO iz = 1, 2 + half(1, :, iz) = om(iz:N:2) + half(2, :, iz) = k(iz:N:2) + half(3, :, iz) = amp(iz:N:2) + half(4, :, iz) = ph(iz:N:2) + END DO + DO ip = 1, NPAIR + cb(1:N:2) = COS(PAIRS(1, ip)*PI/180.0_wp) + sb(1:N:2) = SIN(PAIRS(1, ip)*PI/180.0_wp) + cb(2:N:2) = COS(PAIRS(2, ip)*PI/180.0_wp) + sb(2:N:2) = SIN(PAIRS(2, ip)*PI/180.0_wp) + DO iz = 1, SIZE(ZS) + CALL CD_Component_Wave_Kinematics(X, Y, ZS(iz), T, D, 0.0_wp, .FALSE., om, k, amp, ph, e0, u0, a0, es, em, & + pdyn=p0, exp_m2kd=e2kd, tail_sums=tail, comp_cos=cb, comp_sin=sb) + CALL require(es == CD_HYDRO_OK, 'short-crested sum: '//TRIM(em)) + CALL long_crested(X, Y, ZS(iz), T, D, PAIRS(1, ip), half(:, :, 1), e1, u1, a1, p1, es, em) + CALL require(es == CD_HYDRO_OK, 'first long-crested half: '//TRIM(em)) + CALL long_crested(X, Y, ZS(iz), T, D, PAIRS(2, ip), half(:, :, 2), e2, u2, a2, p2, es, em) + CALL require(es == CD_HYDRO_OK, 'second long-crested half: '//TRIM(em)) + CALL require(ABS(e0 - e1 - e2) <= tol .AND. MAXVAL(ABS(u0 - u1 - u2)) <= tol .AND. & + MAXVAL(ABS(a0 - a1 - a2)) <= tol .AND. ABS(p0 - p1 - p2) <= tol, & + 'a short-crested table is the sum of its long-crested halves') + ! stretched: the sum at the stretched depth of the one total elevation + CALL CD_Component_Wave_Kinematics(X, Y, ZS(iz), T, D, 0.0_wp, .TRUE., om, k, amp, ph, e1, u1, a1, es, em, & + exp_m2kd=e2kd, tail_sums=tail, comp_cos=cb, comp_sin=sb) + CALL require(es == CD_HYDRO_OK, 'stretched short-crested sum: '//TRIM(em)) + IF (ZS(iz) > e1) CYCLE ! above this trough the point is dry + ze = (ZS(iz) - e1)*D/(D + e1) + CALL CD_Component_Wave_Kinematics(X, Y, ze, T, D, 0.0_wp, .FALSE., om, k, amp, ph, e2, u2, a2, es, em, & + exp_m2kd=e2kd, comp_cos=cb, comp_sin=sb) + CALL require(es == CD_HYDRO_OK, 'at the stretched depth: '//TRIM(em)) + CALL require(MAXVAL(ABS(u1 - u2)) <= tol .AND. MAXVAL(ABS(a1 - a2)) <= tol, & + 'stretched short-crested sum at the depth of the total elevation') + END DO + ! one direction everywhere + cb = COS(PAIRS(2, ip)*PI/180.0_wp) + sb = SIN(PAIRS(2, ip)*PI/180.0_wp) + CALL CD_Component_Wave_Kinematics(X, Y, -4.0_wp, T, D, 0.0_wp, .TRUE., om, k, amp, ph, e1, u1, a1, es, em, & + comp_cos=cb, comp_sin=sb) + CALL CD_Component_Wave_Kinematics(X, Y, -4.0_wp, T, D, PAIRS(2, ip), .TRUE., om, k, amp, ph, e2, u2, a2, es, em) + CALL require(ABS(e1 - e2) <= tol .AND. MAXVAL(ABS(u1 - u2)) <= tol .AND. MAXVAL(ABS(a1 - a2)) <= tol, & + 'one direction per component equals the long-crested sum') + END DO + CALL CD_Component_Wave_Kinematics(X, Y, -4.0_wp, T, D, 0.0_wp, .TRUE., om, k, amp, ph, e1, u1, a1, es, em, & + comp_cos=cb) + CALL require(es /= CD_HYDRO_OK, 'comp_cos without comp_sin fails closed') + CALL CD_Component_Wave_Kinematics(X, Y, -4.0_wp, T, D, 0.0_wp, .TRUE., om, k, amp, ph, e1, u1, a1, es, em, & + comp_cos=cb(1:NH), comp_sin=sb(1:NH)) + CALL require(es /= CD_HYDRO_OK, 'direction tables of the wrong length fail closed') + END SUBROUTINE check_short_crested + + SUBROUTINE long_crested(x, y, z, t, depth, direction, table, eta, u, a, p, es, em) + !! The unstretched sum of the table rows omega, k, amplitude and phase along one direction. + REAL(wp), INTENT(IN) :: x, y, z, t, depth, direction, table(:, :) + REAL(wp), INTENT(OUT) :: eta, u(3), a(3), p + INTEGER, INTENT(OUT) :: es + CHARACTER(*), INTENT(OUT) :: em + REAL(wp) :: om(SIZE(table, 2)), k(SIZE(table, 2)), amp(SIZE(table, 2)), ph(SIZE(table, 2)) + om = table(1, :) + k = table(2, :) + amp = table(3, :) + ph = table(4, :) + CALL CD_Component_Wave_Kinematics(x, y, z, t, depth, direction, .FALSE., om, k, amp, ph, eta, u, a, es, em, pdyn=p) + END SUBROUTINE long_crested + + SUBROUTINE sample(p, t, om, k, amp, ph, e2kd, tail, out) + REAL(wp), INTENT(IN) :: p(3), t, om(:), k(:), amp(:), ph(:), e2kd(:), tail(:, :) + REAL(wp), INTENT(OUT) :: out(7) + REAL(wp) :: e, v(3), w(3) + INTEGER :: st + CHARACTER(200) :: msg + CALL CD_Component_Wave_Kinematics(p(1), p(2), p(3), t, 60.0_wp, DIRECTION, .TRUE., om, k, amp, ph, e, v, w, st, & + msg, inputs_validated=.TRUE., exp_m2kd=e2kd, tail_sums=tail) + out = [e, v, w] + END SUBROUTINE sample + + SUBROUTINE within(value, exact, tol, what) + REAL(wp), INTENT(IN) :: value + REAL(qp), INTENT(IN) :: exact, tol + CHARACTER(*), INTENT(IN) :: what + REAL(wp) :: ratio + ! a sum below the double range underflows: the bound keeps a floor of the smallest normal + ratio = REAL(ABS(REAL(value, qp) - exact)/(tol + REAL(TINY(1.0_wp), qp)), wp) + worst = MAX(worst, ratio) + CALL require(ratio <= 1.0_wp .AND. ratio == ratio, what) + END SUBROUTINE within + + SUBROUTINE require(condition, what) + LOGICAL, INTENT(IN) :: condition + CHARACTER(*), INTENT(IN) :: what + IF (.NOT. condition) THEN + nfail = nfail + 1 + IF (nfail <= 40) WRITE (*, '(A)') ' FAILED: '//what + END IF + END SUBROUTINE require + +END PROGRAM test_wave_component_sum diff --git a/tests/test_wavekin_modes.f90 b/tests/test_wavekin_modes.f90 index 896e9b30..cf4e05ce 100644 --- a/tests/test_wavekin_modes.f90 +++ b/tests/test_wavekin_modes.f90 @@ -14,10 +14,23 @@ !> equals the record's two harmonics to 1e-10 m), and velocity and acceleration, which !> MoorDyn-C interpolates in the stretched depth on its 0.25 m grid, within 1e-3 of their !> scale; +!> - a mean level (tests/data/wavekin_mdc_mean, MoorDyn-C v2.7.2): an omega = 0 row of 0.8 m +!> under six WaveKin 7 components in six directions, and the WaveKin 3 record raised by +!> 0.8 m. MoorDyn-C adds the level to the elevation alone, which its stretching then maps, and +!> keeps the wave numbers of WtrDpth; CableDyn matches it to the tolerances above (WaveKin 7 +!> to 1e-8). Taking +!> the wave numbers at WtrDpth plus the level, as 0.4.0 did, put WaveKin 7 7.7e-3 m off in +!> elevation and 1e-2 of its scale in velocity, and WaveKin 3 2.2e-4 m and 6e-3; !> - WvHiCOff: 1 rad/s keeps only the record's 8 s harmonic, exactly; none keeps both, as the !> 0.5 Hz default does (the record has nothing above it); !> - Currents 1: identical to 1e-12 m/s; -!> - the MoorDyn-C modes CableDyn does not support fail closed by name. +!> - WaveKin 7 rows of different directions, across the quadrants: the elevation is the sum of +!> the two single-direction seas and the velocity, acceleration likewise at the depth stretched +!> by the total elevation, to round-off; the beta of the omega = 0 row has no effect; +!> - WaveRefX, WaveRefY: an elevation record or a component file referred to a point equals the +!> same sea with the deck translated by it, for WaveKin 3 and 7 and WaterKin WaveKinMod 1; +!> - the MoorDyn-C modes CableDyn does not support, and a reference point with no record to +!> place, fail closed by name. PROGRAM test_wavekin_modes USE CableDyn_Precision, ONLY: wp USE CableDyn_DeckDriver, ONLY: CD_Deck_Fluid_Probe, CD_DECKDRV_OK @@ -28,10 +41,16 @@ PROGRAM test_wavekin_modes 1.0e-6_wp, 1.0e-6_wp) CALL check_mode('wavekin_mdc/wavekin3.dat', 'wavekin_mdc/mdc_wavekin3.csv', 'WaveKin 3', 1.0e-6_wp, & 1.0e-3_wp, 1.0e-3_wp) + CALL check_mode('wavekin_mdc_mean/wavekin7.dat', 'wavekin_mdc_mean/mdc_wavekin7.csv', 'WaveKin 7, mean level', & + 1.0e-6_wp, 1.0e-6_wp, 1.0e-6_wp) + CALL check_mode('wavekin_mdc_mean/wavekin3.dat', 'wavekin_mdc_mean/mdc_wavekin3.csv', 'WaveKin 3, mean level', & + 1.0e-6_wp, 1.0e-3_wp, 1.0e-3_wp) CALL check_record() CALL check_cutoff() CALL check_mode('wavekin_mdc/currents1.dat', 'wavekin_mdc/mdc_currents1.csv', 'Currents 1', 1.0e-12_wp, & 1.0e-12_wp, 1.0e-12_wp, absolute=.TRUE.) + CALL check_short_crested() + CALL check_reference_point() CALL check_rejections() CALL check_still_water_ramp() IF (n_fail > 0) THEN @@ -234,6 +253,205 @@ SUBROUTINE expect_failure(wavekin, currents, needle) ' fails closed naming "'//needle//'": '//TRIM(em)) END SUBROUTINE expect_failure + SUBROUTINE expect_row_failure(wavekin, row, needle) + CHARACTER(*), INTENT(IN) :: wavekin, row, needle + REAL(wp) :: pos(3, 1), t(1), eta(1), vel(3, 1), acc(3, 1) + INTEGER :: es + CHARACTER(512) :: em + pos = 0.0_wp + pos(3, 1) = -5.0_wp + t = 0.0_wp + CALL write_fail_deck('wavekin_fail.dat', wavekin, '0', row) + CALL CD_Deck_Fluid_Probe('wavekin_fail.dat', pos, t, eta, vel, acc, es, em) + CALL require(es /= CD_DECKDRV_OK .AND. INDEX(em, needle) > 0, 'WaveKin '//wavekin//' with '//row// & + ' fails closed naming "'//needle//'": '//TRIM(em)) + END SUBROUTINE expect_row_failure + + SUBROUTINE write_components(beta_a, beta_b, which, beta_mean) + !! wave_frequencies.txt of two component sets, set a at direction beta_a and set b at beta_b + !! (radians), in file order a then b, so that the frequencies do not ascend; which: 0 both, + !! 1 a alone, 2 b alone. beta_mean: the direction of the omega = 0 row (default that of the + !! first component). + REAL(wp), INTENT(IN) :: beta_a, beta_b + INTEGER, INTENT(IN) :: which + REAL(wp), INTENT(IN), OPTIONAL :: beta_mean + INTEGER :: u + REAL(wp) :: b0 + b0 = MERGE(beta_b, beta_a, which == 2) + IF (PRESENT(beta_mean)) b0 = beta_mean + OPEN (NEWUNIT=u, FILE='wave_frequencies.txt', STATUS='REPLACE', ACTION='WRITE') + WRITE (u, '(A,ES24.16)') '0.0 0.0 0.0 ', b0 + IF (which /= 2) THEN + WRITE (u, '(A,ES24.16)') '0.6 0.7 0.2 ', beta_a + WRITE (u, '(A,ES24.16)') '1.1 0.25 -0.3 ', beta_a + END IF + IF (which /= 1) THEN + WRITE (u, '(A,ES24.16)') '0.6 -0.3 0.5 ', beta_b + WRITE (u, '(A,ES24.16)') '0.9 0.4 0.1 ', beta_b + END IF + CLOSE (u) + END SUBROUTINE write_components + + SUBROUTINE probe_components(beta_a, beta_b, which, pos, t, eta, vel, acc) + REAL(wp), INTENT(IN) :: beta_a, beta_b, pos(:, :), t(:) + INTEGER, INTENT(IN) :: which + REAL(wp), INTENT(OUT) :: eta(:), vel(:, :), acc(:, :) + INTEGER :: es + CHARACTER(512) :: em + CALL write_components(beta_a, beta_b, which) + CALL write_fail_deck('wavekin_short.dat', '7', '0') + CALL CD_Deck_Fluid_Probe('wavekin_short.dat', pos, t, eta, vel, acc, es, em) + CALL require(es == CD_DECKDRV_OK, 'WaveKin 7 component deck evaluated: '//TRIM(em)) + END SUBROUTINE probe_components + + SUBROUTINE check_short_crested() + !! Rows of different directions against the two single-direction seas they are made of. The + !! deck stretches each sea by its own elevation, so a single sea is probed at the depth that + !! maps to the depth the total elevation stretches the point to. + INTEGER, PARAMETER :: NP = 6, NPAIR = 6 + REAL(wp), PARAMETER :: D = 50.0_wp + ! radians, within +-2 pi: the quadrants, the axes, and directions that are no multiple of 90 deg + REAL(wp), PARAMETER :: PAIRS(2, NPAIR) = RESHAPE([0.0_wp, 1.5707963267948966_wp, 0.3_wp, 2.4_wp, -1.0_wp, 3.7_wp, & + 5.1_wp, -2.2_wp, 3.141592653589793_wp, 4.71238898038469_wp, & + 0.65_wp, -6.0_wp], [2, NPAIR]) + REAL(wp) :: pos(3, NP), t(NP), eta(NP), vel(3, NP), acc(3, NP), ea(NP), va(3, NP), aa(3, NP) + REAL(wp) :: eb(NP), vb(3, NP), ab(3, NP), ze(NP), pa(3, NP), pb(3, NP), err_e, err_v, err_a, tmp(NP) + REAL(wp) :: tmpv(3, NP), tmpa(3, NP), e0(NP), v0(3, NP), a0(3, NP) + INTEGER :: ip, es + CHARACTER(512) :: em + pos(1, :) = [0.0_wp, 13.0_wp, -41.5_wp, 77.0_wp, -8.25_wp, 150.0_wp] + pos(2, :) = [0.0_wp, -7.0_wp, 22.0_wp, 90.5_wp, -33.0_wp, -120.0_wp] + pos(3, :) = [-2.0_wp, -5.0_wp, -11.0_wp, -20.0_wp, -33.0_wp, -46.0_wp] + t = [0.0_wp, 1.7_wp, 3.3_wp, 5.9_wp, 7.1_wp, 9.8_wp] + err_e = 0.0_wp + err_v = 0.0_wp + err_a = 0.0_wp + DO ip = 1, NPAIR + CALL probe_components(PAIRS(1, ip), PAIRS(2, ip), 0, pos, t, eta, vel, acc) + CALL probe_components(PAIRS(1, ip), PAIRS(2, ip), 1, pos, t, ea, tmpv, tmpa) + CALL probe_components(PAIRS(1, ip), PAIRS(2, ip), 2, pos, t, eb, tmpv, tmpa) + err_e = MAX(err_e, MAXVAL(ABS(eta - ea - eb))) + ze = (pos(3, :) - eta)*D/(D + eta) + pa = pos + pb = pos + pa(3, :) = ze*(D + ea)/D + ea + pb(3, :) = ze*(D + eb)/D + eb + CALL probe_components(PAIRS(1, ip), PAIRS(2, ip), 1, pa, t, tmp, va, aa) + CALL probe_components(PAIRS(1, ip), PAIRS(2, ip), 2, pb, t, tmp, vb, ab) + err_v = MAX(err_v, MAXVAL(ABS(vel - va - vb))) + err_a = MAX(err_a, MAXVAL(ABS(acc - aa - ab))) + END DO + WRITE (*, '(A,3ES10.3)') 'WaveKin 7 two directions vs two seas: elevation, velocity, acceleration ', err_e, & + err_v, err_a + CALL require(err_e <= 1.0e-13_wp, 'two directions: the elevation is the sum of the two seas') + CALL require(err_v <= 1.0e-13_wp, 'two directions: the velocity is the sum of the two seas') + CALL require(err_a <= 1.0e-13_wp, 'two directions: the acceleration is the sum of the two seas') + ! the mean row carries no direction: a different beta there leaves a one-direction table + CALL write_components(0.4_wp, 0.4_wp, 0) + CALL write_fail_deck('wavekin_short.dat', '7', '0') + CALL CD_Deck_Fluid_Probe('wavekin_short.dat', pos, t, e0, v0, a0, es, em) + CALL require(es == CD_DECKDRV_OK, 'one direction evaluated: '//TRIM(em)) + CALL write_components(0.4_wp, 0.4_wp, 0, beta_mean=1.3_wp) + CALL CD_Deck_Fluid_Probe('wavekin_short.dat', pos, t, eta, vel, acc, es, em) + CALL require(es == CD_DECKDRV_OK, 'mean-row direction evaluated: '//TRIM(em)) + CALL require(ALL(eta == e0) .AND. ALL(vel == v0) .AND. ALL(acc == a0), & + 'the direction of the omega = 0 row has no effect') + END SUBROUTINE check_short_crested + + SUBROUTINE copy_deck_with(source, target, match, row, replace) + !! source with row after the line that holds match, or in its place (replace). + CHARACTER(*), INTENT(IN) :: source, target, match, row + LOGICAL, INTENT(IN) :: replace + CHARACTER(1024) :: line + INTEGER :: u_in, u_out, ios + OPEN (NEWUNIT=u_in, FILE=source, STATUS='OLD', ACTION='READ') + OPEN (NEWUNIT=u_out, FILE=target, STATUS='REPLACE', ACTION='WRITE') + DO + READ (u_in, '(A)', IOSTAT=ios) line + IF (ios /= 0) EXIT + IF (INDEX(line, match) > 0) THEN + IF (.NOT. replace) WRITE (u_out, '(A)') TRIM(line) + WRITE (u_out, '(A)') row + ELSE + WRITE (u_out, '(A)') TRIM(line) + END IF + END DO + CLOSE (u_in) + CLOSE (u_out) + END SUBROUTINE copy_deck_with + + SUBROUTINE compare_translated(plain, shifted, shift, label) + !! The deck shifted, referred to the point shift, at positions p + shift, against the deck + !! plain at p. + CHARACTER(*), INTENT(IN) :: plain, shifted, label + REAL(wp), INTENT(IN) :: shift(2) + INTEGER, PARAMETER :: NP = 5 + REAL(wp) :: pos(3, NP), moved(3, NP), t(NP), e0(NP), v0(3, NP), a0(3, NP), e1(NP), v1(3, NP), a1(3, NP), err + INTEGER :: es + CHARACTER(512) :: em + pos(1, :) = [0.0_wp, 11.0_wp, -37.0_wp, 60.5_wp, -5.0_wp] + pos(2, :) = [0.0_wp, -3.5_wp, 19.0_wp, 44.0_wp, -71.0_wp] + pos(3, :) = [-1.5_wp, -6.0_wp, -14.0_wp, -27.0_wp, -41.0_wp] + t = [0.3_wp, 1.9_wp, 4.4_wp, 6.2_wp, 9.1_wp] + moved = pos + moved(1, :) = pos(1, :) + shift(1) + moved(2, :) = pos(2, :) + shift(2) + CALL CD_Deck_Fluid_Probe(plain, pos, t, e0, v0, a0, es, em) + CALL require(es == CD_DECKDRV_OK, label//' plain deck evaluated: '//TRIM(em)) + IF (es /= CD_DECKDRV_OK) RETURN + CALL CD_Deck_Fluid_Probe(shifted, moved, t, e1, v1, a1, es, em) + CALL require(es == CD_DECKDRV_OK, label//' referred deck evaluated: '//TRIM(em)) + IF (es /= CD_DECKDRV_OK) RETURN + err = MAX(MAXVAL(ABS(e1 - e0)), MAXVAL(ABS(v1 - v0)), MAXVAL(ABS(a1 - a0))) + WRITE (*, '(A,A,ES10.3)') label, ' referred to a point vs the deck translated: ', err + CALL require(err <= 1.0e-12_wp, label//': a shifted reference point equals the deck translated') + ! and the point matters: the same positions without the shift are another sea + CALL CD_Deck_Fluid_Probe(shifted, pos, t, e1, v1, a1, es, em) + CALL require(MAXVAL(ABS(e1 - e0)) > 1.0e-3_wp, label//': the reference point moves the sea') + END SUBROUTINE compare_translated + + SUBROUTINE check_reference_point() + REAL(wp), PARAMETER :: SHIFT(2) = [250.37_wp, -70.19_wp] + CHARACTER(*), PARAMETER :: ROWS = '250.37 WaveRefX'//NEW_LINE('a')//'-70.19 WaveRefY' + INTEGER :: u + ! WaveKin 7, one direction and two + CALL write_components(0.9_wp, 0.9_wp, 0) + CALL write_fail_deck('wavekin_ref0.dat', '7', '0') + CALL write_fail_deck('wavekin_ref1.dat', '7', '0', ROWS) + CALL compare_translated('wavekin_ref0.dat', 'wavekin_ref1.dat', SHIFT, 'WaveKin 7') + CALL write_components(0.65_wp, 2.9_wp, 0) + CALL compare_translated('wavekin_ref0.dat', 'wavekin_ref1.dat', SHIFT, 'WaveKin 7, two directions') + ! WaveKin 3 + CALL copy_deck_with('wavekin_mdc/wavekin3.dat', 'wavekin_mdc/wavekin3_ref.dat', 'dtWave', ROWS, .FALSE.) + CALL compare_translated('wavekin_mdc/wavekin3.dat', 'wavekin_mdc/wavekin3_ref.dat', SHIFT, 'WaveKin 3') + ! WaterKin WaveKinMod 1, along a direction off the axes + OPEN (NEWUNIT=u, FILE='wavekin_mdc/waterkin_ref.dat', STATUS='REPLACE', ACTION='WRITE') + WRITE (u, '(A)') 'MoorDyn v2 water kinematics file' + WRITE (u, '(A)') '(header line 2)' + WRITE (u, '(A)') '--------------------------- WAVES -------------------------------------' + WRITE (u, '(A)') '1 WaveKinMod' + WRITE (u, '(A)') '"wave_elevation.txt" WaveKinFile' + WRITE (u, '(A)') '0.1 dtWave' + WRITE (u, '(A)') '37.0 WaveDir' + WRITE (u, '(A)') '0 X wave input' + WRITE (u, '(A)') '0.0' + WRITE (u, '(A)') '0 Y wave input' + WRITE (u, '(A)') '0.0' + WRITE (u, '(A)') '0 Z wave input' + WRITE (u, '(A)') '0.0' + WRITE (u, '(A)') '--------------------------- CURRENT -----------------------------------' + WRITE (u, '(A)') '0 CurrentMod' + WRITE (u, '(A)') '0 Z current' + WRITE (u, '(A)') '0.0' + WRITE (u, '(A)') '--------------------- need this line ----------------------------------' + CLOSE (u) + CALL copy_deck_with('wavekin_mdc/wavekin3.dat', 'wavekin_mdc/waterkin0.dat', 'WaveKin ', & + 'waterkin_ref.dat WaterKin', .TRUE.) + CALL copy_deck_with('wavekin_mdc/waterkin0.dat', 'wavekin_mdc/waterkin1.dat', 'WaterKin', & + 'waterkin_ref.dat WaterKin'//NEW_LINE('a')//ROWS, .TRUE.) + CALL compare_translated('wavekin_mdc/waterkin0.dat', 'wavekin_mdc/waterkin1.dat', SHIFT, 'WaterKin WaveKinMod 1') + END SUBROUTINE check_reference_point + SUBROUTINE check_still_water_ramp() !! rampTime is valid without waves (it also ramps prescribed motion). The still-water !! kinematics must then be exactly zero, including at t = 0 where the ramp is closed @@ -252,19 +470,17 @@ SUBROUTINE check_still_water_ramp() END SUBROUTINE check_still_water_ramp SUBROUTINE check_rejections() - INTEGER :: u CALL expect_failure('1', '0', 'MoorDyn-C API') CALL expect_failure('2', '0', 'FFT_GRID') CALL expect_failure('4', '0', 'no kinematics source in MoorDyn-C') CALL expect_failure('6', '0', 'no kinematics source in MoorDyn-C') CALL expect_failure('0', '3', 'no current source in MoorDyn-C') CALL expect_failure('0', '4', 'no current source in MoorDyn-C') - ! short-crested components (rows with different directions) - OPEN (NEWUNIT=u, FILE='wave_frequencies.txt', STATUS='REPLACE', ACTION='WRITE') - WRITE (u, '(A)') '0.0 0.0 0.0 0.0' - WRITE (u, '(A)') '0.6 0.7 0.2 0.0' - WRITE (u, '(A)') '0.9 -0.3 0.5 1.57' - CLOSE (u) - CALL expect_failure('7', '0', 'different directions') + ! a reference point needs a record or component file to place, and a bounded position + CALL write_components(0.4_wp, 0.4_wp, 0) + CALL expect_row_failure('0', '1.0 WaveRefX', 'reference point') + CALL expect_row_failure('0', '1.0 WaveRefY'//NEW_LINE('a')//'airy 2.0 8.0 0.0 waves', 'reference point') + CALL expect_row_failure('7', '2.0e6 WaveRefX', 'at most 1e6 m') + CALL expect_row_failure('7', 'nan WaveRefY', 'WaveRefY') END SUBROUTINE check_rejections END PROGRAM test_wavekin_modes diff --git a/validation/RELEASE_0_4_0.md b/validation/RELEASE_0_4_0.md index 65bdb2fa..5f5e56ab 100644 --- a/validation/RELEASE_0_4_0.md +++ b/validation/RELEASE_0_4_0.md @@ -45,6 +45,11 @@ LAPACK are those of [v0.3.0](RELEASE_0_3_0.md); the OpenFAST patch set grows fro | OpenFAST linearisation (`validation/scripts/openfast_lin_ab.py`, IEA-15 MW UMaine twin decks and a mixed cable deck) | passed with the release `openfast.exe` (single precision): CableDyn mooring block symmetric to 7.2e-6; surge stiffness 7.721e4 N/m against MoorDyn-F's 7.716e4 N/m; fairlead blocks to 1.1e-3 and the platform 6×6 stiffness to 1.0e-3 of MoorDyn-F's quasi-static reduction; the lowest nine line-mode frequencies to 0.3 %; the mixed cable deck's point stiffness to its static solves to 1.1e-4 | | Wall time of the shipped examples, v0.3.0 vs dev during the release checks (GNU, same host, one thread, OpenBLAS Haswell kernel) | over the 51 decks both run, the geometric-mean ratio is 0.938 and the total falls from 213.0 to 196.5 s | +**Correction (2026-11-08):** the round-off fragility row above overstates its evidence. SkylakeX +was not run (the Windows OpenBLAS ran Haswell in its place) and the example scan could not see the +relaxed-tolerance note. The corrected checks pass on the same examples; see +[the v0.4.1 record](RELEASE_0_4_1.md#correction-to-the-v040-record). + ## Windows executable gates Every gate of `build_static_windows.ps1` passed in the release workflow (`release-windows-static.yml`, diff --git a/validation/RELEASE_0_4_1.md b/validation/RELEASE_0_4_1.md new file mode 100644 index 00000000..ef4a7b48 --- /dev/null +++ b/validation/RELEASE_0_4_1.md @@ -0,0 +1,126 @@ + + +# CableDyn v0.4.1 release record + +This record lists what was built and tested for the v0.4.1 release: the source commit, +toolchains, dependency pins, test results and asset hashes. Case definitions and pass criteria +are in [`VALIDATION.md`](../VALIDATION.md). The OpenFAST and r-test pins and the reference +LAPACK are those of [v0.4.0](RELEASE_0_4_0.md); the OpenFAST patch set grows from 22 to 23. +The last section corrects one row of the v0.4.0 record. + +## Source and pins + +| Item | Value | +| --- | --- | +| CableDyn | `0.4.1` (2026-11-08), C ABI 1, minor extension 4 | +| Source | The release tree (`release/0.4.1` at `f8f1e1a`, tree `79d3309`): dev with #87 to #104, the version and the experiment records of the release. The assets, the local gates and the Windows gates were produced from this tree; this record is added afterwards | +| OpenFAST base | `2895884d2be01862173c88d70f86b358d2f1a50a`; the 23 patches of [`integration/openfast/patches`](../integration/openfast/patches) applied to a clean checkout by `prepare_openfast_source.ps1` | +| OpenFAST r-test | `dd5feaaaa500ba7283140107806300d551cff0a7` | +| Reference LAPACK/BLAS | 3.12.1, release-tag archive with SHA-256 `2ca6407a001a474d4d4d35f3a61550156050c48016d949f0da0529c0aa052422`, built with IFX for x86-64-v3 (`/fp:precise /arch:CORE-AVX2`) | + +## Toolchains + +| Item | Version | +| --- | --- | +| GNU Fortran (test builds) | conda-forge GCC 16.2.0, with OpenBLAS 0.3.34 and LAPACK from the conda environment | +| Intel Fortran (Windows release) | IFX 2025.3.2, `/fp:precise /arch:CORE-AVX2 /Qfma-` (value-safe floating point, x86-64-v3, no contraction into fused multiply-adds), `/Qauto` (thread-safe local storage) | +| Microsoft C/C++ | Visual Studio 2022 (release workflow) | +| Python wheels | scikit-build-core; cibuildwheel v4.3.3 (Linux `manylinux_2_28`, macOS arm64); IFX static build on Windows (`release/build_windows_wheel.ps1`) | +| Python and packaging | Python 3.13.17, build 1.6.1, Twine 7.0.0 (release workflow) | + +## Test results + +| Gate | Result | +| --- | --- | +| GNU Release CTest, including the slow tier (Windows, local, fresh build directory) | 365 of 365 passed; 0 compiler warnings | +| GNU Debug CTest (`-fcheck=all`), including the slow tier (Windows, local, fresh build directory) | 365 of 365 passed; 0 compiler warnings | +| Round-off fragility, Windows (GNU Release build of the release tree; OpenBLAS kernels Prescott, Nehalem, Sandybridge, Haswell and Zen through `OPENBLAS_CORETYPE`, each confirmed by name with `tests/check_example_fallbacks.py --coretype`) | on each kernel the full CTest passes 365 of 365 and all 54 example decks run with no fallback note (`check_example_fallbacks.py`, which matches the notes the solver prints) | +| Round-off fragility, SkylakeX (the CI round-off job with `OPENBLAS_CORETYPE=SkylakeX` on the release tree, #105; a Linux runner with AVX-512, AMD EPYC 9V74, required by the job) | the full CTest passes 358 of 358 (the Linux set) and all 54 example decks run with no fallback note. A first run landed on a runner without AVX-512 and stopped on illegal instructions; OpenBLAS names the forced kernel whether or not the processor can run it | +| Python package tests (local, native library and driver of the release tree) | 2872 passed, 47 skipped (platform- and tool-dependent cases) | +| Python distributions (`python -m build python`, `twine check`) | both pass; each carries the Apache-2.0 licence; the wheel holds only `cabledyn` and its metadata | +| CI matrix of #104 (the last change before the release tree) | Linux and Windows, Release and Debug; the Prescott round-off job; Python on three platforms with coverage and strict type checks; Sphinx; all passed | +| Sphinx documentation (`-W -n`) | built with no warnings | +| pre-commit, all files | passed | +| Release metadata | `check_release_metadata.py` passed (CableDyn v0.4.1, 2026-11-08) | +| Release-tree audit | worktree of the release tree: 944 files, SHA-256 `ae227dd5d017d0f01ed80080d4e39c955ed0601dbf9235edaf320448479c890a` | +| MoorDyn-C water kinematics (`wavekin_modes`) | MoorDyn-C v2.7.2, built for the check, reproduces the stored v2.7.1 references to 1e-16. With a 0.8 m mean level under six `WaveKin 7` components in six directions, CableDyn matches it to 1e-8 of its scale; the `WaveKin 3` record raised by 0.8 m matches to the grid tolerances (3e-7 m, 1e-4) | +| Experimental comparisons ([`experiments`](experiments/README.md)) | Holcombe (12 cases) and Bergdahl (30 primary and 8 refinement cases) re-run with a GNU Release build of the release tree (`cabledyn.exe` SHA-256 `9b86cbd5abc47d73d535f49dd5952f7805646b190a3744380d0b43849019e10f`). Both reproduce their summaries, metrics and refinement figures byte for byte. Of the hashed profiles only Holcombe's two static profiles change, in their curvature and bend-moment columns by at most 1.1e-4 relative (the geometric curvature of a stretched line). The Bergdahl waveform comparison was not re-run (its measured series was not available); its files and metrics are those of v0.3.0, as `provenance.json` states | +| Bodies suite (102 metrics) | 102 of 102 pass with a GNU Release build of the release tree | +| OpenFAST linearisation (`validation/scripts/openfast_lin_ab.py`, IEA-15 MW UMaine twin decks and a mixed cable deck) | passed with the release `openfast.exe` (single precision), its log identical to v0.4.0's: CableDyn mooring block symmetric to 7.2e-6; surge stiffness 7.721e4 N/m against MoorDyn-F's 7.716e4 N/m; dominant fairlead-block entries to 1.1e-3 and every entry above 1 % of its block to 2.9e-3; the platform 6×6 stiffness to 1.0e-3 of MoorDyn-F's quasi-static reduction; the lowest nine line-mode frequencies to 0.3 %; the mixed cable deck's point stiffness to its static solves to 1.1e-4 | +| Wall time of the shipped examples, v0.4.0 vs the release tree (GNU Release builds, same host, one thread, OpenBLAS Haswell kernel, best of two runs in alternating order) | over the 53 decks, the geometric-mean ratio is 0.967 and the total falls from 155.8 to 123.1 s; the six decks in irregular seas take 15 to 51 % less and no deck is slower | + +## Windows executable gates + +Every gate of `build_static_windows.ps1` passed in the release workflow (`release-windows-static.yml`, +run 42438065613). The smoke runs used a system-only `PATH`. + +| Gate | Result | +| --- | --- | +| Compiler | IFX 2025.3.2 (minimum 2025.3) | +| OpenFAST solution build | one pass: 34 projects succeeded, 0 failed | +| `CableDyn_driver.exe` imports | `KERNEL32.dll`, `SHELL32.dll` and `imagehlp.dll` only; stack reserve 268,435,456 bytes; `--version` reports `Build x86-64-v3 (AVX2)` | +| `openfast.exe` imports | `KERNEL32.dll` and `imagehlp.dll` only; `-v` reports `OpenFAST-v5.0.0-CableDyn-0.4.1` | +| Processor check | both executables stop at start with exit 3 and the message on a simulated missing x86-64-v3 feature, in the workflow and again on the downloaded assets | +| Standalone smoke | the shallow-chain static deck (`chain_catenary_shallow_30m.dat`) converges | +| Memory growth over a 10x longer run | 2.6 -> 2.6 MiB (`dynamic_chain_current.dat`), 20.7 -> 20.7 MiB (`lozon_gomex80_power_cable.dat`); limit +16 MiB | +| Exit contract | a run states the exit contract after the banner; a refused input ends stderr with the closing line | +| `CompMooring = 5` smoke | CableDyn banner, `dtM` 0.025 s (one glue step), equilibrium fairlead tensions 2.437 MN, the same as v0.4.0 to every printed digit, normal termination | + +## Clean-machine test + +A self-contained check ran the release assets from a folder whose path holds spaces, an accented +letter and Hangul, with a system-only `PATH`. It checks the checksums of the four files and the +manifest, the start of both executables (both name 0.4.1), every shipped example deck (53) with +the release `CableDyn_driver.exe`, four `CompMooring = 5` runs, and the Python wheel in a new +virtual environment, where it imports as 0.4.1 and runs the release driver. The four runs are a +70 s JONSWAP sea with mixed chains and a cable, a 400 s line failure, a checkpoint at 30 s with a +restart whose last 10 s of output (400 rows) must be bit-identical, and, new in 0.4.1, a +checkpoint between two mooring steps (`dtM` = 2 `DT`, at 30.025 s) whose restart must match the +rest of the run (200 rows) bit for bit. All 13 checks passed. + +## Python distribution + +The PyPI distribution is built by `.github/workflows/wheels.yml`; each wheel is installed into a +clean environment and smoke-tested on its own platform (the library and the driver it carries +load and run a deck, and the driver reports the expected build). The preflight run (42438067968) +built the files below and uploaded them to TestPyPI; the release run rebuilds them from the tag +before the upload. The Windows wheel installed from TestPyPI into a new environment has the +preflight checksum and passed `release/smoke_test_wheel.py` (library, driver, build x86-64-v3). + +| File | SHA-256 (preflight) | +| --- | --- | +| `cabledyn-0.4.1.tar.gz` | `a442878a37649e317e7765d94513a8f0fcadba581bd9baba154d224759d0ca52` | +| `cabledyn-0.4.1-py3-none-win_amd64.whl` | `a9edeaabb979bb41b64e2655482f46b45464644b19821068ac3d2f732af354a2` | +| `cabledyn-0.4.1-py3-none-macosx_15_0_arm64.whl` | `a6972b6fb1035e4221a02c0a9fc32e3db2f14a16d5bc9b17c9debd33e1649e87` | +| `cabledyn-0.4.1-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl` | `19420f17d4cfc3af07b48a6d675ab119ec9b581a9e1b67d79f7695ec86add063` | + +## Assets + +| File | SHA-256 | +| --- | --- | +| `CableDyn_driver.exe` | `30020098b7b2df3664955a0e5288b89f397a5ffae0bf93bf994a935b583010f8` | +| `openfast.exe` | `e92f7311483771295165acc909e30fcee6d0bd69436cfaebf174511bc0a795e6` | +| `cabledyn-0.4.1-py3-none-any.whl` | `6d92659c7fb7b4dd0a6abf230aaca2d940c189387880a86e93f7b12ab131d22b` | +| `cabledyn-0.4.1.tar.gz` | `2a5e963d512774d493fb27370ef7806cc47660a2298217764a2f781fe3bb79d4` | + +## Correction to the v0.4.0 record + +The round-off fragility row of [the v0.4.0 record](RELEASE_0_4_0.md) states that the full CTest +passed on six OpenBLAS kernels and that all 53 examples ran "with no fallback, stall or +relaxed-tolerance note" on each. Two parts of that were not shown by the checks run for it: + +- **SkylakeX was not run.** The Windows conda-forge OpenBLAS accepts + `OPENBLAS_CORETYPE=SkylakeX` and silently runs its Haswell kernel, on a processor with AVX-512 + too, so the SkylakeX row repeated Haswell. `tests/check_example_fallbacks.py --coretype` now + asks OpenBLAS to name the kernel it loaded and fails on a mismatch. SkylakeX was first run for + real on a Linux runner on 2026-11-08 (#103, dev at `cf5d48a`): CTest 358 of 358 and 54 + example decks with no fallback note. The v0.4.0 evidence is therefore five kernels. +- **The scan could not see the relaxed-tolerance note.** It searched the console for "looser + tolerance", which the solver does not print; the note reads "residual floor above". The + corrected scan (`check_example_fallbacks.py`, also run by the CI round-off job) found every + example clean on all five Windows kernels and on SkylakeX at `cf5d48a`, and does so for the + release tree above. + +No result of v0.4.0 changes: the corrected checks pass on the same examples. The same record +also gives the `CompMooring = 5` smoke tensions as 2.436 MN; its workflow printed 2.43712E+06 N, +which is 2.437 MN. diff --git a/validation/experiments/bergdahl/README.md b/validation/experiments/bergdahl/README.md index 96032209..c7b24c72 100644 --- a/validation/experiments/bergdahl/README.md +++ b/validation/experiments/bergdahl/README.md @@ -76,7 +76,8 @@ mean absolute percentage error of 3.11%, a mean bias of +0.47 N, a Pearson corre conditions, halving the nominal element length and the time step changes the mean cycle maximum by no more than 0.29% and 0.27%, respectively. The phase-averaged force histories had RMSEs of 5.93% and 2.56% of the measured force range with 0.3.0; that comparison was not rerun for -0.4.0 (its waveform files and metrics are the 0.3.0 run's, `provenance.json`). The line's upper +0.4.0 or 0.4.1 (its waveform files and metrics are the 0.3.0 run's, `provenance.json`). The +0.4.1 rerun reproduces the 0.4.0 force maxima, metrics and refinement summaries byte for byte. The line's upper end lies 0.3 m above the still-water level, so 0.4.0's dry weight of the part above the surface changed the results: the RMSE from 1.583 N to 1.577 N and the mean absolute percentage error from 3.13% to 3.11%. The waveform CSVs carry the measured phase averages computed from the diff --git a/validation/experiments/bergdahl/provenance.json b/validation/experiments/bergdahl/provenance.json index 1db994ab..4266d5c2 100644 --- a/validation/experiments/bergdahl/provenance.json +++ b/validation/experiments/bergdahl/provenance.json @@ -1,8 +1,8 @@ { - "CableDyn_release": "v0.4.0", - "validation_date": "2026-11-03", + "CableDyn_release": "v0.4.1", + "validation_date": "2026-11-08", "executable_name": "cabledyn.exe", - "executable_sha256": "d6603f989f41b01e7921dc52c1c9e580182208c99b45e6ebdd93377b4cbc289f", + "executable_sha256": "9b86cbd5abc47d73d535f49dd5952f7805646b190a3744380d0b43849019e10f", "compiler": "GNU Fortran 16.2.0", "build_type": "Release", "platform": "Windows 11 x86-64", @@ -22,5 +22,5 @@ "waveform_metrics_sha256": "0fb6015cb30aa2bbb1cd8951b603f9201af50372ee6d3633a72fc70ea5280d1a", "refinement_summary_sha256": "75342ed4f3ebe2d7acb19af56394e24e6ce909bb3df7445bf3a1d884280f9eb6", "waveform_comparison_release": "v0.3.0", - "waveform_comparison_note": "not rerun with 0.4.0: the supplement expData.dat was not available when the record was rerun; the waveform files and metrics are those of the v0.3.0 run" + "waveform_comparison_note": "not rerun with 0.4.0 or 0.4.1: the supplement expData.dat was not available when the record was rerun; the waveform files and metrics are those of the v0.3.0 run" } diff --git a/validation/experiments/holcombe/provenance.json b/validation/experiments/holcombe/provenance.json index b0b380c9..06f401ed 100644 --- a/validation/experiments/holcombe/provenance.json +++ b/validation/experiments/holcombe/provenance.json @@ -1,8 +1,8 @@ { - "CableDyn_release": "v0.4.0", - "validation_date": "2026-11-03", + "CableDyn_release": "v0.4.1", + "validation_date": "2026-11-08", "executable_name": "cabledyn.exe", - "executable_sha256": "d6603f989f41b01e7921dc52c1c9e580182208c99b45e6ebdd93377b4cbc289f", + "executable_sha256": "9b86cbd5abc47d73d535f49dd5952f7805646b190a3744380d0b43849019e10f", "compiler": "GNU Fortran 16.2.0", "build_type": "Release", "platform": "Windows 11 x86-64", @@ -14,6 +14,6 @@ "marker_curvature_comparison_sha256": "169a2ac11f65f815d75ba84eb6c588693d19971aa524e4b619a34d27a0fb8715", "marker_curvature_summary_sha256": "cd7598043724a79b2bd2f43deac83537319bffc9117f649c92298751236a5a83", "mesh_refinement_summary_sha256": "964fa735d541f5674285fb31aca6053e414b516d579e0b7e41bc5b8cd6286659", - "free_rotation_profile_sha256": "9fd8b0e476739c981fdd0064efc051f92f971b741ec4fbbf4126773e07afb459", - "ideal_clamp_profile_sha256": "1052900c5b5bde579dbdd27e0d3ec3efa86f413158735b4766e940f14fa7a87e" + "free_rotation_profile_sha256": "2b76f492fccfc6b3ab1ac02765169d884f377939382ea99b09cba9737ef91b20", + "ideal_clamp_profile_sha256": "69e9fe0e0f445122993c687b1428f140de4ea230d74707d32bdb3fc267be84a4" } diff --git a/validation/scripts/orcaflex_bend_stiffener.py b/validation/scripts/orcaflex_bend_stiffener.py index 99d9fa3a..bb78762d 100644 --- a/validation/scripts/orcaflex_bend_stiffener.py +++ b/validation/scripts/orcaflex_bend_stiffener.py @@ -18,8 +18,8 @@ first 8 m and 1 m beyond, as in the check. The CSV holds the static curvature of the cable along its first 8 m (arc from End A, curvature in 1/m). -Result (OrcFxAPI 11.6d): root curvature 0.4522 /m (CableDyn 0.4523), cable bend moment at the -root 9.04 kN.m; CableDyn's curvature follows the profile within 0.8 % of the root curvature. +Result (OrcFxAPI 11.6d): root curvature 0.4522 /m (CableDyn 0.4528), cable bend moment at the +root 9.04 kN.m; CableDyn's curvature follows the profile within 0.5 % of the root curvature. OrcaFlex SI (m, te, kN). Usage: python validation/scripts/orcaflex_bend_stiffener.py [output.csv] diff --git a/validation/scripts/wavekin_moordyn_c_reference.py b/validation/scripts/wavekin_moordyn_c_reference.py index 2d08cba2..e536699a 100644 --- a/validation/scripts/wavekin_moordyn_c_reference.py +++ b/validation/scripts/wavekin_moordyn_c_reference.py @@ -1,10 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 -"""Regenerate the MoorDyn-C water-kinematics references of tests/data/wavekin_mdc. +"""Regenerate the MoorDyn-C water-kinematics references of tests/data/wavekin_mdc and wavekin_mdc_mean. Compiles wavekin_mdc_probe.cpp against a MoorDyn-C build (CD_MDC_SRC, CD_MDC_BUILD, CD_GXX as for the bodies scripts), runs it on the shared decks (WaveKin 3, WaveKin 7, Currents 1) and their probe points, and writes mdc_wavekin3.csv, mdc_wavekin7.csv and mdc_currents1.csv next to them. -The wavekin_modes CTest compares CableDyn against these rows. +wavekin_mdc_mean holds the same WaveKin 3 and 7 decks with a 0.8 m mean level; its references +come from MoorDyn-C v2.7.2. The wavekin_modes CTest compares CableDyn against these rows. """ import os @@ -15,10 +16,13 @@ from bodies_common import GXX, MDC_BUILD, MDC_SRC, REPO, SCRIPTS -DATA = REPO / "tests" / "data" / "wavekin_mdc" -CASES = (("wavekin3.dat", "points3.txt", "mdc_wavekin3.csv"), - ("wavekin7.dat", "points7.txt", "mdc_wavekin7.csv"), - ("currents1.dat", "pointsc.txt", "mdc_currents1.csv")) +FOLDERS = { + REPO / "tests" / "data" / "wavekin_mdc": (("wavekin3.dat", "points3.txt", "mdc_wavekin3.csv"), + ("wavekin7.dat", "points7.txt", "mdc_wavekin7.csv"), + ("currents1.dat", "pointsc.txt", "mdc_currents1.csv")), + REPO / "tests" / "data" / "wavekin_mdc_mean": (("wavekin3.dat", "points3.txt", "mdc_wavekin3.csv"), + ("wavekin7.dat", "points7.txt", "mdc_wavekin7.csv")), +} def main() -> None: @@ -34,14 +38,17 @@ def main() -> None: subprocess.run([GXX, "-O2", "-std=c++17", *[f"-I{p}" for p in inc], str(SCRIPTS / "wavekin_mdc_probe.cpp"), str(dll), "-o", str(exe)], check=True, env=env) shutil.copy2(dll, work / "libmoordyn.dll") - for f in DATA.iterdir(): - if not f.name.startswith("mdc_"): - shutil.copy2(f, work / f.name) - for deck, points, out in CASES: - subprocess.run([str(exe), deck, points, out], cwd=work, check=True, env=env, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - shutil.copy2(work / out, DATA / out) - print(f"wrote {DATA / out}") + for data, cases in FOLDERS.items(): + case_dir = work / data.name + case_dir.mkdir() + for f in data.iterdir(): + if not f.name.startswith("mdc_"): + shutil.copy2(f, case_dir / f.name) + for deck, points, out in cases: + subprocess.run([str(exe), deck, points, out], cwd=case_dir, check=True, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + shutil.copy2(case_dir / out, data / out) + print(f"wrote {data / out}") if __name__ == "__main__":