diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 0000000..b0eb5a5 --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,265 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +# +# The published Python distribution: an sdist and one py3-none platform wheel each for Linux +# x86-64 (manylinux_2_28), macOS arm64 and Windows x86-64, every wheel carrying the CableDyn +# library and the standalone driver. The x86-64 wheels target x86-64-v3 (AVX2), as the static +# Windows release does. Each wheel is installed into a clean environment and +# smoke-tested (release/smoke_test_wheel.py) on its own platform. The distributions are workflow +# artifacts. They are uploaded only by the two publish jobs, through PyPI Trusted Publishing +# (no stored token): to TestPyPI when a manual run asks for it, and to PyPI when a GitHub +# release is published, after checking that the distributions carry the release tag's version. +name: wheels + +on: + workflow_dispatch: + inputs: + upload: + description: Upload the built distributions (TestPyPI only; PyPI uploads come from releases) + type: choice + options: [none, testpypi] + default: none + release: + types: [published] + pull_request: + paths: + - pyproject.toml + - CMakeLists.txt + - python/cabledyn/** + - release/build_windows_wheel.ps1 + - release/smoke_test_wheel.py + - .github/workflows/wheels.yml + +permissions: + contents: read + +jobs: + sdist: + runs-on: ubuntu-latest + steps: + - name: Check out CableDyn + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + + - name: Build and check the sdist + run: | + python -m pip install --upgrade pip build twine + export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)" + python -m build --sdist --outdir dist . + python -m twine check dist/* + + - name: Upload the sdist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cabledyn-sdist + path: dist/*.tar.gz + if-no-files-found: error + + linux: + runs-on: ubuntu-latest + steps: + - name: Check out CableDyn + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build, repair (auditwheel) and test the manylinux wheel + uses: pypa/cibuildwheel@8c2dcd2e96041a619f5ff03c9638d7941e2553a4 # v4.3.3 + env: + # one py3-none wheel serves every Python; build it once + CIBW_BUILD: cp312-manylinux_x86_64 + CIBW_ARCHS_LINUX: x86_64 + CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 + # gfortran comes with the image's GCC toolset; OpenBLAS supplies LAPACK and is bundled + # into the wheel by auditwheel together with the Fortran runtime + CIBW_BEFORE_ALL_LINUX: >- + dnf install -y epel-release && dnf install -y openblas-devel + # x86-64-v3, as the Windows release (CABLEDYN_TARGET_AVX2): the wheel's library and + # driver stop with a message on a processor without AVX2 + CIBW_ENVIRONMENT_LINUX: SKBUILD_CMAKE_DEFINE="CABLEDYN_TARGET_AVX2=ON" + CIBW_TEST_COMMAND: >- + python {project}/release/smoke_test_wheel.py {project}/examples/dynamic_chain_current.dat --expect-build x86-64-v3 + with: + output-dir: wheelhouse + + - name: Upload the Linux wheel + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cabledyn-wheel-linux-x86_64 + path: wheelhouse/*.whl + if-no-files-found: error + + macos: + runs-on: macos-14 + steps: + - name: Check out CableDyn + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build, repair (delocate) and test the macOS wheel + uses: pypa/cibuildwheel@8c2dcd2e96041a619f5ff03c9638d7941e2553a4 # v4.3.3 + env: + CIBW_BUILD: cp312-macosx_arm64 + CIBW_ARCHS_MACOS: arm64 + # Homebrew gfortran and OpenBLAS; delocate bundles them with the Fortran runtime. The + # deployment target is the runner's macOS, the oldest the Homebrew libraries support. + CIBW_BEFORE_ALL_MACOS: brew install gcc openblas + CIBW_ENVIRONMENT_MACOS: >- + MACOSX_DEPLOYMENT_TARGET=14.0 + FC=gfortran + CMAKE_PREFIX_PATH=$(brew --prefix openblas) + CIBW_TEST_COMMAND: >- + python {project}/release/smoke_test_wheel.py {project}/examples/dynamic_chain_current.dat --expect-build baseline + with: + output-dir: wheelhouse + + - name: Upload the macOS wheel + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cabledyn-wheel-macos-arm64 + path: wheelhouse/*.whl + if-no-files-found: error + + windows: + timeout-minutes: 180 + runs-on: windows-2022 + steps: + - name: Check out CableDyn + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + + - name: Install Intel Fortran Essentials + # the release compiler, as in release-windows-static.yml + shell: powershell + env: + URL: https://registrationcenter-download.intel.com/akdlm/IRC_NAS/33676fcf-14a3-4e96-a9b9-72976b1145d9/intel-fortran-essentials-2025.3.1.25_offline.exe + run: | + $installer = Join-Path $env:RUNNER_TEMP 'intel-fortran-essentials-2025.3.1.25.exe' + curl.exe --fail --location --output $installer --url $env:URL --retry 5 --retry-delay 5 + if ($LASTEXITCODE -ne 0) { throw "Intel Fortran download failed with exit $LASTEXITCODE" } + if ((Get-Item -LiteralPath $installer).Length -ne 880951016) { + throw 'Intel Fortran installer length differs from the pinned release payload' + } + $signature = Get-AuthenticodeSignature -LiteralPath $installer + if ($signature.Status -ne 'Valid' -or + $signature.SignerCertificate.Subject -notmatch '(^|, )O=Intel Corporation(,|$)') { + throw "Intel Fortran installer signature is not a valid Intel Corporation signature: $($signature.Status)" + } + $arguments = @( + '-s', '-a', '--silent', '--eula', 'accept', + '-p=NEED_VS2017_INTEGRATION=0', + '-p=NEED_VS2019_INTEGRATION=0', + '-p=NEED_VS2022_INTEGRATION=1', + '--log-dir=.' + ) + $process = Start-Process -FilePath $installer -ArgumentList $arguments -Wait -PassThru + if ($process.ExitCode -notin @(0, 3010)) { + throw "Intel Fortran installer failed with exit $($process.ExitCode)" + } + + - name: Build the static Windows wheel + shell: powershell + run: .\release\build_windows_wheel.ps1 -OutputDir "$PWD\wheelhouse" -Jobs 4 + + - name: Install the wheel into a clean environment and smoke-test it + shell: powershell + run: | + $venv = Join-Path $env:RUNNER_TEMP 'wheel-test' + python -m venv $venv + & "$venv\Scripts\python.exe" -m pip install --upgrade pip + $wheel = (Get-ChildItem wheelhouse\cabledyn-*-win_amd64.whl).FullName + & "$venv\Scripts\python.exe" -m pip install $wheel + Push-Location $env:RUNNER_TEMP + try { + & "$venv\Scripts\python.exe" "$env:GITHUB_WORKSPACE\release\smoke_test_wheel.py" ` + "$env:GITHUB_WORKSPACE\examples\dynamic_chain_current.dat" --expect-build x86-64-v3 + if ($LASTEXITCODE -ne 0) { throw "smoke test failed ($LASTEXITCODE)" } + } finally { + Pop-Location + } + + - name: Upload the Windows wheel + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cabledyn-wheel-windows-x86_64 + path: wheelhouse/*.whl + if-no-files-found: error + + publish-testpypi: + # manual runs that ask for it; a version already on TestPyPI is skipped, not an error + if: github.event_name == 'workflow_dispatch' && inputs.upload == 'testpypi' + needs: [sdist, linux, macos, windows] + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/cabledyn + permissions: + id-token: write + steps: + - name: Download the distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: cabledyn-* + merge-multiple: true + path: dist + + - name: Check the distribution set + run: | + ls -l dist + test "$(ls dist/*.tar.gz | wc -l)" -eq 1 + test "$(ls dist/*.whl | wc -l)" -eq 3 + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + + publish-pypi: + # published GitHub releases only, behind the pypi environment + if: github.event_name == 'release' + needs: [sdist, linux, macos, windows] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/cabledyn + permissions: + id-token: write + steps: + - name: Download the distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: cabledyn-* + merge-multiple: true + path: dist + + - name: Check the distributions against the release tag + env: + TAG: ${{ github.event.release.tag_name }} + run: | + ls -l dist + version="${TAG#v}" + test "$(ls dist/*.tar.gz | wc -l)" -eq 1 + test "$(ls dist/*.whl | wc -l)" -eq 3 + for f in dist/*; do + case "$(basename "$f")" in + "cabledyn-${version}.tar.gz"|"cabledyn-${version}-py3-none-"*.whl) ;; + *) echo "::error::$(basename "$f") is not version ${version} of release ${TAG}"; exit 1 ;; + esac + done + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ebfa1..37ef50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,57 @@ All notable changes to CableDyn are recorded in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.3.0] - 2026-10-23 + +### Added + +- `pip install cabledyn`: the package is on PyPI, with wheels for Windows x64 and Linux x86-64 + (processors with AVX2) and macOS on Apple silicon that carry the solver library and the + standalone driver, so it runs decks without the release executables; other platforms build + from the source distribution. The release wheels are built and smoke-tested in CI and published + through PyPI Trusted Publishing. +- OpenFAST with `dtM` larger than `DT`: between CableDyn solves the coupled loads are extrapolated + from the last two solves instead of held (deck `OPTIONS predictLoads`, default `True`). On the + IEA-15 MW VolturnUS-S at `dtM = 0.1 s` and `DT = 0.025 s` the platform motions differ from a + `dtM = DT` run by at most 0.12 % of their standard deviation, against 0.42 % held. Runs at + `dtM = DT` (the default) do not change. +- Initialisation notes, none of which changes results: a `dtM` that resolves a line's axial + period in fewer than six steps, with the `dtM` that resolves it; and a finite-EI line whose + grounded elements lock more seabed friction than 15 % of the touchdown tension, with the + grounded element length that resolves the tension of the laid part. +- `validation/scripts/openfast_supercycle_ab.py`, which scores coupled runs at a coarser `dtM` + against `dtM = DT` (`VALIDATION.md`, supercycling verification). +- The run-time profile (`CABLEDYN_PROFILE=1`) splits the line and cable steps into their kernels + and assembly sections, and the OpenFAST `CalcOutput` and SeaState sampling into their parts. + +### Changed + +- Speed: the Windows release executables are compiled for processors with AVX2 (x86-64-v3: + Intel Haswell and AMD Excavator or newer), which makes the driver 13-33 % faster on the + benchmark decks; on an older processor they stop at start with a message naming the missing + instructions. In OpenFAST, `CalcOutput` no longer evaluates cable reactions it discarded and + assembles a fairlead reaction from the elements at its node (a 600 s IEA-15 MW run with a + 248-element power cable: 227 s to 194 s). Results change at round-off only. +- A static solve in a current that seabed friction cannot hold fails closed sooner (22 s to + 17.7 s on a 1000 m lazy wave), with the same message. +- The manual explains the mesh dependence of the negative `BA` (damping ratio) form and of + node-lumped seabed friction, and the measured trade-off of `modified_newton`, which stays off. + +### Fixed + +- Finite-EI node tension, curvature and bend moment at touchdown understated the last suspended + node's tension by half an element's floor reaction (5.3 % on a 10 m lazy-wave mesh); the coarse + mesh now follows a four times finer one within 3 %. End forces and the solution do not change. +- OpenFAST restarts with `dtM` larger than `DT` now continue the run bit for bit (the checkpoint + carries the held SeaState fields). A checkpoint written by an earlier CableDyn cannot be + restarted with this one. +- A restore (`CableDyn_RestoreState`, OpenFAST correction iterations) of a finite-EI cable with + seabed friction now continues bit for bit like the step it repeats. +- The FAQ and the OpenFAST tutorial gave 0.1 s as the default `dtM` in OpenFAST; it is the glue + `DT`. + ## [0.2.0] - 2026-10-20 ### Added @@ -534,6 +585,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.3.0...HEAD +[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 [0.1.1]: https://github.com/SMI-Lab-Inha/CableDyn/releases/tag/v0.1.1 [0.1.0]: https://github.com/SMI-Lab-Inha/CableDyn/releases/tag/v0.1.0 diff --git a/CITATION.cff b/CITATION.cff index b22c25e..f2786fe 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -22,8 +22,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.2.0 -date-released: 2026-10-20 +version: 0.3.0 +date-released: 2026-10-23 keywords: - mooring dynamics - cable dynamics diff --git a/CMakeLists.txt b/CMakeLists.txt index fa0bb6d..27126a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,13 +8,16 @@ # cmake_minimum_required(VERSION 3.20) # VERSION is a bare numeric triple matching the release version. -project(CableDynCore VERSION 0.2.0 LANGUAGES C Fortran +project(CableDynCore VERSION 0.3.0 LANGUAGES C Fortran DESCRIPTION "CableDyn geometrically nonlinear cable/mooring solver — Fortran core") option(CABLEDYN_ENABLE_COVERAGE "Instrument the GNU Fortran core for gcov/gcovr source coverage" OFF) option(CABLEDYN_ENABLE_OPENMP "Enable OpenMP acceleration when the compiler provides it" ON) option(CABLEDYN_STATIC_WINDOWS_RELEASE "Build a single-file non-OpenMP Windows release with Intel IFX/MSVC" OFF) +option(CABLEDYN_TARGET_AVX2 + "Compile the Fortran core for x86-64-v3 (AVX2, FMA, BMI1/2); programs then stop with a message on older processors" + OFF) if(CABLEDYN_STATIC_WINDOWS_RELEASE) if(NOT WIN32 OR NOT CMAKE_Fortran_COMPILER_ID STREQUAL "IntelLLVM" OR NOT MSVC) @@ -25,6 +28,8 @@ if(CABLEDYN_STATIC_WINDOWS_RELEASE) # static MSVC CRT, and no OpenMP runtime dependency (IFX has no static OpenMP # runtime on Windows). set(CABLEDYN_ENABLE_OPENMP OFF CACHE BOOL "" FORCE) + # The release executables target x86-64-v3 (CABLEDYN_TARGET_AVX2 below). + set(CABLEDYN_TARGET_AVX2 ON CACHE BOOL "" FORCE) set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded" CACHE STRING "" FORCE) # Large finite-EI meshes create compiler-generated array temporaries in deeply # nested residual/assembly calls. Keep those temporaries off the process stack; @@ -41,8 +46,11 @@ if(CABLEDYN_STATIC_WINDOWS_RELEASE) # driver below, or the OpenFAST Release|x64 project, which enables whole-program # optimisation). string(APPEND CMAKE_Fortran_FLAGS " /libs:static /heap-arrays:1024 /Qipo") - # CMake does not pass the compile flags to an IFX executable link on Windows. + # CMake does not pass the compile flags to an IFX executable link on Windows. The shared + # library (the Windows wheel's cabledyn.dll) links the same IPO objects, so it needs the + # flag as well: without it IFX hands the intermediate objects to link.exe (LNK1107). string(APPEND CMAKE_EXE_LINKER_FLAGS " /Qipo") + string(APPEND CMAKE_SHARED_LINKER_FLAGS " /Qipo") # LAPACK/BLAS: the netlib reference implementation compiled with this IFX # (release/build_static_windows.ps1 builds it), not MKL. Every CableDyn system is # banded with a small bandwidth, and MKL's sequential DGBTRF/DGBTRS/DTBSV spend @@ -63,6 +71,39 @@ if(CABLEDYN_STATIC_WINDOWS_RELEASE) endforeach() endif() +# x86-64-v3 (Intel Haswell, AMD Excavator and newer): the vector maths of the wave +# kinematics and the element kernels run four doubles wide instead of two. On the release +# benchmark decks the IFX driver is 13-33% faster; results change at round-off (the wider +# vector maths), identically on every x86-64-v3 processor. Contraction into fused +# multiply-adds stays off: the compiler would fuse the same expression differently where it +# is inlined, so a restored or re-entered state would no longer continue a run bit for bit +# (test_line_attachments), for 2-5% more speed. IFX takes the /arch form, documented for +# any processor with these instructions (the /Qx form is documented for Intel processors; +# both gave the same code and speed on the benchmarks). +# Only the Fortran sources take it: src/cabledyn_cpu_guard.c stays baseline x86-64 and +# stops a program on an older processor with a message before any Fortran code runs. +if(CABLEDYN_TARGET_AVX2) + if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(AMD64|amd64|x86_64|X86_64)$") + message(FATAL_ERROR "CABLEDYN_TARGET_AVX2 needs an x86-64 target (found ${CMAKE_SYSTEM_PROCESSOR})") + endif() + if(CMAKE_Fortran_COMPILER_ID STREQUAL "IntelLLVM") + if(WIN32) + string(APPEND CMAKE_Fortran_FLAGS " /arch:CORE-AVX2 /Qfma-") + else() + string(APPEND CMAKE_Fortran_FLAGS " -march=core-avx2 -no-fma") + endif() + elseif(CMAKE_Fortran_COMPILER_ID STREQUAL "GNU") + if(CMAKE_Fortran_COMPILER_VERSION VERSION_LESS 11) + message(FATAL_ERROR "CABLEDYN_TARGET_AVX2 needs GNU Fortran 11 or newer (-march=x86-64-v3)") + endif() + string(APPEND CMAKE_Fortran_FLAGS " -march=x86-64-v3 -ffp-contract=off") + else() + message(FATAL_ERROR "CABLEDYN_TARGET_AVX2 supports Intel IFX and GNU Fortran") + endif() + set_source_files_properties(src/cabledyn_cpu_guard.c PROPERTIES + COMPILE_DEFINITIONS CABLEDYN_REQUIRE_X86_64_V3) +endif() + # src/cabledyn_mutex.c uses C11 , which the MSVC C compiler provides only # behind this flag, in every MSVC configuration (static release or not). if(CMAKE_C_COMPILER_ID STREQUAL "MSVC") @@ -328,11 +369,13 @@ add_library(cabledyn_obj OBJECT src/cabledyn_mutex.c src/cabledyn_blas.c src/cabledyn_crt_locale.c + src/cabledyn_cpu_guard.c src/cabledyn_path.c src/cabledyn_fatal.c src/CableDyn_Precision.f90 src/CableDyn_FatalReport.f90 src/CableDyn_Profile.f90 + src/CableDyn_LoadPredictor.f90 src/CableDyn_PathIO.f90 src/CableDyn_Banner.f90 src/CableDyn_Linalg.f90 @@ -1084,6 +1127,32 @@ set_tests_properties(openfast_touchdown PROPERTIES # Other BLAS implementations ignore this environment variable. ENVIRONMENT "OPENBLAS_CORETYPE=Sandybridge") +# The reaction of a finite-EI cable assembles only the rows of its node: bit-identical to the +# full assembly at every node, with every load section, on hand-built cables and along a +# coupled trajectory of the same deck, and without touching the model's scratch. +add_executable(test_hermite_node_reaction tests/test_hermite_node_reaction.f90) +target_link_libraries(test_hermite_node_reaction PRIVATE cabledyn_core) +add_test(NAME hermite_node_reaction + COMMAND test_hermite_node_reaction ${CMAKE_SOURCE_DIR}/examples/iea15mw_umaine_mixed_cabledyn.dat) +set_tests_properties(hermite_node_reaction PROPERTIES + LABELS "fortran;unit;dynamic;openfast;contact" + ENVIRONMENT "OPENBLAS_CORETYPE=Sandybridge") + +# The dtM advisor on the coupled route: the mixed example's power cable (axial period 0.351 s) +# is noted at dtM = 0.1 s with a dtM that resolves it; nothing is noted at 0.05 s. +add_executable(test_dtm_advisor tests/test_dtm_advisor.f90) +target_link_libraries(test_dtm_advisor PRIVATE cabledyn_core) +add_test(NAME dtm_advisor + COMMAND test_dtm_advisor ${CMAKE_SOURCE_DIR}/examples/iea15mw_umaine_mixed_cabledyn.dat) +set_tests_properties(dtm_advisor PROPERTIES LABELS "fortran;unit;openfast") + +# The load predictor between supercycled OpenFAST solves: used only between consecutive commits, +# exact at the commit and for loads linear in time, second order otherwise. +add_executable(test_load_predictor tests/test_load_predictor.f90) +target_link_libraries(test_load_predictor PRIVATE cabledyn_core) +add_test(NAME load_predictor COMMAND test_load_predictor) +set_tests_properties(load_predictor PROPERTIES LABELS "fortran;unit;openfast") + # The same deck initialises without a static compression/mesh note: the touchdown # contact kink is element-local, the smoothed element-mean axial force is tensile. add_executable(test_flagship_compression_note tests/test_flagship_compression_note.f90) @@ -1773,6 +1842,16 @@ if(CABLEDYN_STATIC_WINDOWS_RELEASE) target_link_options(cabledyn PRIVATE "LINKER:/STACK:268435456") endif() install(TARGETS cabledyn RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +if(SKBUILD) + # A Python wheel (pyproject.toml, scikit-build-core, install component `python`) carries the + # C-ABI library beside the package modules, where cabledyn._discovery looks first, and the + # driver in the package's bin folder, where cabledyn.driver looks after CABLEDYN_DRIVER. The + # wheel repair tools bundle the runtime and LAPACK libraries they need. + install(TARGETS cabledyn_shared + RUNTIME DESTINATION cabledyn COMPONENT python + LIBRARY DESTINATION cabledyn COMPONENT python) + install(TARGETS cabledyn RUNTIME DESTINATION cabledyn/bin COMPONENT python) +endif() if(WIN32 AND CABLEDYN_RUNTIME_DLLS) # A conventional GNU/conda Windows install must be usable outside the build # environment, not only from the separately assembled release bundle. @@ -1959,6 +2038,21 @@ add_executable(test_crt_locale_guard tests/test_crt_locale_guard.f90 tests/crt_l target_link_libraries(test_crt_locale_guard PRIVATE cabledyn_core) add_test(NAME crt_locale_guard COMMAND test_crt_locale_guard $,1,0>) set_tests_properties(crt_locale_guard PROPERTIES LABELS "fortran;unit") +# The x86-64-v3 start-up processor check is armed exactly in CABLEDYN_TARGET_AVX2 builds. +add_executable(test_cpu_guard tests/test_cpu_guard.f90) +target_link_libraries(test_cpu_guard PRIVATE cabledyn_core) +add_test(NAME cpu_guard COMMAND test_cpu_guard $,1,0>) +set_tests_properties(cpu_guard PROPERTIES LABELS "fortran;unit") +if(CABLEDYN_TARGET_AVX2) + # The armed check stops the driver before any Fortran code runs, with the message naming + # what is missing (a simulated feature, so the stop is exercised on any processor). + add_test(NAME cpu_guard_stop + COMMAND ${CMAKE_COMMAND} -E env CABLEDYN_CPU_GUARD_SIMULATE=SIMULATED-FEATURE + $ --version) + set_tests_properties(cpu_guard_stop PROPERTIES LABELS "fortran;unit" + PASS_REGULAR_EXPRESSION "This computer lacks: SIMULATED-FEATURE" + FAIL_REGULAR_EXPRESSION "CableDyn v") +endif() # An abnormal end of the driver process is never silent: a stack overflow (on a 1 MiB stack # in Windows GNU builds), an access violation and, off Windows, SIGTERM each end the process # with a report on stderr of the cause and the simulated time reached; the march records the @@ -2047,7 +2141,7 @@ add_test( -DDRIVER=$ -DDECK=${CMAKE_CURRENT_SOURCE_DIR}/examples/dynamic_chain_waves.dat -DOUTPUT_ROOT=${CMAKE_CURRENT_BINARY_DIR}/driver_profile_ei0 - "-DEXPECT=\n EI = 0 line steps: [1-9][0-9]* \\(substeps and retries included\\), Newton iterations: [1-9]" + "-DEXPECT=\n EI = 0 line steps: [1-9][0-9]* \\(substeps and retries included\\), Newton iterations: [1-9][^\n]*\n EI = 0 line step kernels: residual [0-9]+ \\([0-9.]+ s\\), residual and tangent [1-9][0-9]* \\([0-9.]+ s\\), factorisation [1-9][0-9]* \\([0-9.]+ s\\), back-substitution [1-9][0-9]* \\([0-9.]+ s\\)\\." -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_driver_profile.cmake) add_test( NAME driver_profile_finite_ei @@ -2055,7 +2149,7 @@ add_test( -DDRIVER=$ -DDECK=${CMAKE_CURRENT_SOURCE_DIR}/tests/data/held_start_gomex80_power_cable.dat -DOUTPUT_ROOT=${CMAKE_CURRENT_BINARY_DIR}/driver_profile_finite_ei - "-DEXPECT=\n Finite-EI cable steps: [1-9][0-9]*, residual evaluations: [1-9]" + "-DEXPECT=\n Finite-EI cable steps: [1-9][0-9]*, residual evaluations: [1-9][0-9]*, tangent assemblies: [1-9][0-9]*, factorisations: [1-9][0-9]*, linear solves: [1-9][0-9]*, force-blend corrections: [0-9]+[^\n]*\n Finite-EI cable step time \\(s\\): residual [0-9.]+, tangent [0-9.]+, effective matrix [0-9.]+, factorisation [0-9.]+, back-substitution [0-9.]+, added mass [0-9.]+, other [0-9.]+\\.\n Finite-EI cable assembly time \\(s\\): band zeroing [0-9.]+, tangent element kernels [0-9.]+, tangent scatter [0-9.]+, structural residual [0-9.]+, weight [0-9.]+, attachments [0-9.]+, dry buoyancy [0-9.]+, seabed contact [0-9.]+, axial damping [0-9.]+, drag [0-9.]+, end connections [0-9.]+, wave Froude-Krylov [0-9.]+, held Froude-Krylov [0-9.]+, torsion [0-9.]+\\." -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_driver_profile.cmake) add_test( NAME driver_profile_failure @@ -2190,6 +2284,15 @@ if(Python3_Interpreter_FOUND) set_tests_properties(driver_ei0_fold_layouts PROPERTIES LABELS "python;driver;integration;static" ENVIRONMENT "${_cabledyn_python_test_environment}") + # Node tension through touchdown on a coarse mesh (10 m grounded elements) follows a four + # times finer reference within 3.5 %: the touchdown element, grounded at one end only, takes + # no share of its grounded node's floor reaction. + add_test(NAME driver_touchdown_recovery + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_touchdown_recovery.py + $) + set_tests_properties(driver_touchdown_recovery PROPERTIES + LABELS "python;driver;integration;static" + 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 7e3c14f..2f7bfa8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -44,6 +44,18 @@ The Debug configuration (`-DCMAKE_BUILD_TYPE=Debug`) adds `-fcheck=all conda OpenBLAS through `CONDA_PREFIX`. `cmake --install build --prefix stage` installs the driver, the shared library, and `CableDyn_CAPI.h`. +`-DCABLEDYN_TARGET_AVX2=ON` compiles the Fortran core for x86-64-v3 (AVX2, FMA, +BMI1/2: Intel Haswell and AMD Excavator or newer) with IFX (`/arch:CORE-AVX2`) or +gfortran 11+ (`-march=x86-64-v3`), with contraction into fused multiply-adds off +(`/Qfma-`, `-ffp-contract=off`) so a restored state still continues a run bit for +bit. The static Windows release always sets it: its IFX driver runs the benchmark +decks 13-33% faster, and the results change at round-off. Such a build checks the +processor when it starts +(`src/cabledyn_cpu_guard.c`, compiled for baseline x86-64) and stops with a +message naming the missing instructions instead of crashing on the first one; +the driver's `--version` output and the OpenFAST module's start-up lines report the +x86-64-v3 build. The default (`OFF`) runs on any x86-64 processor. + A Windows build with gfortran links every executable with a 64 MiB stack reserve (the MinGW default is 2 MiB). With OpenMP enabled, gfortran keeps fixed-size local arrays on the stack, and the stack depth of the OpenBLAS kernels differs between @@ -93,6 +105,60 @@ the shared library. They compare against independent references (the ASTM E1049 cycle table and analytical sinusoids); do not replace these with snapshots of CableDyn output. +### Distribution: sdist and platform wheels + +The root `pyproject.toml` builds the published distribution with scikit-build-core: an +sdist of the sources and a platform wheel that carries the compiled library beside the +package modules and the standalone driver in `cabledyn/bin`. Only the `cabledyn_shared` +and `cabledyn` targets are built and installed (CMake install component `python`, active +under scikit-build only), serially (`CABLEDYN_ENABLE_OPENMP` off). The wheel is tagged +`py3-none-`: the package reaches the library through `ctypes`, so one wheel +serves every supported Python version. `python/pyproject.toml` stays the pure package for +development against a build tree; `tests/test_packaging_metadata.py` keeps the two +`[project]` tables identical except for the readme, licence files and platform +classifiers. + +To build both locally from the activated `cabledyn` environment (Ninja, the conda GNU +toolchain, and forward slashes in the archiver paths): + +```powershell +python -m pip install "scikit-build-core>=0.10" build +python -m build --sdist --no-isolation --outdir dist . +$ar = "$env:CONDA_PREFIX/Library/bin/x86_64-w64-mingw32-ar.exe" -replace '\\','/' +$rl = "$env:CONDA_PREFIX/Library/bin/x86_64-w64-mingw32-ranlib.exe" -replace '\\','/' +python -m pip wheel . --no-deps --no-build-isolation -w dist ` + "--config-settings=cmake.args=-GNinja" ` + --config-settings=cmake.define.CMAKE_C_COMPILER=gcc ` + --config-settings=cmake.define.CMAKE_Fortran_COMPILER=gfortran ` + --config-settings=cmake.define.CMAKE_AR=$ar --config-settings=cmake.define.CMAKE_RANLIB=$rl +``` + +A wheel built this way still needs the toolchain's runtime and LAPACK libraries on the +search path; the release wheels bundle them (or link them statically on Windows). + +The release wheels come from `.github/workflows/wheels.yml` (manual runs, published +releases, and pull requests that touch the packaging), as workflow artifacts, which only +its publish jobs upload (TestPyPI from a manual run with **upload = testpypi**, PyPI from a +published release; see +[RELEASE.md](https://github.com/SMI-Lab-Inha/CableDyn/blob/main/RELEASE.md)). It builds +the sdist (checked with `twine`) and three wheels: + +| Wheel | Toolchain | Runtime and LAPACK | Build | +|---|---|---|---| +| Linux x86-64 (`manylinux_2_28`) | cibuildwheel, image GCC toolset | OpenBLAS and the Fortran runtime bundled by auditwheel | x86-64-v3 | +| macOS arm64 (14.0+) | cibuildwheel, Homebrew gfortran | OpenBLAS and the Fortran runtime bundled by delocate | baseline | +| Windows x86-64 | `release/build_windows_wheel.ps1`: IFX and MSVC, static runtime | the release's reference LAPACK, linked statically | x86-64-v3 | + +The Windows wheel uses the static release configuration and the same pinned reference +LAPACK as `release/build_static_windows.ps1` (`tests/test_packaging_metadata.py` keeps +the version, checksum and flags equal), and the script checks with `dumpbin` that the +library and the driver import only Windows system DLLs. Each wheel is installed into a +clean environment and checked by `release/smoke_test_wheel.py`: the package loads the +library it carries, selects the driver it carries, the driver reports the expected build +(`--expect-build`), and a standalone deck runs. On a processor without AVX2 the x86-64 +wheels' driver stops with the start-up message, and so does a Python process when it +loads the library. + On Windows, run Python from the activated `cabledyn` environment and point `CABLEDYN_LIBRARY` at the DLL. Do not prepend compiler or OpenBLAS runtime directories to `PATH` before importing NumPy: mixed runtime DLLs can terminate diff --git a/README.md b/README.md index b5b9f44..eb0db3b 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,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.2.0`. Interfaces are +The current stable release is `v0.3.0`. Interfaces are versioned and may change before `1.0.0`. ## Capabilities @@ -46,10 +46,10 @@ a named error rather than running approximately. | Asset | Contents | |---|---| - | `CableDyn_driver.exe` | Standalone solver for Windows x64 | + | `CableDyn_driver.exe` | Standalone solver for Windows x64 (AVX2 processor) | | `openfast.exe` | OpenFAST with CableDyn available as `CompMooring = 5` | - | `cabledyn-0.2.0-py3-none-any.whl` | Python package (pre- and post-processing, batch runs) | - | `cabledyn-0.2.0.tar.gz` | Python source distribution | + | `cabledyn-0.3.0-py3-none-any.whl` | Python package (pre- and post-processing, batch runs) | + | `cabledyn-0.3.0.tar.gz` | Python source distribution | | `SHA256SUMS.txt` | Checksums for the assets above | Both executables are statically linked, need no installer, compiler, or runtime @@ -76,19 +76,22 @@ a named error rather than running approximately. The driver does not create folders: an output root such as `results\wd0050` needs `New-Item -ItemType Directory -Force results | Out-Null` first. -4. Optionally, script runs from Python. `cabledyn-run` needs to find the driver: - pass `--executable`, set the `CABLEDYN_DRIVER` environment variable, or put the - driver on `PATH`. A relative output root is resolved against the deck's folder, so - this run writes `examples\wd0050.out`: +4. Optionally, script runs from Python. The package on PyPI carries the solver + library and the driver (wheels for Windows x64 and Linux x86-64 with an AVX2 + processor, and macOS on Apple silicon), so nothing else is needed. A relative + output root is resolved against the deck's folder, so this run writes + `examples\wd0050.out`: ```powershell - python -m pip install "cabledyn-0.2.0-py3-none-any.whl[post]" - cabledyn-run examples\wd0050_chain.dat wd0050 --executable .\CableDyn_driver.exe + python -m pip install "cabledyn[post]" + cabledyn-run examples\wd0050_chain.dat wd0050 ``` - A source build names the driver `cabledyn` (see - [Build from source](#build-from-source)); the release executable is - `CableDyn_driver.exe`. + The pure Python wheel on the release page (`cabledyn-0.3.0-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)); + the release executable is `CableDyn_driver.exe`. For OpenFAST, set `CompMooring = 5` in the `.fst` file and point `MooringFile` at a CableDyn deck; ready-to-run cases are in @@ -151,7 +154,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.2.0). +To identify the software version, also cite the release (CableDyn 0.3.0). 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 9136568..ded58fe 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -119,6 +119,19 @@ manually (`workflow_dispatch`) as a preflight. Download its `CableDyn-release-assets` artifact, verify every checksum (on a second machine where practical), and keep the workflow URL with the release record. +The PyPI distribution (the sdist and the Linux, macOS and Windows wheels that carry +the library and the driver) comes from `.github/workflows/wheels.yml`. As its +preflight, run that workflow manually with **upload = testpypi**: it builds and +smoke-tests the four distributions and uploads them to TestPyPI (a version already +there is skipped). Then, in a clean environment on each platform you can reach: + +```powershell +python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ "cabledyn==MAJOR.MINOR.PATCH" +python release/smoke_test_wheel.py examples/dynamic_chain_current.dat --expect-build x86-64-v3 +``` + +(`--expect-build baseline` on macOS arm64.) + ## 5. Tag and publish Tag only the commit that passed every check: @@ -141,9 +154,16 @@ partial release or invalid manifest fails without replacing any file. If assets not match, withdraw the release and investigate; never replace a file without a documented correction. +Publishing also starts `.github/workflows/wheels.yml`, which rebuilds and smoke-tests +the PyPI distributions from the tag, checks that every file carries the tag's +version, and then waits for approval of the `pypi` environment. Approve it only +after the release assets above check out: a version uploaded to PyPI can be yanked +but never replaced. + ## 6. Close the release -Confirm that the release page lists all five assets, the documentation builds from +Confirm that the release page lists all five assets, +lists the sdist and the three wheels of the release, the documentation builds from the tag, the GitHub source archives contain no credentials or run output, and a fresh clone passes the fast test tier. Then add an empty `[Unreleased]` section to the changelog. @@ -162,6 +182,12 @@ Before the first release is published from a new GitHub repository: - [ ] **Actions**: allow GitHub-hosted runners; the workflows need no secrets beyond the automatic `GITHUB_TOKEN`, and only the release job writes (`contents: write`). +- [ ] **PyPI**: on and , register a + (pending) Trusted Publisher for the project `cabledyn`: this repository, the + workflow `wheels.yml`, and the environments `pypi` and `testpypi` + respectively. Create both environments in the repository settings and make + a maintainer a required reviewer of `pypi`. The publish jobs then need no + token (`id-token: write` only). - [ ] **Branch protection** on `main` and `dev`: require pull requests, the `fortran` and `python` workflow checks, and linear history. - [ ] **Read the Docs**: import the repository as project `cabledyn`, build diff --git a/VALIDATION.md b/VALIDATION.md index bd2f11f..99243cd 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -321,6 +321,23 @@ scripts, results, and source provenance are in tension statistics match CableDyn's own per-glue-step solution to 0.01% mean / ~3% std. Tension agreement with MoorDyn is scored in the settled case above and in the DLC matrix below. + - *Supercycling* (`dtM` = 4 `DT` = 0.1 s): the chain mooring in a DLC-1.6 + environment (NTM 10.6 m/s turbulent wind, JONSWAP Hs 8 m / Tp 12 s, 3800 s), scored + over 200–3800 s on the common 0.1 s grid against the same model at `dtM = DT` + (`validation/scripts/openfast_supercycle_ab.py`). With the coupled loads + extrapolated between CableDyn solves (the default), every platform DOF's time series + differs by at most 0.14 % of its standard deviation (0.41 % with held loads); the + fairlead and anchor tensions keep their mean within 0.01 %, std within 0.12 % and + maximum within 0.16 %; their damage-equivalent loads (rainflow, slope 3) change by + at most 0.21 % at the fairleads and 1.5 % at the anchors (held: 0.27 % / 1.1 %), the + anchor changes being of the order of the reference's own sampling effect (its DEL + from 0.025 s against 0.1 s samples, up to 0.94 %). Against stock MoorDyn (same + seeds, 200–1200 s) the in-plane platform standard deviations agree within 0.03 % + (0.04 % at `dtM = DT`, 0.14 % held). The tension time series differ from those at + `dtM = DT` by 5–7 % of their standard deviation on the leeward lines, because the + lines step on the coarser `dtM`; statistics and damage are unaffected. A finite-EI + cable needs a finer `dtM` (the initialisation advisory; 0.05 s for the mixed + example's power cable). - *Mixed deck*: the shipped example `iea15mw_umaine_mixed_cabledyn.dat` (three chain moorings and a 248-element finite-EI power cable that touches down on the 200 m seabed) runs to completion under full coupled physics with finite, bounded @@ -746,7 +763,7 @@ stop with a named error. | Nonzero `PtfmInit` | Pass verified | 6-DOF displaced free decay ≤0.51% | | Correction iterations (`NumCrctn > 0`) | Pass verified | bit-identical to own `NumCrctn = 0` run | | SeaState wave/current kinematics | Pass verified | wave A/B ≤0.16% of range | -| Checkpoint-restart (incl. Free/Connect points) | Pass verified | restarted window = continuous run to 0.0 | +| Checkpoint-restart (incl. Free/Connect points) | Pass verified | restarted window = continuous run to 0.0; also with supercycling (`dtM` > `DT`, the held SeaState fields and the predictor loads checkpointed): IEA-15 VolturnUS-S, turbulent wind and JONSWAP sea, `openfast -restart` at 50 s reproduces every later OpenFAST and `.CD.out` row of the chain mooring at `dtM` = 4 `DT` (extrapolated and held loads) and `DT`, and of the chain + 248-element power-cable mooring at `dtM` = 2 `DT` | | Linearisation | Pass verified (quasi-static stiffness) | quasi-static `dYdu` compared entry by entry with stock MoorDyn's zero-frequency reduction `D − C A⁻¹ B` (full-precision `.lin` output): fairlead stiffness 0.07–0.10%, transverse `H/X` term 0.29%, platform 6×6 diagonals ≤0.10%; standalone static FD 1.5e-5; transverse term equals `H/X`. Velocity/acceleration columns are zero by design ([`openfast_lin_ab.py`](https://github.com/SMI-Lab-Inha/CableDyn/blob/main/validation/scripts/openfast_lin_ab.py)) | | FAST.Farm shared moorings + clumps + channel record | Pass verified | 600 s A/B of the r-test `MD_Shared` farm against stock MoorDyn (same deck, analysis from 100 s; [`fastfarm_shared_ab.py`](https://github.com/SMI-Lab-Inha/CableDyn/blob/main/validation/scripts/fastfarm_shared_ab.py)): tension means ≤0.02%, minima and maxima ≤0.25%, platform motions within millimetres and 0.001°; shared-line tension std 2.2–2.7% lower, 1.0% at half the mooring step (CableDyn's implicit step at the 0.05 s coupling step against MoorDyn's 1e-4 s explicit step). On the shared lines, which have no Fixed end, `FairTen`/`AnchTen` name the opposite ends from MoorDyn's (see the migration guide). MoorDyn's initial settling does not converge for the anchor legs 6 and 7 (2.4 m excess length at t = 0), a start-up transient over by 100 s that most likely set the 3% end of the earlier 60 s record | | Slack-line tension convention | Pass verified | tension-only dynamics; BA on slack lines matches `MoorDyn_Line.f90` | diff --git a/app/cabledyn.f90 b/app/cabledyn.f90 index 1f4bf25..3e2b9fb 100644 --- a/app/cabledyn.f90 +++ b/app/cabledyn.f90 @@ -37,10 +37,14 @@ PROGRAM cabledyn USE CableDyn_Profile, ONLY: CD_Profile_Requested, CD_Profile_Arm, CD_Profile_Armed, CD_Profile_Enter, & CD_Profile_Flush, CD_Profile_Step, CD_Profile_Seconds, CD_Profile_Counts, & CD_PROF_INPUT, CD_PROF_INIT, CD_PROF_FLUID, CD_PROF_SOLVE, CD_PROF_CHECK, & - CD_PROF_OUTPUT, CD_PROF_PROGRESS, CD_PROF_FINAL, CD_PROF_NPHASE + CD_PROF_OUTPUT, CD_PROF_PROGRESS, CD_PROF_FINAL, CD_PROF_NPHASE, & + CD_Profile_Kernels, CD_PROF_K_RESIDUAL, CD_PROF_K_TANGENT, CD_PROF_K_FACTOR, & + CD_PROF_K_SOLVE, CD_PROF_NKERNEL USE CableDyn_HermiteCableDynamic, ONLY: CD_HermiteCable_Dyn_Reset_Profile, CD_HermiteCable_Dyn_Get_Profile, & - CD_HermiteCable_Dyn_Recovery_Reset, CD_HermiteCable_Dyn_Recovery_Count + CD_HermiteCable_Dyn_Recovery_Reset, CD_HermiteCable_Dyn_Recovery_Count, & + CD_HermiteCable_Dyn_Get_Assembly_Profile, HC_ASM_NSEC, HC_ASM_NAMES USE, INTRINSIC :: ISO_FORTRAN_ENV, ONLY: error_unit, output_unit, int64 + USE, INTRINSIC :: ISO_C_BINDING, ONLY: C_INT IMPLICIT NONE TYPE :: StandaloneProgress INTEGER :: nstep = 0, stride = 1 @@ -53,6 +57,14 @@ PROGRAM cabledyn CHARACTER(*), PARAMETER :: PROG = 'CableDyn_driver' ! Lock file beside the outputs that keeps a second run off the same output root. CHARACTER(*), PARAMETER :: LOCK_SUFFIX = '.cabledyn.lock' + INTERFACE + ! 1 when this build targets x86-64-v3 and carries the start-up processor check of + ! src/cabledyn_cpu_guard.c (the reference also keeps that check in the static link) + FUNCTION cpu_guard_armed() BIND(C, name='cabledyn_cpu_guard_armed') RESULT(armed) + IMPORT :: C_INT + INTEGER(C_INT) :: armed + END FUNCTION cpu_guard_armed + END INTERFACE ! Longest suffix a run appends to the output root (".Line.range.out" with a 10-digit id). INTEGER, PARAMETER :: MAX_OUTPUT_SUFFIX = 25 ! Command-line paths as UTF-8; the native spellings are what the Fortran runtime opens. @@ -77,6 +89,11 @@ PROGRAM cabledyn SELECT CASE (TRIM(deck_path)) CASE ('-v', '-V', '-version', '-VERSION', '--version') CALL CD_Print_Banner() + IF (cpu_guard_armed() /= 0) THEN + WRITE (output_unit, '(A)') ' Build x86-64-v3 (AVX2)' + ELSE + WRITE (output_unit, '(A)') ' Build baseline' + END IF STOP 0, QUIET = .TRUE. CASE ('-h', '-H', '-help', '-HELP', '--help', '-?', '/?') CALL CD_Print_Banner() @@ -857,9 +874,11 @@ SUBROUTINE print_profile() !! of each phase of the run, the step counts and the Newton iterations, and the interior of !! the finite-EI cable steps. Printed once, on stdout, at the end of the run. LOGICAL, SAVE :: printed = .FALSE. - REAL(wp) :: seconds(CD_PROF_NPHASE), total, march, t_step, t_resid, t_eff, t_solve, t_am, t_tan - INTEGER(int64) :: march_steps, line_steps, line_iterations - INTEGER :: line_max_iterations, n_step, n_resid, n_solve, n_am, n_alloc, n_tan, k + REAL(wp) :: seconds(CD_PROF_NPHASE), total, march, t_step, t_resid, t_eff, t_solve, t_am, t_tan, t_factor + REAL(wp) :: kernel_seconds(CD_PROF_NKERNEL), asm_seconds(HC_ASM_NSEC) + CHARACTER(:), ALLOCATABLE :: asm_line + INTEGER(int64) :: march_steps, line_steps, line_iterations, kernel_calls(CD_PROF_NKERNEL) + INTEGER :: line_max_iterations, n_step, n_resid, n_solve, n_am, n_alloc, n_tan, n_factor, n_correct, k IF (printed .OR. .NOT. CD_Profile_Armed()) RETURN printed = .TRUE. ! the running phase up to now: the finalisation of a completed run, or the phase in which a @@ -887,16 +906,31 @@ SUBROUTINE print_profile() IF (line_steps > 0_int64) WRITE (output_unit, '(A,I0,A,I0,A,A,A,I0,A)') ' EI = 0 line steps: ', & line_steps, ' (substeps and retries included), Newton iterations: ', line_iterations, ' (', & fixed(REAL(line_iterations, wp)/REAL(line_steps, wp), 3), ' per line step, at most ', line_max_iterations, ').' + CALL CD_Profile_Kernels(kernel_calls, kernel_seconds) + IF (line_steps > 0_int64) WRITE (output_unit, '(A,I0,3A,I0,3A,I0,3A,I0,3A)') & + ' EI = 0 line step kernels: residual ', kernel_calls(CD_PROF_K_RESIDUAL), ' (', & + fixed(kernel_seconds(CD_PROF_K_RESIDUAL), 3), ' s), residual and tangent ', kernel_calls(CD_PROF_K_TANGENT), & + ' (', fixed(kernel_seconds(CD_PROF_K_TANGENT), 3), ' s), factorisation ', kernel_calls(CD_PROF_K_FACTOR), & + ' (', fixed(kernel_seconds(CD_PROF_K_FACTOR), 3), ' s), back-substitution ', kernel_calls(CD_PROF_K_SOLVE), & + ' (', fixed(kernel_seconds(CD_PROF_K_SOLVE), 3), ' s).' CALL CD_HermiteCable_Dyn_Get_Profile(n_step, n_resid, n_solve, n_am, n_alloc, t_step, t_resid, t_eff, & - t_solve, t_am, n_tan, t_tan) + t_solve, t_am, n_tan, t_tan, n_factor, t_factor, n_correct) IF (n_step > 0) THEN - WRITE (output_unit, '(A,I0,A,I0,A,I0,A,I0,A,I0,A)') ' Finite-EI cable steps: ', n_step, & - ', residual evaluations: ', n_resid, ', tangent assemblies: ', n_tan, ', linear solves: ', n_solve, & + WRITE (output_unit, '(A,I0,A,I0,A,I0,A,I0,A,I0,A,I0,A,I0,A)') ' Finite-EI cable steps: ', n_step, & + ', residual evaluations: ', n_resid, ', tangent assemblies: ', n_tan, ', factorisations: ', n_factor, & + ', linear solves: ', n_solve, ', force-blend corrections: ', n_correct, & ', intervals recovered by substeps: ', CD_HermiteCable_Dyn_Recovery_Count(), '.' - WRITE (output_unit, '(13A)') ' Finite-EI cable step time (s): residual ', fixed(t_resid, 3), & - ', tangent ', fixed(t_tan, 3), ', effective matrix ', fixed(t_eff, 3), ', linear solve ', & - fixed(t_solve, 3), ', added mass ', fixed(t_am, 3), ', other ', & - fixed(MAX(0.0_wp, t_step - (t_resid + t_tan + t_eff + t_solve + t_am)), 3), '.' + WRITE (output_unit, '(15A)') ' Finite-EI cable step time (s): residual ', fixed(t_resid, 3), & + ', tangent ', fixed(t_tan, 3), ', effective matrix ', fixed(t_eff, 3), ', factorisation ', & + fixed(t_factor, 3), ', back-substitution ', fixed(t_solve, 3), ', added mass ', fixed(t_am, 3), ', other ', & + fixed(MAX(0.0_wp, t_step - (t_resid + t_tan + t_eff + t_factor + t_solve + t_am)), 3), '.' + CALL CD_HermiteCable_Dyn_Get_Assembly_Profile(asm_seconds) + asm_line = ' Finite-EI cable assembly time (s):' + DO k = 1, HC_ASM_NSEC + asm_line = asm_line//' '//TRIM(HC_ASM_NAMES(k))//' '//fixed(asm_seconds(k), 3) + IF (k < HC_ASM_NSEC) asm_line = asm_line//',' + END DO + WRITE (output_unit, '(A)') asm_line//'.' ELSE IF (.NOT. profile_cable_detail .AND. n_finite_ei > 0) THEN WRITE (output_unit, '(A)') ' Finite-EI cable steps: not counted on the mixed EI = 0 + finite-EI route, '// & 'whose cables step concurrently.' diff --git a/doc/capi.rst b/doc/capi.rst index 8944618..77ea8dc 100644 --- a/doc/capi.rst +++ b/doc/capi.rst @@ -110,12 +110,12 @@ files directly, as shown below. - Library files - Header * - Linux - - ``lib/libcabledyn.so.0.2.0``, the soname link ``libcabledyn.so.1``, and the development + - ``lib/libcabledyn.so.0.3.0``, 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.2.0.dylib``, ``lib/libcabledyn.1.dylib``, ``lib/libcabledyn.dylib`` + - ``lib/libcabledyn.0.3.0.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 @@ -245,7 +245,7 @@ ABI and versioning - the same four numbers, reported by the loaded library * - ``CableDyn_GetVersionString`` - runtime - - ``"CableDyn 0.2.0 C-ABI 1"`` + - ``"CableDyn 0.3.0 C-ABI 1"`` * - shared-library ``SOVERSION`` - 1 - soname ``libcabledyn.so.1`` or install name ``libcabledyn.1.dylib``. The Windows DLL @@ -732,7 +732,7 @@ Version queries - ``char *`` - ``version_len`` - yes: no-op - - receives ``"CableDyn 0.2.0 C-ABI 1"``, truncated to ``version_len - 1`` characters and + - receives ``"CableDyn 0.3.0 C-ABI 1"``, truncated to ``version_len - 1`` characters and ``NUL``-terminated * - ``version_len`` - in diff --git a/doc/citing.rst b/doc/citing.rst index 822a3a1..d3d89e6 100644 --- a/doc/citing.rst +++ b/doc/citing.rst @@ -37,21 +37,21 @@ Software release ---------------- Seo, J. H. (2026). *CableDyn: A cable and mooring dynamics solver for floating offshore -wind* (version 0.2.0) [Computer software]. https://github.com/SMI-Lab-Inha/CableDyn +wind* (version 0.3.0) [Computer software]. https://github.com/SMI-Lab-Inha/CableDyn .. code-block:: bibtex @software{CableDyn_0_1_1, author = {Seo, Jae Hoon}, title = {{CableDyn}: A cable and mooring dynamics solver for floating offshore wind}, - version = {0.2.0}, + version = {0.3.0}, year = {2026}, url = {https://github.com/SMI-Lab-Inha/CableDyn}, license = {Apache-2.0} } When results depend on a particular release, quote the version reported in the solver banner -(``CableDyn v0.2.0``) together with the options recorded for the run. +(``CableDyn v0.3.0``) together with the options recorded for the run. Relation to the journal article ------------------------------- diff --git a/doc/cli.rst b/doc/cli.rst index d9f0ec0..affb914 100644 --- a/doc/cli.rst +++ b/doc/cli.rst @@ -157,20 +157,22 @@ breakdown such as: .. code-block:: text Run-time profile (CABLEDYN_PROFILE), wall-clock time: - input parsing 0.002 s 0.0 % - initialisation and statics 0.233 s 3.1 % - dynamic march 7.232 s 96.8 % - fluid sampled by the driver 0.050 s 0.7 % - line, cable and object steps 7.168 s 96.0 % - checks and line failures 0.001 s 0.0 % - outputs 0.013 s 0.2 % + input parsing 0.002 s 0.1 % + initialisation and statics 0.238 s 8.7 % + dynamic march 2.496 s 91.2 % + fluid sampled by the driver 0.051 s 1.9 % + line, cable and object steps 2.430 s 88.8 % + checks and line failures 0.002 s 0.1 % + outputs 0.013 s 0.5 % progress and bookkeeping 0.000 s 0.0 % finalisation 0.000 s 0.0 % - total 7.467 s 100.0 % - March: 600 committed step(s), 12.0528 ms per step. - EI = 0 line steps: 3621 (substeps and retries included), Newton iterations: 4661 (1.287 per line step, at most 2). - Finite-EI cable steps: 4849, residual evaluations: 21255, tangent assemblies: 10391, linear solves: 10391, intervals recovered by substeps: 0. - Finite-EI cable step time (s): residual 1.416, tangent 2.674, effective matrix 0.136, linear solve 0.943, added mass 0.167, other 0.457. + total 2.736 s 100.0 % + March: 600 committed step(s), 4.1596 ms per step. + EI = 0 line steps: 3621 (substeps and retries included), Newton iterations: 4739 (1.309 per line step, at most 2). + EI = 0 line step kernels: residual 6947 (0.040 s), residual and tangent 3322 (0.064 s), factorisation 3322 (0.013 s), back-substitution 5126 (0.009 s). + Finite-EI cable steps: 1423, residual evaluations: 6140, tangent assemblies: 2914, factorisations: 2914, linear solves: 2914, force-blend corrections: 1423, intervals recovered by substeps: 0. + Finite-EI cable step time (s): residual 0.420, tangent 0.913, effective matrix 0.039, factorisation 0.213, back-substitution 0.076, added mass 0.050, other 0.086. + Finite-EI cable assembly time (s): band zeroing 0.033, tangent element kernels 0.809, tangent scatter 0.065, structural residual 0.173, weight 0.003, attachments 0.000, dry buoyancy 0.024, seabed contact 0.007, axial damping 0.147, drag 0.240, end connections 0.001, wave Froude-Krylov 0.000, held Froude-Krylov 0.126, torsion 0.000. The phases add up to the total, which runs from the start of the deck parse to the report. If the run stops with an error, the time up to the stop counts to the phase in which it stopped, and @@ -193,9 +195,25 @@ The counts follow the march: * **EI = 0 line steps** counts every step of every tension-only line, including recovery substeps, retries and, on the body marches, each re-step of a line within the body iterations. **Newton iterations** is their total. +* **EI = 0 line step kernels** splits the work inside those steps: the residual-only + evaluations (predictor tests and line-search trials), the evaluations that also assemble the + effective tangent, the LU factorisations of that tangent, and the back-substitutions with it + (Newton updates, a reused factor and the end tangent of a body junction). Each count is + followed by its time, summed over the lines: it can exceed the wall time when lines step + concurrently, so set ``OMP_NUM_THREADS=1`` to compare it with the phase rows. * **Finite-EI cable steps** counts the same for the cubic-Hermite cables. A cable clamped to a body is re-stepped in every body iteration, so the count can exceed the number of march - steps. + steps. Its step time is split into the residual and tangent assemblies, the effective + matrix, the factorisations, the back-substitutions (of the Newton solves and of the + force-blend correction that ends each step), the added mass and the rest; the rarely used + torsion and consistent-acceleration solves count to the rest. +* **Finite-EI cable assembly time** splits the residual and tangent assemblies into their + sections. The tangent element kernels evaluate the structural, damping, drag and wave + tangents of each element together; the other sections are the element residuals, the weight + and supports, the attachments, the dry-part buoyancy, the seabed contact, the axial damping, + the drag, the end connections, the wave and held Froude-Krylov loads and the torsion. The + sections also count the assemblies made outside the step (reactions, end moments and + consistent accelerations), so they can add up to more than the residual and tangent times. An unset or empty variable, or ``0``, leaves the profile off. The driver reads the variable once, when it starts. diff --git a/doc/conventions.rst b/doc/conventions.rst index 5b5b8b8..811baa1 100644 --- a/doc/conventions.rst +++ b/doc/conventions.rst @@ -48,7 +48,9 @@ are in degrees. - ``0`` selects the ``EI = 0`` cable path * - Axial damping ``BA`` - N·s - - a negative value is a damping ratio :math:`-\zeta` (dimensionless); see :doc:`theory` + - a negative value is a damping ratio :math:`-\zeta` (dimensionless) of each element's axial + mode, so the damping of the line's own axial modes depends on the mesh; give ``BA`` in + N·s for mesh-independent damping (see :doc:`theory`) * - End-connection stiffness - N·m/rad - ``END CONNECTIONS`` table diff --git a/doc/coupling_boundary.md b/doc/coupling_boundary.md index 94b4850..666c336 100644 --- a/doc/coupling_boundary.md +++ b/doc/coupling_boundary.md @@ -108,7 +108,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.2.0 and C ABI version 1, minor extension 2. The Python package uses the same library. +CableDyn 0.3.0 and C ABI version 1, minor extension 2. The Python package uses the same library. ### Concurrency diff --git a/doc/faq.rst b/doc/faq.rst index 226b305..a554942 100644 --- a/doc/faq.rst +++ b/doc/faq.rst @@ -83,18 +83,21 @@ OpenFAST coupling .. admonition:: Why do CableDyn channels look stepped in an OpenFAST plot? :class: note - CableDyn solves on ``dtM`` (0.1 s by default) and holds its committed loads/channels between - solves on the faster glue clock of OpenFAST (maintained by NLR, the National Laboratory of the - Rockies, formerly NREL). A smaller ``DT_Out`` records more copies of the held - value; it does not add physical bandwidth. Plot ``.CD.out`` for the solver-native - record containing one row per genuine CableDyn solve. Set deck ``dtM = DT`` only when glue-rate - solves are required, and demonstrate damage/range convergence by reducing ``dtM``. + CableDyn solves on ``dtM`` (the glue ``DT`` unless the deck sets a larger ``dtM``) and, with a + larger ``dtM``, holds its committed channels between solves (its coupled loads are + extrapolated from the last two solves) on the faster glue clock of + OpenFAST (maintained by NLR, the National Laboratory of the Rockies, formerly NREL). A smaller + ``DT_Out`` records more copies of the held value; it does not add physical bandwidth. Plot + ``.CD.out`` for the solver-native record containing one row per genuine CableDyn + solve. A larger ``dtM`` is faster; demonstrate damage/range convergence by reducing it, and + heed the initialisation note on a line whose axial period it does not resolve + (:doc:`modeling`). .. admonition:: Why did ``dtM = 0.025 s`` make my coupled run much slower? :class: note - Equal ``dtM`` and OpenFAST ``DT`` intentionally request a nonlinear CableDyn solve on every glue - step, four times as many as the 0.1 s default. The release executables are serial; the + Equal ``dtM`` and OpenFAST ``DT`` (the default) request a nonlinear CableDyn solve on every glue + step, four times as many as ``dtM = 0.1 s`` at ``DT = 0.025 s``. The release executables are serial; the threading advice in this answer applies only to source builds configured with OpenMP. On such builds an ``EI = 0`` line evaluates its elements in parallel only from 8192 elements and a finite-EI cable from 32 elements, while separate lines can still run concurrently; nested @@ -104,8 +107,14 @@ OpenFAST coupling benchmark does **not** imply a fast coupled run: OpenFAST may call ``CalcOutput`` several times per physical step with different trial kinematics. Set the environment variable ``CABLEDYN_PROFILE=1`` before running ``openfast.exe`` to separate CableDyn solve, SeaState - sampling, output-probe, and native-I/O time; the standalone driver reads the same variable - and prints its own breakdown (:ref:`cli-profile`). Keep ``ModCoupling`` fixed while + sampling, output-probe, and native-I/O time, and to split the steps of ``EI = 0`` lines and + finite-EI cables into residual, tangent, factorisation and back-substitution time (with + ``OMP_NUM_THREADS=1`` the line times add up to the solve time). The ``profile parts`` line + splits ``CalcOutput`` further, into the overlay and restore of the current kinematics (at + ``nsuper = 1``) and the output channels, and splits the SeaState sampling into the node + queries, the grid checks and the hand-over of the fields; the ``Hermite assembly`` line splits + the cable assemblies into their sections (:ref:`cli-profile`). The standalone driver reads + the same variable and prints its own breakdown (:ref:`cli-profile`). Keep ``ModCoupling`` fixed while comparing configurations. Do not switch to loose coupling merely to meet a wall-clock target; on the IEA-15MW reference model it materially changed platform and tension responses. Test ``OMP_NUM_THREADS=1`` as well as the intended threaded setting: three short lines can be too diff --git a/doc/index.rst b/doc/index.rst index 754283b..a56a337 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -12,9 +12,10 @@ with MoorDyn-C, MoorDyn-F, and OrcaFlex on common reference cases. .. admonition:: Release status :class: note - CableDyn ``v0.2.0`` is the current stable release. It adds active tensioning and coupled - torsion of finite-EI cables, sub-stepping of tension-only lines through snaps and seabed - friction in the static solve, and recovers node results from nodal equilibrium. + CableDyn ``v0.3.0`` 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. Every comparative-accuracy statement is linked to a passing benchmark in :doc:`validation`. The project remains below ``1.0.0``, so public APIs may evolve between minor releases. @@ -50,8 +51,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.2.0 release - `_ and verify the checksum — + ``examples`` folder) from the `v0.3.0 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. diff --git a/doc/installation.rst b/doc/installation.rst index b7d78cb..c0d87a0 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.2.0 GitHub release - `_. No installer, no compiler, + attached to the `v0.3.0 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,10 +35,10 @@ 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`) - * - ``cabledyn-0.2.0-py3-none-any.whl`` + * - ``cabledyn-0.3.0-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.2.0.tar.gz`` + * - ``cabledyn-0.3.0.tar.gz`` - the Python source distribution (same package as the wheel) * - ``SHA256SUMS.txt`` - SHA-256 checksums of the four files above @@ -52,11 +52,19 @@ What is on the release page the ``examples/`` folder of the **Source code (zip)** archive on the same release page. Download it once and unzip it next to the executables. +.. note:: + + The executables need a 64-bit processor with AVX2 (the x86-64-v3 level: Intel Haswell, + 2013, and AMD Excavator, 2015, or newer), for which they are compiled. On an older processor + they stop at start with a message that names the missing instructions; there, build CableDyn + from source as `DEVELOPMENT.md + `_ describes. + 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.2.0-py3-none-any.whl`` + ``openfast.exe`` (only if you will couple with OpenFAST), ``cabledyn-0.3.0-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: @@ -76,8 +84,8 @@ Download and verify OK CableDyn_driver.exe OK openfast.exe - OK cabledyn-0.2.0-py3-none-any.whl - not downloaded cabledyn-0.2.0.tar.gz + OK cabledyn-0.3.0-py3-none-any.whl + not downloaded cabledyn-0.3.0.tar.gz ``not downloaded`` only reports an asset you chose to skip. @@ -86,7 +94,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.2.0``; copy (or move) +3. Unzip ``Source code (zip)``. It expands to a folder such as ``CableDyn-0.3.0``; copy (or move) its ``examples`` folder into ``C:\CableDyn`` so the layout is:: C:\CableDyn\ @@ -112,7 +120,7 @@ Check that it runs .. code-block:: text =================================================================== - CableDyn v0.2.0 + CableDyn v0.3.0 Geometrically nonlinear cable & mooring dynamics for floating wind (lazy-wave power cables and taut / semi-taut / catenary moorings) ------------------------------------------------------------------- @@ -153,24 +161,29 @@ See :doc:`standalone_driver` for the full path rules. Python package ~~~~~~~~~~~~~~ -The wheel is pure Python (NumPy is its only required dependency) and works on any platform with -Python 3.10 or newer: +Install it from PyPI (Python 3.10 or newer; NumPy is the only required dependency): .. code-block:: powershell - py -m pip install .\cabledyn-0.2.0-py3-none-any.whl + py -m pip install cabledyn # optional extras for DataFrames and plots: - py -m pip install ".\cabledyn-0.2.0-py3-none-any.whl[post]" + py -m pip install "cabledyn[post]" This installs the ``cabledyn`` package and four console commands: ``cabledyn-run``, -``cabledyn-deck``, ``cabledyn-study``, and ``cabledyn-post``. +``cabledyn-deck``, ``cabledyn-study``, and ``cabledyn-post``. The wheels for Windows x64 and +Linux x86-64 (both for processors with AVX2) and for macOS on Apple silicon carry the solver: +the CableDyn shared library and the standalone driver, so the package runs decks without the +release executables. On another platform pip builds from the source distribution, which needs a +Fortran compiler and LAPACK. -The package drives the solver as a subprocess; it does not contain the solver. It finds -``CableDyn_driver.exe`` in this order: +The pure Python wheel on the release page (``cabledyn-0.3.0-py3-none-any.whl``) carries no +solver; install it with ``py -m pip install .\cabledyn-0.3.0-py3-none-any.whl``. The package +finds the driver in this order: #. an explicit path, ``CableDynDriver(r"C:\CableDyn\CableDyn_driver.exe")`` or ``cabledyn-run --executable ...``; #. the ``CABLEDYN_DRIVER`` environment variable (the full path to the executable); +#. the driver inside the package (the PyPI wheels); #. the first ``CableDyn_driver.exe``, ``CableDyn_driver``, or ``cabledyn`` on ``PATH``. If none is found it raises ``DriverNotFoundError`` listing every location it tried. To set the @@ -188,7 +201,7 @@ Check the installation from a new terminal: .. code-block:: text - 0.2.0 C:\CableDyn\CableDyn_driver.exe + 0.3.0 C:\CableDyn\CableDyn_driver.exe The in-process API (``cabledyn.CableDyn``, stepping the solver from Python without a subprocess) additionally needs the shared library from a source build; see :doc:`python`. @@ -259,7 +272,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.2.0 assets were built from +``SHA256SUMS.txt`` into ``build-static-release\dist``. The v0.3.0 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 38231b0..25eb2eb 100644 --- a/doc/modeling.rst +++ b/doc/modeling.rst @@ -200,14 +200,30 @@ performance comparisons keep the default. Choose ``dtM`` by convergence of peaks, phase, rainflow ranges, and accumulated damage—not only by solver success. Numerical stability at a large implicit step is not evidence of fatigue accuracy. In OpenFAST the default is ``dtM = DT`` (a solve every glue step); with a larger deck ``dtM``, -loads and assembled OpenFAST channels are zero-order-held between CableDyn solves, which delays -the loads by about ``dtM/2`` (see :doc:`openfast`). The separate ``.CD.out`` contains only genuine committed +the coupled loads are extrapolated from the last two CableDyn solves (or held, with +``predictLoads False``, which delays them by about ``dtM/2``) and the assembled OpenFAST channels +are held between solves (see :doc:`openfast`). The separate ``.CD.out`` contains only genuine committed ``dtM`` samples, while ``DT_Out`` changes the assembled OpenFAST recording cadence only. Neither file convention substitutes for a time-step convergence study, which should cover the forcing bandwidth, the shortest element transit time, contact switching, and constitutive relaxation times. For prescribed motion, row 1 defines the actual :math:`t=0` position, velocity, and acceleration and must be consistent with the intended initial state. +**dtM advisory.** At initialisation each line's axial period is estimated as that of a bar fixed +at both ends, :math:`T_\text{ax} = 2\sum_j l_j/\sqrt{EA_j/m_j}` over its sections (unstretched +lengths, dry mass per length, and the dynamic ``EA`` of a viscoelastic type with constant dynamic +stiffness). A line that ``dtM`` resolves in fewer than six steps gets an initialisation note +with the period, the number of steps and a ``dtM`` that resolves it, on the standalone, mixed and +OpenFAST routes. The threshold comes from the IEA-15 MW mixed example in a turbulent 600 s +OpenFAST run: its power cable (:math:`T_\text{ax} = 0.351` s) matches the ``dtM = DT`` +fairlead tension within 0.3 % of its standard deviation at 7 steps (``dtM = 0.05 s``), while at +3.5 steps (0.1 s) its axial dynamics are damped and its peak tension is about 35 % high; the chains +(0.78 s) keep their tension statistics within 0.3 % at 7.8 steps. The estimate leaves out added +mass and the catenary compliance, and types whose stiffness depends on the load (``ElasticMod`` +3, Syrope) are not assessed. The note is a lower bound, not a convergence study: segment-scale +snap waves need a far smaller step (above), and the platform side of a larger ``dtM`` is +discussed in :doc:`openfast`. + 8. Request auditable outputs ----------------------------- diff --git a/doc/openfast.rst b/doc/openfast.rst index 69e0ee9..e21b582 100644 --- a/doc/openfast.rst +++ b/doc/openfast.rst @@ -77,8 +77,10 @@ Time step ``DT`` is the OpenFAST glue step; ``dtM`` is CableDyn's committed step. With no deck ``dtM``, CableDyn solves every glue step (``dtM = DT``). A deck ``dtM`` that is a whole multiple of ``DT`` -solves once per that many glue steps and holds its loads (zero-order hold) between commits, which -is faster but delays the loads (see the note below). The console reports the result, for +solves once per that many glue steps, which is faster. Between commits the coupled loads are +extrapolated from the last two commits (deck ``OPTIONS predictLoads``, the default) or, with +``predictLoads False``, held at the last commit, which delays them (see the note below). The +console reports the result, for example ``CableDyn time step dtM = 2.50000E-02 s (1 x glue DT; deck dtM)``. A deck ``dtM`` larger than ``DT`` but not a whole multiple of it is never coarsened: CableDyn uses the largest multiple of ``DT`` that does not exceed it and reports a warning with both values (``dtM = 0.025`` @@ -92,15 +94,31 @@ peaks, and damage. If CableDyn needs a smaller step than the glue step, reduce ` .. note:: - With ``dtM`` larger than ``DT``, CableDyn holds its loads between commits, so the platform - receives the mooring load on average half a ``dtM`` late. A spring force delayed this way - acts on the platform as a negative damping of about the mooring stiffness times ``dtM/2``. For a catenary mooring this - is small: in the three-seed DLC-1.1 comparison of :doc:`validation` the platform motions with - ``dtM`` above ``DT`` agree with MoorDyn within 0.6 % in mean and standard deviation, but the - fairlead tension's standard deviation differs by about 3 % from CableDyn's own solution at - every glue step. For stiff taut, semi-taut or tension-leg moorings the lag can be comparable - to the system's own damping. Check such systems, and any fatigue or tension-range result, at - a smaller ``dtM`` (down to ``dtM = DT``) before relying on them. + With ``dtM`` larger than ``DT``, the loads CableDyn returns at a glue step a fraction + :math:`\tau = (t - t_c)/\mathrm{dtM}` past its last commit :math:`t_c` are extrapolated from + that commit and the one before, :math:`F = F_c + \tau\,(F_c - F_p)`. Their error is of + second order in ``dtM`` (:math:`\tau(1+\tau)\,\mathrm{dtM}^2 \ddot F/2`). Held instead + (``predictLoads False``), the platform receives the mooring load on average half a ``dtM`` + late, and a spring force delayed this way acts on the platform as a negative damping of about + the mooring stiffness times ``dtM/2``. + + On the IEA-15 MW VolturnUS-S in a turbulent 600 s DLC-1.6 environment at ``dtM = 0.1 s`` + (four glue steps of 0.025 s), the platform surge, sway, roll and pitch time series differ from + the ``dtM = DT`` run by at most 0.12 % of their standard deviation with the extrapolation, on + the chain mooring as on the chain and power-cable mooring, where the held loads give up to + 0.42 %. The chain-only run took a fifth less time (116 s against 141 s for ``dtM = DT`` + on the same machine). Over a one-hour run in the same environment the fairlead and + anchor tension statistics stay within 0.16 % and their fatigue damage-equivalent loads + within 1.5 % (see :doc:`validation`). In the three-seed DLC-1.1 comparison + of :doc:`validation`, made with held loads, the platform motions with ``dtM`` above ``DT`` + agree with MoorDyn within 0.6 % in mean and standard deviation, but the fairlead tension's + standard deviation differs by about 3 % from CableDyn's own solution at every glue step. + + The extrapolation does not change how the lines themselves are integrated: each line still + steps on ``dtM``, so its own resolution (see the ``dtM`` advisory in :doc:`modeling`) decides + its tension accuracy, and the output channels and the ``.CD.out`` record the committed + samples. For stiff taut, semi-taut or tension-leg moorings, and for any fatigue or + tension-range result, check a smaller ``dtM`` (down to ``dtM = DT``) before relying on it. Fluid source ~~~~~~~~~~~~ @@ -187,7 +205,7 @@ Run and confirm Before trusting a run, confirm in the console: -#. ``Running CableDyn (v0.2.0, ...)`` and ``Parsing CableDyn input file: ``; +#. ``Running CableDyn (v0.3.0, ...)`` 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 @@ -327,7 +345,9 @@ The same module takes part in OpenFAST's checkpoint, linearisation, farm and con without an auxiliary process: * **Checkpoint/restart** serialises the committed line, constitutive, connection, body, and rod - state needed to continue the trajectory. A checkpoint is accepted only on a committed CableDyn + 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 temporary deck copy, ``.CD.rst.dat``, beside the ``MooringFile``, rebuilds the model diff --git a/doc/options.rst b/doc/options.rst index cfa2934..3d6090d 100644 --- a/doc/options.rst +++ b/doc/options.rst @@ -109,7 +109,10 @@ Environment and seabed ``dtM``/``TMax`` in the standalone driver. Line nodes carry stick-slip friction springs; in a current the static solve holds the line with friction springs from its still-water laid shape, and the march continues them (``staticsFriction asLaid`` holds it toward its - as-laid position instead, in still water too). The isotropic shorthand of + as-laid position instead, in still water too). Friction acts at the nodes, so a coarse + grounded mesh steps the tension of the laid part; the initialisation report notes a + finite-EI line whose grounded elements are too long (*Mesh resolution* in :doc:`theory`). + The isotropic shorthand of ``frictionMuAxial``/``frictionMuLateral``. Not supported (the deck is rejected) with a ``FAILURE`` section, on a deck with ``Connect``/``Free`` points or ``Point3`` buoys, or on a deck whose lines attach to ``Rigid6`` bodies or rods unless it runs on the default @@ -346,7 +349,12 @@ rejected as unknown. The dynamic Newton controls are set with ``dynamic_solver`` - logical - ``False`` - ``True`` enables guarded within-step tangent reuse on every dynamic solver the deck builds. - It does not relax convergence tolerances and should be qualified against full Newton + It does not relax convergence tolerances and should be qualified against full Newton. + Measured against full Newton: 8-12 % faster on free-hanging finite-EI cables (lazy wave, + buoyed cable), about 1.5 times slower on finite-EI lines on the seabed (contact fails the + reuse contraction test and rebuilds the tangent), 3 % slower to 5 % faster on ``EI = 0`` + systems; + results move at the solver tolerance (Bergdahl mean cycle maxima within 0.16 %) - both - ``False modified_newton`` * - ``recovery_max_substeps`` / ``recoverymaxsubsteps`` @@ -527,6 +535,15 @@ Finite-EI cable controls controlled one-way comparison, not a physical operating configuration - OpenFAST (coupled finite-EI cables) - ``True cable_load_feedback`` + * - ``predictLoads`` / ``predict_loads`` + - logical + - ``True`` + - with ``dtM`` larger than the glue ``DT``: ``True`` extrapolates the coupled loads between + CableDyn solves from the last two, ``False`` holds the last solve's loads (about ``dtM/2`` + late). No effect at ``dtM = DT``; the output channels hold the committed samples either way + (see :doc:`openfast`) + - OpenFAST + - ``False predictLoads`` Ambient fluid and prescribed motion ----------------------------------- diff --git a/doc/quickstart.rst b/doc/quickstart.rst index ffb15da..2f95083 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.2.0 banner, exit code 0 + .\CableDyn_driver.exe --version # prints the v0.3.0 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.2.0 + CableDyn v0.3.0 Geometrically nonlinear cable & mooring dynamics for floating wind (lazy-wave power cables and taut / semi-taut / catenary moorings) ------------------------------------------------------------------- diff --git a/doc/theory.rst b/doc/theory.rst index b696d11..312d462 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -456,6 +456,24 @@ element as using the final element length after any mesh refinement. The damping share is included in the reported tension. +**The damping ratio form depends on the mesh.** ``BA`` resolved this way is proportional to the +element length, so it is a property of the discretisation, not of the material. For a uniform +line of :math:`N` elements the axial mode :math:`n` (wavenumber :math:`n\pi/L`) has the damping +ratio + +.. math:: + + \zeta_n = \frac{BA\,\omega_n}{2\,EA} = \zeta\,\frac{n\pi}{2N} , + +so the element-scale modes (:math:`n \approx N`) are damped at up to :math:`(\pi/2)\,\zeta`, as intended +(the convention suppresses segment-scale axial noise, the same one as MoorDyn's negative +``BA``), while the line's global axial modes are damped much less, and less again on a finer +mesh: ``BA = -1`` on 100 elements gives the first axial mode 1.6 %, and 0.8 % on 200 elements. +Results that depend on the axial damping (tension peaks of snatch loads, the decay of axial +oscillations) therefore change with the mesh. For mesh-independent damping give ``BA`` in N·s: +a material damping ratio :math:`\zeta_1` of the first axial mode of a line of length :math:`L` is +:math:`BA = 2\,\zeta_1\,EA/\omega_1` with :math:`\omega_1 = (\pi/L)\sqrt{EA/\rho_A}`. + Viscoelastic rope (MoorDyn ``ElasticMod`` 2 and 3) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -884,6 +902,16 @@ failed or subdivided step, join the aggregate snapshot, and are part of the Open checkpoint. The force and its position and velocity Jacobians (through :math:`N`) are exact. Unlike a velocity-regularised Coulomb law, the spring holds its force at rest. +**Mesh resolution.** Because friction acts at the nodes, the tension along the laid part changes +in steps of up to :math:`\mu w L` (:math:`w` the submerged weight per length, :math:`L` the +element length) instead of continuously, and as the line lands and lifts at touchdown each node +locks or releases that much. On a coarse grounded mesh the tension of the laid part and near +touchdown then swings in time by about that amount. On a 600 m lazy-wave cable in 200 m of +water (:math:`\mu = 0.35`) the standard deviation of a grounded segment tension was 491, 136, 42 +and 33 N with 10, 5, 2.5 and 1 m elements, where :math:`\mu w L` is 46, 22, 11 and 4.5 % of the +touchdown tension. When a finite-EI line's grounded elements exceed 15 %, the initialisation +report notes it with the grounded element length that resolves it. + .. _static-friction: **Friction in statics.** With seabed friction a static equilibrium is not unique: a grounded diff --git a/doc/tutorial_openfast.rst b/doc/tutorial_openfast.rst index 7614aac..5345b99 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.2.0, 2026-10-20). + Running CableDyn (v0.3.0, 2026-10-23). CableDyn: geometrically nonlinear cable & mooring dynamics for floating wind. ... CableDyn time step dtM = 2.50000E-02 s (1 x glue DT; deck dtM) @@ -360,8 +360,8 @@ Exercises 1. **Wind.** Set ``CompInflow = 1`` and ``CompAero = 2`` in a copy of the ``.fst`` (the ``MD_Shared`` InflowWind and AeroDyn files are already present) and compare the mean surge and line-1 tension with the parked case. -2. **Supercycling.** Remove the ``dtM`` row from ``CableDyn_UMaine.dat``. The console now - reports ``dtM = 0.1 s (4 x glue DT; the default)``, CableDyn solves once per four glue steps, +2. **Supercycling.** Set ``dtM`` to 0.1 in ``CableDyn_UMaine.dat``. The console now + reports ``dtM = 0.1 s (4 x glue DT; deck dtM)``, CableDyn solves once per four glue steps, and ``.CD.out`` has one row per 0.1 s. Here the line-1 tension statistics over 10–70 s barely move (mean 2.456 MN, std 0.116 MN, against 2.456 and 0.115 MN); verify that for your own quantities before adopting a coarser step. diff --git a/examples/openfast/README.md b/examples/openfast/README.md index 61103ba..3877ae6 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.2.0, 2026-10-20)` and +The CableDyn run log identifies the module with `Running CableDyn (v0.3.0, 2026-10-23)` and prints the converged fairlead state of each line, as the standalone driver does: ```text diff --git a/integration/openfast/README.md b/integration/openfast/README.md index 018f210..17373be 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.2.0). They add only the OpenFAST host integration; the CableDyn sources come from +CableDyn v0.3.0). They add only the OpenFAST host integration; the CableDyn sources come from this repository. | File | Purpose | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..af814b5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,101 @@ +# File: pyproject.toml +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +# +# The published distribution: an sdist of the Fortran sources and platform wheels that carry the +# compiled CableDyn library and the standalone driver inside the `cabledyn` package. The [project] +# table mirrors python/pyproject.toml (the pure-Python package for development against a CMake +# build tree); tests/test_packaging_metadata.py keeps the two identical. +[build-system] +requires = ["scikit-build-core>=0.10"] +build-backend = "scikit_build_core.build" + +[project] +name = "cabledyn" +version = "0.3.0" +description = "Python APIs for the CableDyn standalone driver and native coupling library" +readme = "python/README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +license-files = ["LICENSE", "NOTICE"] +authors = [{ name = "Jae Hoon Seo", email = "jaehoon.seo@inha.ac.kr" }] +keywords = [ + "cable dynamics", + "dynamic power cable", + "finite element", + "floating offshore wind", + "lazy wave", + "mooring dynamics", + "MoorDyn", + "offshore engineering", + "OpenFAST", +] +dependencies = ["numpy>=1.24"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Programming Language :: Fortran", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Physics", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://github.com/SMI-Lab-Inha/CableDyn" +Documentation = "https://cabledyn.readthedocs.io" +Repository = "https://github.com/SMI-Lab-Inha/CableDyn" +Issues = "https://github.com/SMI-Lab-Inha/CableDyn/issues" +Changelog = "https://github.com/SMI-Lab-Inha/CableDyn/blob/main/CHANGELOG.md" +Releases = "https://github.com/SMI-Lab-Inha/CableDyn/releases" +Citation = "https://doi.org/10.1016/j.oceaneng.2026.128332" + +[project.scripts] +cabledyn-run = "cabledyn.cli:main" +cabledyn-post = "cabledyn.post_cli:main" +cabledyn-deck = "cabledyn.deck_cli:main" +cabledyn-study = "cabledyn.study_cli:main" + +[project.optional-dependencies] +dataframe = ["pandas>=2.0"] +plot = ["matplotlib>=3.7"] +post = ["pandas>=2.0", "matplotlib>=3.7"] +dev = [ + "pytest>=7", "pytest-cov>=5", "pandas>=2.0", "matplotlib>=3.7", "ruff>=0.15,<0.16", "mypy>=1.10", +] + +[tool.scikit-build] +minimum-version = "build-system.requires" +cmake.version = ">=3.20" +cmake.build-type = "Release" +# Only the products the wheel carries: the C-ABI library and the standalone driver (the test +# programs are configured but not built), installed into the package by the `python` component. +build.targets = ["cabledyn_shared", "cabledyn"] +install.components = ["python"] +wheel.packages = ["python/cabledyn"] +# The package reaches the library through ctypes, with no Python extension module: one wheel per +# platform serves every supported Python version. +wheel.py-api = "py3" +# The source distribution: every file git does not ignore except the validation records, the +# manual, the OpenFAST integration and the release tooling; it keeps the sources, the tests the +# CMake configuration registers, the examples they run and the Python package (gitignore-style +# patterns, anchored at the project root). +sdist.exclude = [ + "/validation/", "/doc/", "/integration/", "/release/", "/.github/", "/build*/", + "**/__pycache__/", "*.pyc", +] + +[tool.scikit-build.cmake.define] +# Serial like the release executables: no OpenMP runtime to bundle, and the same results on +# every machine. +CABLEDYN_ENABLE_OPENMP = false diff --git a/python/README.md b/python/README.md index e28f177..3941ae2 100644 --- a/python/README.md +++ b/python/README.md @@ -10,20 +10,26 @@ The full reference is the ## Install -The wheel and source distribution are attached to each -[GitHub release](https://github.com/SMI-Lab-Inha/CableDyn/releases/latest); the -package is not on PyPI or conda-forge. It requires Python 3.10 or newer and NumPy. +From PyPI (Python 3.10 or newer; NumPy is the only required dependency): ```powershell -py -m pip install .\cabledyn-0.2.0-py3-none-any.whl -py -m pip install ".\cabledyn-0.2.0-py3-none-any.whl[post]" # adds pandas and matplotlib +py -m pip install cabledyn +py -m pip install "cabledyn[post]" # adds pandas and matplotlib ``` -The package does not contain the solver. `CableDynDriver` runs -`CableDyn_driver.exe`, found from an explicit path, then the `CABLEDYN_DRIVER` -environment variable, then `PATH`. The in-process `CableDyn` class needs a -CableDyn shared library built from the same source tag, located through -`CABLEDYN_LIBRARY` or a source checkout's build tree. +The wheels for Windows x64, Linux x86-64 (both for processors with AVX2) and macOS +on Apple silicon carry the solver: the CableDyn shared library for the in-process +`CableDyn` class and the standalone driver that `CableDynDriver` and `cabledyn-run` +run. On another platform pip builds from the source distribution, which needs a +Fortran compiler and LAPACK. + +The pure Python wheel attached to each +[GitHub release](https://github.com/SMI-Lab-Inha/CableDyn/releases/latest) +(`cabledyn-0.3.0-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 +a source checkout's build tree. ## Components diff --git a/python/cabledyn/__init__.py b/python/cabledyn/__init__.py index fbbc8b6..7916bbc 100644 --- a/python/cabledyn/__init__.py +++ b/python/cabledyn/__init__.py @@ -282,7 +282,7 @@ "upcrossing_maxima", ] -__version__ = "0.2.0" +__version__ = "0.3.0" def __getattr__(name: str) -> Any: diff --git a/python/cabledyn/driver.py b/python/cabledyn/driver.py index 9c0666c..d1e7f6b 100644 --- a/python/cabledyn/driver.py +++ b/python/cabledyn/driver.py @@ -322,6 +322,16 @@ def read_line_tensions(self, line_id: int) -> OutputTable: return table +def bundled_driver(package_directory: Path | None = None) -> Path: + """The driver a wheel carries in the package's ``bin`` folder: ``CableDyn_driver`` (the name + the MSVC-style toolchains give it) or ``cabledyn`` (GNU), each with ``.exe`` on Windows. Returns + the first that exists, else the ``CableDyn_driver`` path (to report).""" + package = Path(__file__).resolve().parent if package_directory is None else package_directory + suffix = ".exe" if os.name == "nt" else "" + candidates = [package / "bin" / f"{stem}{suffix}" for stem in ("CableDyn_driver", "cabledyn")] + return next((c for c in candidates if c.is_file()), candidates[0]) + + def _resolve_executable(executable: str | os.PathLike[str] | None) -> Path: direct_candidates: list[str | os.PathLike[str]] = [] if executable is not None: @@ -338,12 +348,21 @@ def _resolve_executable(executable: str | os.PathLike[str] | None) -> Path: if resolved.is_file() and (os.name == "nt" or os.access(resolved, os.X_OK)): return resolved if executable is None: + # the driver a wheel carries in the package (matching its bundled library) before PATH + bundled = bundled_driver() + tried.append(str(bundled)) + if bundled.is_file() and (os.name == "nt" or os.access(bundled, os.X_OK)): + return bundled for name in ("CableDyn_driver.exe", "CableDyn_driver", "cabledyn"): found = shutil.which(name) tried.append(name) if found: return Path(found).resolve() - source = "the explicit path" if executable is not None else "CABLEDYN_DRIVER or PATH" + source = ( + "the explicit path" + if executable is not None + else "CABLEDYN_DRIVER, the bundled driver or PATH" + ) raise DriverNotFoundError( f"could not locate CableDyn_driver using {source}. Tried: {', '.join(tried)}" ) diff --git a/python/pyproject.toml b/python/pyproject.toml index ffdfe1f..9d9a0a8 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "cabledyn" -version = "0.2.0" +version = "0.3.0" description = "Python APIs for the CableDyn standalone driver and native coupling library" readme = "README.md" requires-python = ">=3.10" diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py index 8f868f1..d3ee91c 100644 --- a/python/tests/test_cli.py +++ b/python/tests/test_cli.py @@ -515,9 +515,46 @@ def test_driver_discovery_reports_every_candidate(monkeypatch): monkeypatch.delenv("CABLEDYN_DRIVER", raising=False) monkeypatch.setattr("shutil.which", lambda name: None) - with pytest.raises(DriverNotFoundError, match="CABLEDYN_DRIVER or PATH") as caught: + with pytest.raises( + DriverNotFoundError, match="CABLEDYN_DRIVER, the bundled driver or PATH" + ) as caught: CableDynDriver() assert "CableDyn_driver.exe" in str(caught.value) and "cabledyn" in str(caught.value) + assert str(Path("bin") / "CableDyn_driver") in str(caught.value) + + +def _fake_driver(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + path.chmod(0o755) + return path + + +def test_bundled_driver_comes_before_path(monkeypatch, tmp_path): + # a wheel's own driver (matching its bundled library) wins over one on PATH + from cabledyn import driver as driver_module + + bundled = _fake_driver(tmp_path / "pkg" / "bin" / "CableDyn_driver.exe") + on_path = _fake_driver(tmp_path / "path" / "CableDyn_driver.exe") + monkeypatch.delenv("CABLEDYN_DRIVER", raising=False) + monkeypatch.setattr(driver_module, "bundled_driver", lambda: bundled) + monkeypatch.setattr("shutil.which", lambda name: str(on_path)) + assert driver_module._resolve_executable(None) == bundled + # without a bundled driver, PATH is searched as before + monkeypatch.setattr( + driver_module, "bundled_driver", lambda: tmp_path / "missing" / "CableDyn_driver.exe" + ) + assert driver_module._resolve_executable(None) == on_path.resolve() + + +def test_cabledyn_driver_variable_comes_before_the_bundled_driver(monkeypatch, tmp_path): + from cabledyn import driver as driver_module + + bundled = _fake_driver(tmp_path / "pkg" / "bin" / "CableDyn_driver.exe") + chosen = _fake_driver(tmp_path / "chosen" / "CableDyn_driver.exe") + monkeypatch.setenv("CABLEDYN_DRIVER", str(chosen)) + monkeypatch.setattr(driver_module, "bundled_driver", lambda: bundled) + assert driver_module._resolve_executable(None) == chosen.resolve() def test_posix_environment_merge_keeps_case_distinct_names(monkeypatch): @@ -547,3 +584,15 @@ def slow(command, **kwargs): CableDynDriver(fake_driver).version(timeout=1.0) assert caught.value.stdout == "partial �" assert caught.value.stderr == "" + + +def test_bundled_driver_accepts_both_toolchain_names(tmp_path): + from cabledyn import driver as driver_module + + suffix = ".exe" if os.name == "nt" else "" + package = tmp_path / "cabledyn" + assert driver_module.bundled_driver(package) == package / "bin" / f"CableDyn_driver{suffix}" + gnu = _fake_driver(package / "bin" / f"cabledyn{suffix}") + assert driver_module.bundled_driver(package) == gnu + msvc = _fake_driver(package / "bin" / f"CableDyn_driver{suffix}") + assert driver_module.bundled_driver(package) == msvc diff --git a/release/audit_release_tree.py b/release/audit_release_tree.py index 8a00d3a..3c6285c 100644 --- a/release/audit_release_tree.py +++ b/release/audit_release_tree.py @@ -49,6 +49,7 @@ "SECURITY.md", "VALIDATION.md", "doc/index.rst", + "pyproject.toml", "python/pyproject.toml", "src/CableDyn_CAPI.f90", "validation/README.md", diff --git a/release/build_static_windows.ps1 b/release/build_static_windows.ps1 index 28ee74e..8a72f4a 100644 --- a/release/build_static_windows.ps1 +++ b/release/build_static_windows.ps1 @@ -123,6 +123,7 @@ $common = '-G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_Fortran_COMPILER=ifx ' + # so both executables link the netlib reference LAPACK/BLAS, compiled here with the # release IFX from a pinned, checksummed source archive. /fp:precise keeps IEEE # semantics inside LAPACK; the upstream CMake applies a strict model only on Unix. +# /arch:CORE-AVX2 is the x86-64-v3 target of the CableDyn core (CABLEDYN_TARGET_AVX2). $lapackVersion = '3.12.1' $lapackSha256 = '2ca6407a001a474d4d4d35f3a61550156050c48016d949f0da0529c0aa052422' $lapackUrl = "https://github.com/Reference-LAPACK/lapack/archive/refs/tags/v$lapackVersion.tar.gz" @@ -160,7 +161,7 @@ Invoke-StaticToolchain ("cmake -S `"$lapackSource`" -B `"$lapackBuild`" $common '-DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF -DBUILD_SINGLE=OFF -DBUILD_DOUBLE=ON ' + '-DBUILD_COMPLEX=OFF -DBUILD_COMPLEX16=OFF -DBUILD_INDEX64_EXT_API=OFF ' + '-DUSE_OPTIMIZED_BLAS=OFF -DUSE_OPTIMIZED_LAPACK=OFF ' + - '"-DCMAKE_Fortran_FLAGS=/nologo /libs:static /fp:precise" ' + + '"-DCMAKE_Fortran_FLAGS=/nologo /libs:static /fp:precise /arch:CORE-AVX2" ' + "`"-DCMAKE_INSTALL_PREFIX=$lapackInstall`"") Invoke-StaticToolchain "cmake --build `"$lapackBuild`" --target blas lapack -j $Jobs" Invoke-StaticToolchain "cmake --install `"$lapackBuild`"" @@ -220,8 +221,28 @@ Copy-Item -LiteralPath $vsOpenFAST -Destination $openfast -Force $savedPath = $env:PATH $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot" try { - & $driver --version - if ($LASTEXITCODE -ne 0) { throw 'CableDyn_driver.exe --version failed in isolated PATH' } + $versionRun = Invoke-NativeCapture $driver @('--version') + Write-Host $versionRun.Text + if ($versionRun.ExitCode -ne 0) { throw 'CableDyn_driver.exe --version failed in isolated PATH' } + # the release targets x86-64-v3 and carries the start-up processor check + if ($versionRun.Text -notmatch 'Build\s+x86-64-v3') { + throw "CableDyn_driver.exe is not the x86-64-v3 build with the processor check`n$($versionRun.Text)" + } + # On a processor without x86-64-v3 both executables stop at start with the message + # (simulated here): exit 3 before any CableDyn or OpenFAST code prints. + $env:CABLEDYN_CPU_GUARD_SIMULATE = 'SIMULATED-FEATURE' + try { + foreach ($stopCase in @(@($driver, '--version'), @($openfast, '-h'))) { + $stopRun = Invoke-NativeCapture $stopCase[0] @($stopCase[1]) + if ($stopRun.ExitCode -ne 3 -or $stopRun.Text -notmatch 'This computer lacks: SIMULATED-FEATURE' -or + $stopRun.Text -match 'CableDyn v|OpenFAST') { + throw "$($stopCase[0]) did not stop at start on a processor without x86-64-v3 " + + "(exit $($stopRun.ExitCode))`n$($stopRun.Text)" + } + } + } finally { + Remove-Item Env:CABLEDYN_CPU_GUARD_SIMULATE -ErrorAction SilentlyContinue + } $driverRoot = Join-Path $cdBuild 'driver_static_smoke' & $driver (Join-Path $CableDynRoot 'examples\chain_catenary_shallow_30m.dat') $driverRoot if ($LASTEXITCODE -ne 0) { throw 'CableDyn_driver.exe static smoke case failed' } @@ -399,6 +420,9 @@ try { if ($runLog -notmatch 'Running CableDyn \(' -or $runLog -notmatch 'OpenFAST terminated normally') { throw "openfast.exe did not prove CableDyn identity and normal termination`n$runLog" } + if ($runLog -notmatch 'CableDyn build: x86-64-v3') { + throw "openfast.exe does not carry the x86-64-v3 CableDyn core with the processor check`n$runLog" + } if ($runLog -notmatch 'Created CableDyn model:' -or $runLog -notmatch 'Line 1 fairlead effective tension:' -or $runLog -notmatch ('Requested CableDyn OUTPUTS at t = 0 s \(equilibrium pose, SeaState ' + diff --git a/release/build_windows_wheel.ps1 b/release/build_windows_wheel.ps1 new file mode 100644 index 0000000..47731b4 --- /dev/null +++ b/release/build_windows_wheel.ps1 @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +# Build the Windows x64 cabledyn wheel with the release toolchain: Intel IFX and the MSVC linker +# with the static runtime, and the pinned netlib reference LAPACK built here with IFX (the same +# pins as build_static_windows.ps1, kept equal by tests/test_packaging_metadata.py). The library +# and the driver in the wheel therefore need no runtime DLLs beyond Windows itself, which the +# script checks with dumpbin before it writes the wheel to OutputDir. +[CmdletBinding()] +param( + [string]$CableDynRoot = '', + [string]$OutputDir = '', + [string]$Python = 'python', + [string]$WindowsSdkVersion = '10.0.22621.0', + [int]$Jobs = 8, + [version]$MinimumIfxVersion = '2025.3', + [string]$OneApiSetvars = 'C:\Program Files (x86)\Intel\oneAPI\setvars.bat', + [string]$VcVarsAll = '', + # Local copy of the pinned reference LAPACK source archive; downloaded when empty. + [string]$ReferenceLapackArchive = '' +) +$ErrorActionPreference = 'Stop' +if (-not $CableDynRoot) { $CableDynRoot = Split-Path $PSScriptRoot -Parent } +$CableDynRoot = (Resolve-Path -LiteralPath $CableDynRoot).Path +if (-not $OutputDir) { $OutputDir = Join-Path $CableDynRoot 'build-wheel\dist' } +$OutputDir = [IO.Path]::GetFullPath($OutputDir) +$Python = (Get-Command $Python).Source +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +if (-not (Test-Path -LiteralPath $vswhere)) { throw "Visual Studio locator not found: $vswhere" } +$vsRoot = (& $vswhere -latest -products * -requires 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64' ` + -property installationPath | Select-Object -First 1) +if (-not $vsRoot) { throw 'Visual Studio with the x64 C++ toolchain was not found' } +if (-not $VcVarsAll) { $VcVarsAll = Join-Path $vsRoot 'VC\Auxiliary\Build\vcvarsall.bat' } +foreach ($required in @($OneApiSetvars, $VcVarsAll)) { + if (-not (Test-Path -LiteralPath $required)) { throw "required toolchain script not found: $required" } +} + +function Invoke-Toolchain([string]$Command) { + # Run one command in a cmd.exe with the IFX and MSVC environments loaded. + $bat = Join-Path $env:TEMP ("cabledyn-wheel-{0}.bat" -f [guid]::NewGuid().ToString('N')) + try { + @( + '@echo off' + "call `"$OneApiSetvars`" --force >nul" + 'if errorlevel 1 exit /b %errorlevel%' + "call `"$VcVarsAll`" x64 $WindowsSdkVersion >nul" + 'if errorlevel 1 exit /b %errorlevel%' + $Command + ) | Set-Content -LiteralPath $bat -Encoding ascii + & cmd.exe /d /c $bat + if ($LASTEXITCODE -ne 0) { throw "toolchain command failed ($LASTEXITCODE): $Command" } + } finally { + Remove-Item -LiteralPath $bat -Force -ErrorAction SilentlyContinue + } +} + +# The release compiler gate: the IFX generation of the official executables. +$probe = Join-Path $env:TEMP ("cabledyn-ifx-{0}.txt" -f [guid]::NewGuid().ToString('N')) +try { + Invoke-Toolchain "ifx /QV > `"$probe`" 2>&1" + $ifxText = Get-Content -LiteralPath $probe -Raw +} finally { + Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue +} +if ($ifxText -notmatch 'Version\s+(\d+\.\d+(?:\.\d+)?)') { throw "could not determine the IFX version from:`n$ifxText" } +if ([version]$Matches[1] -lt $MinimumIfxVersion) { + throw "IFX $($Matches[1]) is below the release minimum $MinimumIfxVersion" +} +Write-Host "PASS compiler gate: IFX $($Matches[1])" + +$common = '-G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_Fortran_COMPILER=ifx ' + + '-DCMAKE_C_COMPILER=cl -DCMAKE_LINKER=link -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded' + +# The netlib reference LAPACK/BLAS, static, built with IFX (pins shared with +# build_static_windows.ps1). +$lapackVersion = '3.12.1' +$lapackSha256 = '2ca6407a001a474d4d4d35f3a61550156050c48016d949f0da0529c0aa052422' +$lapackUrl = "https://github.com/Reference-LAPACK/lapack/archive/refs/tags/v$lapackVersion.tar.gz" +$lapackRoot = Join-Path $CableDynRoot 'build-wheel\reference-lapack' +New-Item -ItemType Directory -Path $lapackRoot -Force | Out-Null +if (-not $ReferenceLapackArchive) { + $ReferenceLapackArchive = Join-Path $lapackRoot "lapack-$lapackVersion.tar.gz" + if (-not (Test-Path -LiteralPath $ReferenceLapackArchive)) { + Invoke-WebRequest -UseBasicParsing -Uri $lapackUrl -OutFile $ReferenceLapackArchive + } +} +$archiveHash = (Get-FileHash -LiteralPath $ReferenceLapackArchive -Algorithm SHA256).Hash.ToLowerInvariant() +if ($archiveHash -ne $lapackSha256) { + throw "reference LAPACK archive $ReferenceLapackArchive has SHA256 $archiveHash; expected $lapackSha256" +} +$lapackSource = Join-Path $lapackRoot "lapack-$lapackVersion" +$lapackBuild = Join-Path $lapackRoot 'build' +$lapackInstall = Join-Path $lapackRoot 'install' +foreach ($stale in @($lapackSource, $lapackBuild, $lapackInstall)) { + if (Test-Path -LiteralPath $stale) { Remove-Item -LiteralPath $stale -Recurse -Force } +} +& "$env:SystemRoot\System32\tar.exe" -xzf $ReferenceLapackArchive -C $lapackRoot +if ($LASTEXITCODE -ne 0) { throw "could not extract $ReferenceLapackArchive" } +Invoke-Toolchain ("cmake -S `"$lapackSource`" -B `"$lapackBuild`" $common " + + '-DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF -DBUILD_SINGLE=OFF -DBUILD_DOUBLE=ON ' + + '-DBUILD_COMPLEX=OFF -DBUILD_COMPLEX16=OFF -DBUILD_INDEX64_EXT_API=OFF ' + + '-DUSE_OPTIMIZED_BLAS=OFF -DUSE_OPTIMIZED_LAPACK=OFF ' + + '"-DCMAKE_Fortran_FLAGS=/nologo /libs:static /fp:precise /arch:CORE-AVX2" ' + + "`"-DCMAKE_INSTALL_PREFIX=$lapackInstall`"") +Invoke-Toolchain "cmake --build `"$lapackBuild`" --target blas lapack -j $Jobs" +Invoke-Toolchain "cmake --install `"$lapackBuild`"" +$lapackLibraries = @('liblapack.lib', 'libblas.lib') | ForEach-Object { + $library = Join-Path $lapackInstall "lib\$_" + if (-not (Test-Path -LiteralPath $library)) { throw "reference LAPACK build did not produce $library" } + $library -replace '\\', '/' +} +Write-Host "PASS reference LAPACK $lapackVersion built with IFX" + +# The wheel: scikit-build-core with the release configuration of CableDyn. +New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +$settings = @( + 'cmake.args=-GNinja', + 'cmake.define.CMAKE_Fortran_COMPILER=ifx', + 'cmake.define.CMAKE_C_COMPILER=cl', + 'cmake.define.CMAKE_LINKER=link', + 'cmake.define.CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded', + 'cmake.define.CABLEDYN_STATIC_WINDOWS_RELEASE=ON', + "cmake.define.CABLEDYN_STATIC_LAPACK_LIBRARIES=$($lapackLibraries -join ';')" +) | ForEach-Object { "`"--config-settings=$_`"" } +Invoke-Toolchain ("`"$Python`" -m pip wheel `"$CableDynRoot`" --no-deps -w `"$OutputDir`" " + ($settings -join ' ')) +$wheel = Get-ChildItem -LiteralPath $OutputDir -Filter 'cabledyn-*-win_amd64.whl' | + Sort-Object LastWriteTime | Select-Object -Last 1 +if (-not $wheel) { throw "no win_amd64 wheel in $OutputDir" } + +# Only Windows system DLLs may be imported by the library and the driver. +$inspect = Join-Path $env:TEMP ("cabledyn-wheel-{0}" -f [guid]::NewGuid().ToString('N')) +try { + # A wheel is a ZIP archive, but Windows PowerShell 5.1's Expand-Archive refuses any + # extension other than .zip; the .NET extractor reads it by content. + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::ExtractToDirectory($wheel.FullName, $inspect) + # Filter on the extension explicitly: with -LiteralPath, Windows PowerShell 5.1 ignores + # -Include and would list every file of the package. + $binaries = @(Get-ChildItem -LiteralPath (Join-Path $inspect 'cabledyn') -Recurse -File | + Where-Object { $_.Extension -in @('.dll', '.exe') }) + $libraries = @($binaries | Where-Object { $_.Extension -eq '.dll' }) + $drivers = @($binaries | Where-Object { $_.Extension -eq '.exe' -and $_.Directory.Name -eq 'bin' }) + if ($libraries.Count -ne 1 -or $drivers.Count -ne 1 -or $binaries.Count -ne 2) { + throw "the wheel carries $($binaries.Count) binaries ($($binaries.Name -join ', ')); " + + 'expected the library beside the package and the driver in cabledyn/bin' + } + foreach ($binary in $binaries) { + $listing = Join-Path $env:TEMP ("cabledyn-deps-{0}.txt" -f [guid]::NewGuid().ToString('N')) + Invoke-Toolchain "dumpbin /nologo /dependents `"$($binary.FullName)`" > `"$listing`"" + $imports = Get-Content -LiteralPath $listing | Where-Object { $_ -match '^\s+\S+\.dll\s*$' } | + ForEach-Object { $_.Trim() } + Remove-Item -LiteralPath $listing -Force + $foreign = $imports | Where-Object { + $_ -notmatch '^(KERNEL32|ADVAPI32|USER32|SHELL32|OLEAUT32|ole32|WS2_32|ntdll|bcrypt|imagehlp|dbghelp|PSAPI|VERSION)\.dll$' -and + $_ -notmatch '^api-ms-win-' + } + if ($foreign) { throw "$($binary.Name) imports non-system DLLs: $($foreign -join ', ')" } + Write-Host "PASS $($binary.Name) imports only system DLLs: $($imports -join ', ')" + } +} finally { + if (Test-Path -LiteralPath $inspect) { Remove-Item -LiteralPath $inspect -Recurse -Force } +} +Write-Host "PASS $($wheel.Name)" diff --git a/release/check_release_metadata.py b/release/check_release_metadata.py index f6b5a95..d1dfcef 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.2.0", "CableDyn v0.2.0", "v0.2.0", -# "cabledyn-0.2.0-py3-none-any.whl", ".../releases/tag/v0.2.0" and "libcabledyn.so.0.2.0". +# Spellings that name a CableDyn release: "CableDyn 0.3.0", "CableDyn v0.3.0", "v0.3.0", +# "cabledyn-0.3.0-py3-none-any.whl", ".../releases/tag/v0.3.0" and "libcabledyn.so.0.3.0". VERSION_REFERENCE = re.compile( r"(?i)(?:cabledyn[ _-]+v?|cabledyn-|releases/tag/v|libcabledyn\.(?:so\.)?|(? int: checks = { "CITATION.cff version": citation_version == version, "Python project version": f'version = "{version}"' in read("python/pyproject.toml"), + "Published distribution version": f'version = "{version}"' in read("pyproject.toml"), "Python runtime version": f'__version__ = "{version}"' in read("python/cabledyn/__init__.py"), "Fortran banner version": f"CableDyn v{version}" in read("src/CableDyn_Banner.f90"), "C ABI version string": f"CableDyn {version} C-ABI {abi_version}" in read( @@ -174,6 +175,9 @@ def main() -> int: "python/pyproject.toml" ), "Python licence file": (ROOT / "python" / "LICENSE").is_file(), + "Published distribution licence metadata": 'license-files = ["LICENSE", "NOTICE"]' in read( + "pyproject.toml" + ), } failures = [label for label, passed in checks.items() if not passed] failures += [f"stale version reference {where}" for where in stale_version_references(version)] diff --git a/release/smoke_test_wheel.py b/release/smoke_test_wheel.py new file mode 100644 index 0000000..f3a2073 --- /dev/null +++ b/release/smoke_test_wheel.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""Smoke-test an installed cabledyn platform wheel, outside the source tree. + +The installed package must load the CableDyn library it carries (not one from a build tree or +CABLEDYN_LIBRARY), select the driver it carries (not one on PATH), and run a standalone deck to +completion. With --expect-build, the driver's --version must report that build ("x86-64-v3" +for the x86-64 release wheels, "baseline" otherwise). Usage: + + python release/smoke_test_wheel.py [--expect-build x86-64-v3|baseline] +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tempfile +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("deck", type=Path) + parser.add_argument("--expect-build", choices=["x86-64-v3", "baseline"]) + args = parser.parse_args() + deck = args.deck.resolve() + for name in ("CABLEDYN_LIBRARY", "CABLEDYN_DRIVER"): + os.environ.pop(name, None) + + import cabledyn + from cabledyn import _discovery, driver + + package = Path(cabledyn.__file__).resolve().parent + library = _discovery.find_library() + if library.parent != package: + print(f"FAIL: the library {library} is not the one in the package {package}", file=sys.stderr) + return 1 + print(f"library : {library}") + print(f"version : {cabledyn.version_string()}") + + executable = driver._resolve_executable(None) + if executable.parent != package / "bin": + print(f"FAIL: the driver {executable} is not the one in {package / 'bin'}", file=sys.stderr) + return 1 + print(f"driver : {executable}") + + if args.expect_build: + banner = subprocess.run([str(executable), "--version"], capture_output=True, text=True, check=False) + build = [line.split(None, 1)[1].strip() for line in banner.stdout.splitlines() if line.split()[:1] == ["Build"]] + if banner.returncode != 0 or not build or not build[0].startswith(args.expect_build): + print( + f"FAIL: the driver reports build {build} (exit {banner.returncode}); expected {args.expect_build}", + file=sys.stderr, + ) + return 1 + print(f"build : {build[0]}") + + with tempfile.TemporaryDirectory() as work: + root = Path(work) / deck.stem + result = driver.CableDynDriver().run(deck, output_root=root) + outputs = sorted(p.name for p in Path(work).iterdir()) + if result is None or not any(name.endswith(".out") for name in outputs): + print(f"FAIL: the driver run wrote no .out file ({outputs})", file=sys.stderr) + return 1 + print(f"deck : {deck.name} ran; outputs {outputs}") + print("PASS: the installed wheel carries a working library and driver") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/CableDyn_Banner.f90 b/src/CableDyn_Banner.f90 index 5585bd0..4c69e86 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.2.0' + s = ' CableDyn v0.3.0' CASE (3) s = ' Geometrically nonlinear cable & mooring dynamics for floating wind' CASE (4) diff --git a/src/CableDyn_CAPI.f90 b/src/CableDyn_CAPI.f90 index c460c91..f44140e 100644 --- a/src/CableDyn_CAPI.f90 +++ b/src/CableDyn_CAPI.f90 @@ -75,10 +75,10 @@ MODULE CableDyn_CAPI INTEGER(C_INT), PARAMETER :: CD_C_SOLVE_FAIL = 4_C_INT 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 = 2_C_INT + INTEGER(C_INT), PARAMETER :: CD_C_VERSION_MINOR = 3_C_INT INTEGER(C_INT), PARAMETER :: CD_C_VERSION_PATCH = 0_C_INT INTEGER(C_INT), PARAMETER :: CD_C_ABI_VERSION = 1_C_INT - CHARACTER(*), PARAMETER :: CD_C_VERSION_STRING = 'CableDyn 0.2.0 C-ABI 1' + CHARACTER(*), PARAMETER :: CD_C_VERSION_STRING = 'CableDyn 0.3.0 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). INTEGER(C_INT), PARAMETER :: CD_C_ABI_MINOR = 2_C_INT diff --git a/src/CableDyn_CAPI.h b/src/CableDyn_CAPI.h index 2a15ea5..d9067dd 100644 --- a/src/CableDyn_CAPI.h +++ b/src/CableDyn_CAPI.h @@ -44,7 +44,7 @@ #define CD_C_NOT_INITIALIZED 5 #define CABLEDYN_CAPI_VERSION_MAJOR 0 -#define CABLEDYN_CAPI_VERSION_MINOR 2 +#define CABLEDYN_CAPI_VERSION_MINOR 3 #define CABLEDYN_CAPI_VERSION_PATCH 0 #define CABLEDYN_CAPI_ABI_VERSION 1 diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index 926b7a5..c43aef6 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -176,6 +176,8 @@ MODULE CableDyn_DeckDriver ! is not positive. REAL(wp), PARAMETER :: COARSE_SECTION_FRACTION = 0.125_wp 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 PUBLIC :: CD_Run_Deck_Driver PUBLIC :: CD_Deck_Equilibrium_Jacobian_Check @@ -632,6 +634,9 @@ MODULE CableDyn_DeckDriver ! channels remain available; FALSE suppresses only its reaction at the host ! moving-point boundary. The default preserves ordinary two-way coupling. LOGICAL :: cable_load_feedback = .TRUE. + ! OpenFAST supercycling (dtM > DT): extrapolate the coupled loads between dtM solves from the + ! last two (CableDyn_LoadPredictor); FALSE holds the last solve's loads + LOGICAL :: predict_loads = .TRUE. ! Opt-in adaptive mesh for COUPLED finite-EI cables: when set, a cable whose static IC is ! diagnosed under-resolved (CD_HermiteCable_Resolution_Metrics fragile) is refined by ! warm-started cubic-Hermite prolongation before the coupled dynamic run. Default FALSE keeps @@ -991,9 +996,9 @@ MODULE CableDyn_DeckDriver LOGICAL :: force_is_total = .TRUE. ! Static-initialisation notes of the line, one per text line (append_note_line): those of a ! 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 of any - ! line; empty when there is nothing to report. It holds a whole cable note (1024) and a - ! strain note. + ! 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 = '' END TYPE CD_LineInitSummary @@ -1016,6 +1021,8 @@ MODULE CableDyn_DeckDriver LOGICAL :: host_wave_enabled = .FALSE. LOGICAL :: host_current_enabled = .FALSE. LOGICAL :: cable_load_feedback = .TRUE. + ! Deck OPTION predictLoads (the OpenFAST shell; see CD_AGG_ModuleType%predict_loads) + LOGICAL :: predict_loads = .TRUE. ! OPTION maxStrain, for the per-step plausibility guard of the coupled EI=0 lines. REAL(wp) :: max_strain = 0.5_wp REAL(wp), ALLOCATABLE :: host_current_profile_z(:) @@ -2228,12 +2235,15 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, IF (pass == 3) THEN IF (.NOT. symmetric_current_retry()) EXIT route_pass END IF - ! A catenary-continuation route that stopped on the frictionless grounded-run - ! diagnosis (the current drives the run on the seabed into compression) is not - ! rescued by the mesh-sequenced route, which reaches the same planar limit after - ! up to a minute; only the symmetry-breaking passes can. + ! A catenary-continuation route that stopped on a grounded-run diagnosis (the current + ! drives the run on the seabed into compression; with friction, the friction holds the + ! grounded run while the current pushes the suspended span into the touchdown) is not + ! rescued by the mesh-sequenced route, which reaches the same limit only after its + ! iteration budget (17 s on a 1000 m lazy wave in 440 m of water); only the + ! symmetry-breaking passes can. IF (pass == 2 .AND. .NOT. opts%cable_statics_sequenced .AND. & - INDEX(primary_msg, 'the run on the seabed') > 0) THEN + (INDEX(primary_msg, 'the run on the seabed') > 0 .OR. & + INDEX(primary_msg, 'the friction holds the grounded run') > 0)) THEN sequence_msg = 'not run: the catenary-continuation route diagnosed the grounded run' CYCLE route_pass END IF @@ -3049,6 +3059,8 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, IF (es /= CD_HCDYN_OK) THEN CALL fail(ErrStat, ErrMsg, 'finite-EI Hermite seabed recompute wiring failed: '//TRIM(em)); RETURN END IF + IF (opts%friction_mu > CD_ZERO .AND. .NOT. PRESENT(bathymetry) .AND. & + (opts%tmax > CD_ZERO .OR. .NOT. standalone_mode)) CALL friction_resolution_note() ! The static friction force of every node becomes the initial force of its ! elastic-plastic spring, so the march starts in the static equilibrium. IF (static_friction) THEN @@ -4363,6 +4375,72 @@ SUBROUTINE report_note(text) IF (PRESENT(init_note)) CALL append_note_line(init_note, text) END SUBROUTINE report_note + SUBROUTINE friction_resolution_note() + !! Seabed friction acts at the nodes: as the line lands and lifts at touchdown, a grounded + !! element locks or releases up to mu w L of friction (mu the axial friction coefficient, + !! w its submerged weight per length, L its length), and the tension of the laid part and + !! near touchdown steps by that much. A note, on a flat seabed, when a grounded element's mu w L + !! exceeds FRICTION_TENSION_FRACTION of the static tension at touchdown, with the element + !! length that keeps it below; nothing is changed. On a 600 m lazy wave in 200 m of water + !! (mu 0.35) the standard deviation of a grounded segment tension was 491, 136, 42 and 33 N + !! with 10, 5, 2.5 and 1 m elements, where mu w L is 46, 22, 11 and 4.5 % of the touchdown + !! tension: converged below about 15 %. + REAL(wp), PARAMETER :: FRICTION_TENSION_FRACTION = 0.15_wp + REAL(wp) :: mu, quantum, q_max, l_max, w_max, t_td, gap_tol, l_res + REAL(wp), ALLOCATABLE :: t_nodes(:) + INTEGER :: k, nnd, ned, i_td, es_rec + LOGICAL, ALLOCATABLE :: grounded(:) + CHARACTER(32) :: b1, b2, b3, b4, b5 + CHARACTER(512) :: text + CHARACTER(1024) :: em_rec + mu = opts%friction_mu + IF (opts%friction_mu_axial > CD_ZERO) mu = opts%friction_mu_axial + nnd = cable%line%nn + ned = cable%line%ne + IF (nnd < 2) RETURN + ALLOCATE (grounded(nnd)) + gap_tol = 0.01_wp*MAXVAL(diam) + DO k = 1, nnd + grounded(k) = cable%line%q(6*(k - 1) + 3) <= contact_z + gap_tol + END DO + q_max = CD_ZERO; l_max = CD_ZERO; w_max = CD_ZERO + DO k = 1, ned + IF (.NOT. (grounded(k) .AND. grounded(k + 1))) CYCLE + quantum = mu*MAX(cable%line%w(k), CD_ZERO)*cable%line%l0(k) + IF (quantum > q_max) THEN + q_max = quantum; l_max = cable%line%l0(k); w_max = cable%line%w(k) + END IF + END DO + IF (.NOT. (q_max > CD_ZERO)) RETURN + ! the touchdown node: a grounded node beside a suspended one (the first from End B, + ! internal node 1), and its tension from nodal equilibrium, as the tension channels report + ! it (the elastic EA (|r'| - 1) of a node is dominated by the discretisation of a stiff EA) + i_td = 0 + DO k = 1, nnd - 1 + IF (grounded(k) .NEQV. grounded(k + 1)) THEN + i_td = MERGE(k, k + 1, grounded(k)); EXIT + END IF + END DO + IF (i_td < 1) RETURN + ALLOCATE (t_nodes(nnd)) + CALL CD_HermiteCable_Dyn_Node_Recovery(cable%line, es_rec, em_rec, tension=t_nodes) + IF (es_rec /= CD_HCDYN_OK) RETURN + t_td = t_nodes(i_td) + IF (.NOT. (t_td > CD_ZERO) .OR. q_max <= FRICTION_TENSION_FRACTION*t_td) RETURN + l_res = FRICTION_TENSION_FRACTION*t_td/(mu*w_max) + WRITE (b1, '(F12.1)') l_max + WRITE (b2, '(I0)') NINT(q_max) + WRITE (b3, '(I0)') NINT(100.0_wp*q_max/t_td) + WRITE (b4, '(I0)') NINT(t_td) + WRITE (b5, '(F12.2)') l_res + text = 'line '//TRIM(int_to_str(ln%id))//': seabed friction acts at the nodes, so a grounded element of '// & + TRIM(ADJUSTL(b1))//' m locks or releases up to '//TRIM(ADJUSTL(b2))//' N (friction coefficient x '// & + 'submerged weight x length) as the line lands and lifts, '//TRIM(ADJUSTL(b3))//' % of the '// & + TRIM(ADJUSTL(b4))//' N tension at touchdown: on this mesh the tensions of the laid part and near '// & + 'touchdown step by that much. Grounded elements of at most '//TRIM(ADJUSTL(b5))//' m resolve it' + CALL report_note(text) + END SUBROUTINE friction_resolution_note + END SUBROUTINE build_one_hermite_cable SUBROUTINE attachment_nodes(l0, arc_public, node, max_offset) @@ -6538,6 +6616,7 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi END IF agg%n_turbines = nturb agg%cable_load_feedback = opts%cable_load_feedback + agg%predict_loads = opts%predict_loads agg%max_strain = opts%max_strain ! The standalone driver retains DeckOptions and evaluates a WaveKinMod-1 Fourier @@ -7086,8 +7165,9 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi DO il = 1, SIZE(agg%init_lines) 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) CYCLE - CALL append_note_line(agg%init_lines(il)%note, strain_note) + IF (LEN_TRIM(strain_note) > 0) CALL append_note_line(agg%init_lines(il)%note, strain_note) + 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) @@ -7633,6 +7713,7 @@ SUBROUTINE CD_End_Deck_Aggregate(agg) agg%host_wave_enabled = .FALSE. agg%host_current_enabled = .FALSE. agg%cable_load_feedback = .TRUE. + agg%predict_loads = .TRUE. agg%max_strain = 0.5_wp IF (ALLOCATED(agg%host_current_profile_z)) DEALLOCATE (agg%host_current_profile_z) IF (ALLOCATED(agg%host_current_profile_velocity)) DEALLOCATE (agg%host_current_profile_velocity) @@ -10113,6 +10194,51 @@ SUBROUTINE percent_text(percent, text) END SUBROUTINE percent_text END SUBROUTINE static_strain_note + SUBROUTINE axial_period_note(line_id, dt, sections, types, note) + !! The dtM advisor: a line whose axial period is resolved by fewer than + !! AXIAL_STEPS_PER_PERIOD steps of dt. The period is that of a bar fixed at both ends, + !! twice the axial-wave transit time sum(l / sqrt(EA / m)) over the line's sections, with + !! the unstretched lengths, the dry mass per length and, for a viscoelastic type of + !! constant dynamic stiffness, its dynamic EA (the stiffness a fast load sees). Added mass + !! is left out, so the estimate errs short. Coarser steps damp the line's axial dynamics + !! (the generalised-alpha rhoInf) and misstate its tension peaks: with the IEA-15 MW mixed + !! example the cable (0.35 s) is converged at 7 steps per period and about 35 % high in peak + !! tension at 3.5. Types whose stiffness depends on the load (ElasticMod 3, Syrope) are not + !! assessed. Only a note: nothing is changed. + INTEGER, INTENT(IN) :: line_id + REAL(wp), INTENT(IN) :: dt + TYPE(DeckSection), INTENT(IN) :: sections(:) + TYPE(DeckLineType), INTENT(IN) :: types(:) + CHARACTER(*), INTENT(OUT) :: note + INTEGER :: j, k + REAL(wp) :: transit, ea, period + CHARACTER(32) :: b1, b2, b3, b4 + + note = '' + IF (.NOT. (dt > CD_ZERO)) RETURN + transit = CD_ZERO + DO j = 1, SIZE(sections) + IF (sections(j)%line_id /= line_id) CYCLE + k = find_type(types, sections(j)%ltype_name) + IF (k < 1) RETURN + IF (types(k)%is_syrope .OR. types(k)%elastic_mod == 3) RETURN + ea = types(k)%ea + IF (types(k)%elastic_mod == 2) ea = MAX(ea, types(k)%ea_d) + IF (.NOT. (ea > CD_ZERO .AND. types(k)%mass > CD_ZERO .AND. sections(j)%length > CD_ZERO)) RETURN + transit = transit + sections(j)%length/SQRT(ea/types(k)%mass) + END DO + period = 2.0_wp*transit + IF (.NOT. (period > CD_ZERO) .OR. period >= AXIAL_STEPS_PER_PERIOD*dt) RETURN + WRITE (b1, '(F12.4)') dt + WRITE (b2, '(F12.3)') period + WRITE (b3, '(F12.1)') period/dt + WRITE (b4, '(F12.4)') period/AXIAL_STEPS_PER_PERIOD + note = 'LINE '//TRIM(int_to_str(line_id))//': dtM = '//TRIM(ADJUSTL(b1))//' s resolves its axial period of '// & + TRIM(ADJUSTL(b2))//' s (twice its length over sqrt(EA/m)) in '//TRIM(ADJUSTL(b3))//' steps, fewer than '// & + TRIM(int_to_str(NINT(AXIAL_STEPS_PER_PERIOD)))//': its axial dynamics are damped and its tension peaks '// & + 'can be wrong. A dtM of at most '//TRIM(ADJUSTL(b4))//' s resolves them' + END SUBROUTINE axial_period_note + SUBROUTINE warn_line_below_anchor(lines, points, sections, q_all, q_off) !! Standard-output WARNING for a deck with no seabed (no WtrDpth or bathymetryFile) on !! which a line hangs below its lowest Fixed end by more than max(1 m, 1 % of its @@ -14196,6 +14322,8 @@ SUBROUTINE apply_option(line, opts, ErrStat, ErrMsg) END SELECT CASE ('cable_load_feedback', 'cableloadfeedback') CALL rd_bool(val, opts%cable_load_feedback, ErrStat, ErrMsg) + CASE ('predict_loads', 'predictloads') + CALL rd_bool(val, opts%predict_loads, ErrStat, ErrMsg) CASE ('tensile_safety', 'tensilesafety') CALL rd_tensile_mode(val, opts%tensile_mode, ErrStat, ErrMsg) CASE ('tensile_strain_tolerance', 'tensilestraintolerance') @@ -32618,6 +32746,10 @@ SUBROUTINE print_deck_init_report(deck_path, lines, points, sections, n_finite, IF (PRESENT(types) .AND. PRESENT(opts)) THEN CALL static_strain_note(summaries(i)%line_id, summaries(i)%fair_tension, sections, types, opts, strain_note) IF (LEN_TRIM(strain_note) > 0) WRITE (error_unit, '(A)') ' Note: '//TRIM(strain_note)//'.' + IF (opts%has_dtm .AND. opts%tmax > CD_ZERO) THEN + CALL axial_period_note(summaries(i)%line_id, opts%dtM, sections, types, strain_note) + IF (LEN_TRIM(strain_note) > 0) WRITE (error_unit, '(A)') ' Note: '//TRIM(strain_note)//'.' + END IF END IF END DO WRITE (error_unit, '(A)') ' CableDyn initialization completed.' diff --git a/src/CableDyn_Dynamic.f90 b/src/CableDyn_Dynamic.f90 index c5cb4fb..20c2ca9 100644 --- a/src/CableDyn_Dynamic.f90 +++ b/src/CableDyn_Dynamic.f90 @@ -37,6 +37,9 @@ MODULE CableDyn_Dynamic CD_Cable_Free_Bandwidth USE CableDyn_Linalg, ONLY: CD_Factor_Banded, CD_Solve_Banded, CD_Solve_Dense_As_Banded, & CD_Solve_Factored_Banded + USE CableDyn_Profile, ONLY: CD_Profile_Kernel_Begin, CD_Profile_Kernel_End, CD_PROF_K_RESIDUAL, & + CD_PROF_K_TANGENT, CD_PROF_K_FACTOR, CD_PROF_K_SOLVE + USE, INTRINSIC :: ISO_FORTRAN_ENV, ONLY: int64 IMPLICIT NONE PRIVATE @@ -755,6 +758,8 @@ SUBROUTINE CD_Cable_Gen_Alpha_Step(q, v, a, elem_conn, l0, ea, rho_a, tension_on LOGICAL :: accepted, has_load_proc, has_load_band_proc, has_added_mass_proc, has_added_mass_band_proc LOGICAL :: has_load_force_proc LOGICAL :: has_prescribed, direct_banded, have_eff, tangent_fresh, use_element_mass_band + ! clock at the start of a profiled kernel (CABLEDYN_PROFILE; zero when not profiling) + INTEGER(int64) :: prof_k0 LOGICAL :: factored_now, guess_used, prev_end_ok, reuse_factor, reused, warm_call, r_at_qk LOGICAL :: scales_stale, trial_is_iterate INTEGER :: es @@ -1239,8 +1244,10 @@ SUBROUTINE CD_Cable_Gen_Alpha_Step(q, v, a, elem_conn, l0, ea, rho_a, tension_on END DO ! eval_dynamic has just checked this band finite when a banded load or added-mass ! callback contributes to it; the factorization then skips repeating that scan. + CALL CD_Profile_Kernel_Begin(prof_k0) CALL CD_Factor_Banded(eff_band, kl, ku, ipiv, es, em, & matrix_validated=has_load_band_proc .OR. has_added_mass_band_proc) + CALL CD_Profile_Kernel_End(CD_PROF_K_FACTOR, prof_k0) factored_now = es == 0 work%step_factor = factored_now .AND. work%end_request IF (es /= 0) THEN @@ -1256,10 +1263,15 @@ SUBROUTINE CD_Cable_Gen_Alpha_Step(q, v, a, elem_conn, l0, ea, rho_a, tension_on IF (direct_banded) THEN ! A tangent factored in this iteration is the finite LU CD_Factor_Banded returned; a ! reused one keeps the full check. + CALL CD_Profile_Kernel_Begin(prof_k0) CALL CD_Solve_Factored_Banded(eff_band, kl, ku, ipiv, dq, es, em, factor_validated=tangent_fresh) + CALL CD_Profile_Kernel_End(CD_PROF_K_SOLVE, prof_k0) ELSE eff_free = eff(free, free) + ! the dense path factors and solves in one call: profiled as a factorisation + CALL CD_Profile_Kernel_Begin(prof_k0) CALL CD_Solve_Dense_As_Banded(eff_free, dq, es, em) + CALL CD_Profile_Kernel_End(CD_PROF_K_FACTOR, prof_k0) END IF IF (es /= 0 .OR. .NOT. CD_All_Finite(dq)) THEN ErrStat = CD_DYN_SINGULAR @@ -1497,7 +1509,9 @@ SUBROUTINE end_junction_data() eff_band_unfactored(i, j) = eff_band(i, j) END DO END DO + CALL CD_Profile_Kernel_Begin(prof_k0) CALL CD_Factor_Banded(eff_band, kl, ku, ipiv, es2, em2) + CALL CD_Profile_Kernel_End(CD_PROF_K_FACTOR, prof_k0) END IF work%step_factor = es2 == 0 IF (es2 /= 0) THEN @@ -1539,7 +1553,9 @@ SUBROUTINE end_junction_data() DO i = 1, nfx work%end_k(i, j) = (R_trial(fixed(i)) - R(fixed(i)))/h END DO + CALL CD_Profile_Kernel_Begin(prof_k0) CALL CD_Solve_Factored_Banded(eff_band, kl, ku, ipiv, dq, es2, em2) + CALL CD_Profile_Kernel_End(CD_PROF_K_SOLVE, prof_k0) IF (es2 /= 0 .OR. .NOT. CD_All_Finite(dq)) THEN ErrStat = CD_DYN_SINGULAR ErrMsg = 'CableDyn_Dynamic: junction end tangent back-solve failed: '//TRIM(em2) @@ -1912,6 +1928,25 @@ SUBROUTINE form_trial(stp) END SUBROUTINE form_trial SUBROUTINE eval_dynamic(q_eval, Rout, es_e, em_e, effout, effbandout) + !! eval_dynamic_core, timed by the run-time profile (CABLEDYN_PROFILE) as a residual-only + !! evaluation or, with an effective tangent requested, as a residual and tangent one. + REAL(wp), INTENT(IN) :: q_eval(:) + REAL(wp), INTENT(OUT) :: Rout(:) + INTEGER, INTENT(OUT) :: es_e + CHARACTER(*), INTENT(OUT) :: em_e + REAL(wp), INTENT(OUT), OPTIONAL :: effout(:, :) + REAL(wp), INTENT(OUT), OPTIONAL :: effbandout(:, :) + INTEGER(int64) :: clock0 + CALL CD_Profile_Kernel_Begin(clock0) + CALL eval_dynamic_core(q_eval, Rout, es_e, em_e, effout, effbandout) + IF (PRESENT(effout) .OR. PRESENT(effbandout)) THEN + CALL CD_Profile_Kernel_End(CD_PROF_K_TANGENT, clock0) + ELSE + CALL CD_Profile_Kernel_End(CD_PROF_K_RESIDUAL, clock0) + END IF + END SUBROUTINE eval_dynamic + + SUBROUTINE eval_dynamic_core(q_eval, Rout, es_e, em_e, effout, effbandout) !! R = M a_alpha + (1-af) r(q_eval, v_eval) + af r(q_n, v_n) with the force blend, else !! M a_alpha + f_int(q_alpha) - f_ext - load(q_alpha, v_alpha) (f_ext constant). When !! the OPTIONAL effout is present it also returns the effective tangent @@ -2150,7 +2185,7 @@ SUBROUTINE eval_dynamic(q_eval, Rout, es_e, em_e, effout, effbandout) CALL apply_release_cap(q_eval, Rout) END IF END IF - END SUBROUTINE eval_dynamic + END SUBROUTINE eval_dynamic_core SUBROUTINE structural_inertia(Rout) !! Rout = M a_alpha with the element consistent mass (of mass_l0 when present, the lengths diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index 87878b6..edf6b65 100644 --- a/src/CableDyn_HermiteCableDynamic.f90 +++ b/src/CableDyn_HermiteCableDynamic.f90 @@ -136,7 +136,7 @@ MODULE CableDyn_HermiteCableDynamic LOGICAL, PARAMETER, PUBLIC :: CD_HC_FORCE_BLEND_DEFAULT = .TRUE. PUBLIC :: CD_HermiteCable_Dyn_End PUBLIC :: CD_HermiteCable_Dyn_Reset_Profile, CD_HermiteCable_Dyn_Disable_Profile, & - CD_HermiteCable_Dyn_Get_Profile + CD_HermiteCable_Dyn_Get_Profile, CD_HermiteCable_Dyn_Get_Assembly_Profile PUBLIC :: CD_HermiteCable_Dyn_Profile_Enabled INTEGER, PARAMETER, PUBLIC :: CD_HCDYN_OK = 0, CD_HCDYN_BADINPUT = 1, CD_HCDYN_NOCONVERGE = 2 INTEGER, PARAMETER, PUBLIC :: CD_HCDYN_TENSILE_OFF = 0 @@ -169,6 +169,8 @@ MODULE CableDyn_HermiteCableDynamic INTEGER, SAVE :: prof_n_resid = 0 ! eval_res calls (predictor + accepted iterates + line-search trials) INTEGER, SAVE :: prof_n_tan = 0 ! tangent assemblies (once per continuing solve iteration) INTEGER, SAVE :: prof_n_solve = 0 ! effective-tangent linear solves (= Newton iterations) + INTEGER, SAVE :: prof_n_factor = 0 ! LU factorisations of the effective tangent + INTEGER, SAVE :: prof_n_correct = 0 ! force-blend corrections after convergence (one back-substitution) INTEGER, SAVE :: prof_n_am = 0 ! frozen added-mass assemblies (once per step when enabled) ! Arrays heap-allocated on the step path: the plain Step allocates none; the recovery ! rewind of CD_HermiteCable_Dyn_Step_Recovering allocates its interval buffers. @@ -177,8 +179,32 @@ MODULE CableDyn_HermiteCableDynamic REAL(wp), SAVE :: prof_t_resid = 0.0_wp ! eval_res wall: element/drag/FK/contact assembly + inertia band matvec REAL(wp), SAVE :: prof_t_tan = 0.0_wp ! tangent-assembly wall (element Hessian + drag/FK Jacobians + band scatters) REAL(wp), SAVE :: prof_t_eff = 0.0_wp ! banded Keff triad + in-band Dirichlet wall - REAL(wp), SAVE :: prof_t_solve = 0.0_wp ! banded linear-solve wall + REAL(wp), SAVE :: prof_t_solve = 0.0_wp ! back-substitution wall (Newton solves and force-blend corrections) + REAL(wp), SAVE :: prof_t_factor = 0.0_wp ! LU factorisation wall of the effective tangent REAL(wp), SAVE :: prof_t_am = 0.0_wp ! frozen added-mass assembly wall (band) + ! assemble_residual split into its sections (asm_mark), summed over every assembly while the + ! profile is armed: the residual and tangent assemblies above, and those made outside the step + ! (committed force, reactions, end moments, consistent accelerations). The tangent element + ! kernels evaluate the structural, damping, drag and wave tangents of each element together, + ! in parallel. + INTEGER, PARAMETER, PUBLIC :: HC_ASM_ZERO = 1, HC_ASM_TAN_KERNELS = 2, HC_ASM_SCATTER = 3, & + HC_ASM_STRUCT = 4, HC_ASM_WEIGHT = 5, HC_ASM_ATTACH = 6, HC_ASM_BUOY = 7, & + HC_ASM_CONTACT = 8, HC_ASM_DAMP = 9, HC_ASM_DRAG = 10, HC_ASM_ENDCONN = 11, & + HC_ASM_WAVE = 12, HC_ASM_HELD = 13, HC_ASM_TORSION = 14, HC_ASM_NSEC = 14 + CHARACTER(*), PARAMETER, PUBLIC :: HC_ASM_NAMES(HC_ASM_NSEC) = [CHARACTER(24) :: & + 'band zeroing', 'tangent element kernels', & + 'tangent scatter', 'structural residual', & + 'weight', 'attachments', 'dry buoyancy', & + 'seabed contact', 'axial damping', 'drag', & + 'end connections', 'wave Froude-Krylov', & + 'held Froude-Krylov', 'torsion'] + ! the same sections as one-word report keys (the OpenFAST module's key=value profile lines) + CHARACTER(*), PARAMETER, PUBLIC :: HC_ASM_KEYS(HC_ASM_NSEC) = [CHARACTER(12) :: & + 'zero', 'tan_kernels', 'tan_scatter', & + 'structural', 'weight', 'attachments', & + 'buoyancy', 'contact', 'damping', 'drag', & + 'end_conn', 'wave_fk', 'held_fk', 'torsion'] + REAL(wp), SAVE :: prof_t_asm(HC_ASM_NSEC) = 0.0_wp ! Number of coupling intervals completed by internal generalised-alpha substeps after the nominal ! interval was too large for the prescribed boundary motion or environmental load. This counter is @@ -255,12 +281,22 @@ MODULE CableDyn_HermiteCableDynamic ! the current iterate (ws_Rk) and of a line-search trial (ws_Rt), and the force at the ! committed state F_n = R(q_n, v_n, t_n). F_n is cached from the previous step's converged ! evaluation and keyed on the exact state it was evaluated at (fc_q, fc_v, fc_t, fc_d0) - ! plus fc_valid, which every load-configuration setter and Restore clears. A key mismatch - ! (host-updated boundary, restored or externally written state) recomputes it, so the - ! cache only ever replaces a bitwise-identical evaluation. + ! plus fc_valid, which every load-configuration setter clears (counting the clears in + ! fc_clears). A key mismatch (host-updated boundary, externally written state) recomputes + ! it. The cache equals a fresh evaluation in value but not always in its last bits: the + ! friction return mapping of the commit moves a sliding node's anchor, and the spring force + ! of the moved anchor reproduces the capped force only to rounding. A run is therefore + ! bit-reproducible along one path: Snapshot keeps the cache with the state, and Restore + ! brings it back unless the cache was invalidated since (fc_clears), so a restored model + ! takes the same path as one that was never rolled back. REAL(wp), ALLOCATABLE :: ws_Rk(:), ws_Rt(:), fc_R(:), fc_q(:), fc_v(:) REAL(wp) :: fc_t = 0.0_wp, fc_d0(3, 2) = 0.0_wp LOGICAL :: fc_valid = .FALSE. + INTEGER :: fc_clears = 0 + REAL(wp), ALLOCATABLE :: snap_fc_R(:), snap_fc_q(:), snap_fc_v(:) + 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 ! 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 @@ -458,14 +494,16 @@ MODULE CableDyn_HermiteCableDynamic ! Cache of the element end residuals behind the node outputs (CD_HermiteCable_Dyn_Node_Values). ! Element e's entry rc_g(:, e) is valid for the time and configuration epoch the cache was ! keyed at (rc_t, rc_key_epoch) and for the element's own 12 q, v and a DOFs it was evaluated - ! at (rc_q/rc_v/rc_a(:, e)), the only state its residual depends on, so a node query costs two - ! element comparisons and the channels of one output row share one evaluation per element. + ! at (rc_q/rc_v/rc_a(:, e)), and for the positions of the neighbouring nodes e - 1 and e + 2 + ! (rc_qnb(:, e); their floor contact decides the share of the floor reaction the element + ! carries), the only state its residual depends on, so a node query costs two element + ! comparisons and the channels of one output row share one evaluation per element. ! Allocated once at initialisation; rc_epoch counts the loads configured after it (drag, ! axial damping, waves, added mass, held fluid, contact). INTEGER :: rc_epoch = 0, rc_key_epoch = -1 LOGICAL :: rc_key_ok = .FALSE. REAL(wp) :: rc_t = CD_ZERO - REAL(wp), ALLOCATABLE :: rc_q(:, :), rc_v(:, :), rc_a(:, :), rc_g(:, :) + REAL(wp), ALLOCATABLE :: rc_q(:, :), rc_v(:, :), rc_a(:, :), rc_g(:, :), rc_qnb(:, :) LOGICAL, ALLOCATABLE :: rc_have(:) LOGICAL :: initialized = .FALSE. END TYPE CD_HermiteCableDynType @@ -499,10 +537,11 @@ SUBROUTINE CD_HermiteCable_Dyn_Reset_Profile() !! the SYSTEM_CLOCK overhead in the step/residual hot path. prof_enabled = .TRUE. prof_n_step = 0; prof_n_resid = 0; prof_n_solve = 0; prof_n_am = 0; prof_n_alloc = 0 - prof_n_tan = 0 + prof_n_tan = 0; prof_n_factor = 0; prof_n_correct = 0 prof_t_step = 0.0_wp; prof_t_resid = 0.0_wp; prof_t_eff = 0.0_wp - prof_t_tan = 0.0_wp + prof_t_tan = 0.0_wp; prof_t_factor = 0.0_wp prof_t_solve = 0.0_wp; prof_t_am = 0.0_wp + prof_t_asm = 0.0_wp END SUBROUTINE CD_HermiteCable_Dyn_Reset_Profile SUBROUTINE CD_HermiteCable_Dyn_Disable_Profile() @@ -512,24 +551,52 @@ SUBROUTINE CD_HermiteCable_Dyn_Disable_Profile() prof_enabled = .FALSE. END SUBROUTINE CD_HermiteCable_Dyn_Disable_Profile + SUBROUTINE CD_HermiteCable_Dyn_Get_Assembly_Profile(seconds) + !! Time of each assemble_residual section (HC_ASM_*, names HC_ASM_NAMES) since the last + !! CD_HermiteCable_Dyn_Reset_Profile; zero unless the profile is armed. + REAL(wp), INTENT(OUT) :: seconds(HC_ASM_NSEC) + seconds = prof_t_asm + END SUBROUTINE CD_HermiteCable_Dyn_Get_Assembly_Profile + + SUBROUTINE asm_mark(section, last) + !! Add the time since last to section and restart from now (profiled runs only). Called on + !! the thread that runs assemble_residual, outside its parallel loop; a profiled run steps + !! its cables one at a time, so the plain update is safe. + INTEGER, INTENT(IN) :: section + INTEGER(INT64), INTENT(INOUT) :: last + INTEGER(INT64) :: now, rate + IF (.NOT. prof_enabled) RETURN + CALL SYSTEM_CLOCK(now, rate) + IF (rate > 0_INT64) prof_t_asm(section) = prof_t_asm(section) + REAL(now - last, wp)/REAL(rate, wp) + last = now + END SUBROUTINE asm_mark + SUBROUTINE CD_HermiteCable_Dyn_Get_Profile(n_step, n_resid, n_solve, n_am, n_alloc, & - t_step, t_resid, t_eff, t_solve, t_am, n_tan, t_tan) + t_step, t_resid, t_eff, t_solve, t_am, n_tan, t_tan, & + n_factor, t_factor, n_correct) !! Read the diagnostic step-profile counters accumulated since the last reset. - !! t_step is the total wall inside CD_HermiteCable_Dyn_Step; t_resid + t_eff + - !! t_solve + t_am partition its instrumented interior (the remainder is - !! predictor/commit bookkeeping and workspace allocation). + !! t_step is the total wall inside CD_HermiteCable_Dyn_Step; t_resid + t_tan + t_eff + + !! t_factor + t_solve + t_am partition its instrumented interior (the remainder is + !! predictor/commit bookkeeping and workspace allocation). t_solve is the back-substitution + !! time of the n_solve Newton solves and the n_correct force-blend corrections; t_factor the + !! time of the n_factor LU factorisations. INTEGER, INTENT(OUT) :: n_step, n_resid, n_solve, n_am, n_alloc REAL(wp), INTENT(OUT) :: t_step, t_resid, t_eff, t_solve, t_am ! n_tan/t_tan report the tangent assemblies separately from the residual evaluations, ! so the eval_res averages stay honest (OPTIONAL so callers may omit them). INTEGER, INTENT(OUT), OPTIONAL :: n_tan REAL(wp), INTENT(OUT), OPTIONAL :: t_tan + INTEGER, INTENT(OUT), OPTIONAL :: n_factor, n_correct + REAL(wp), INTENT(OUT), OPTIONAL :: t_factor n_step = prof_n_step; n_resid = prof_n_resid; n_solve = prof_n_solve n_am = prof_n_am; n_alloc = prof_n_alloc t_step = prof_t_step; t_resid = prof_t_resid; t_eff = prof_t_eff t_solve = prof_t_solve; t_am = prof_t_am IF (PRESENT(n_tan)) n_tan = prof_n_tan IF (PRESENT(t_tan)) t_tan = prof_t_tan + IF (PRESENT(n_factor)) n_factor = prof_n_factor + IF (PRESENT(t_factor)) t_factor = prof_t_factor + IF (PRESENT(n_correct)) n_correct = prof_n_correct END SUBROUTINE CD_HermiteCable_Dyn_Get_Profile SUBROUTINE CD_HermiteCable_Dyn_Init(model, l0, EA, EI, rho_a, w, seed, fixed_dofs, & @@ -658,6 +725,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Init(model, l0, EA, EI, rho_a, w, seed, fixed_dof model%ws_recovery_q0(ndof), model%ws_recovery_v0(ndof), model%ws_recovery_a0(ndof)) ALLOCATE (model%ws_Rk(ndof), model%ws_Rt(ndof), model%fc_R(ndof), model%fc_q(ndof), model%fc_v(ndof)) model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. model%force_blend = CD_HC_FORCE_BLEND_DEFAULT model%max_step_rotation = CD_ZERO @@ -671,7 +739,8 @@ SUBROUTINE CD_HermiteCable_Dyn_Init(model, l0, EA, EI, rho_a, w, seed, fixed_dof ! state: at-rest seed, v = 0, consistent a0 ALLOCATE (model%q(ndof), model%v(ndof), model%a(ndof)) - ALLOCATE (model%rc_q(12, ne), model%rc_v(12, ne), model%rc_a(12, ne), model%rc_g(12, ne), model%rc_have(ne)) + ALLOCATE (model%rc_q(12, ne), model%rc_v(12, ne), model%rc_a(12, ne), model%rc_g(12, ne), model%rc_have(ne), & + model%rc_qnb(6, ne)) model%rc_have = .FALSE. model%rc_key_ok = .FALSE. model%q = seed @@ -723,6 +792,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Contact(model, kn_node, cn_node, mu, ErrStat, model%rc_epoch = model%rc_epoch + 1 ! A load-configuration change invalidates the cached committed force F_n. model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. IF (.NOT. model%initialized) THEN CALL fail_contact('model not initialized'); RETURN @@ -871,6 +941,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Friction_Axial(model, mu_axial, ErrStat, ErrM model%contact_fr_aniso = ABS(mu_axial - model%contact_mu) > CD_ZERO ! A friction-law change invalidates the cached committed force F_n and the carried factor. model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. END SUBROUTINE CD_HermiteCable_Dyn_Set_Friction_Axial @@ -916,6 +987,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Friction_Anchors(model, anchors, ErrStat, Err model%fr_anchor = anchors model%fr_active = .TRUE. model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. CALL solve_consistent_acceleration(model, es, em) IF (es /= CD_HCDYN_OK) THEN @@ -994,6 +1066,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Attachments(model, node, mass, volume, cda, c RETURN END IF model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. a_old = model%a mgb_old = model%Mgb @@ -1055,7 +1128,8 @@ SUBROUTINE CD_HermiteCable_Attachment_Drag(rel_velocity, tangent, rho, cda, cdax END IF END SUBROUTINE CD_HermiteCable_Attachment_Drag - SUBROUTINE assemble_attachments(model, q_cfg, v_cfg, want_residual, want_tangent, R, Kb, Kvb, ErrStat, ErrMsg) + SUBROUTINE assemble_attachments(model, q_cfg, v_cfg, want_residual, want_tangent, R, Kb, Kvb, n_lo, n_hi, & + ErrStat, ErrMsg) !! Attachment loads into R = f_int - f_ext, with the velocity tangent dR/dv and the !! drag's dependence on the nodal line tangent in dR/dq. The held fluid field is !! constant over the step. @@ -1063,6 +1137,7 @@ SUBROUTINE assemble_attachments(model, q_cfg, v_cfg, want_residual, want_tangent REAL(wp), INTENT(IN) :: q_cfg(:), v_cfg(:) LOGICAL, INTENT(IN) :: want_residual, want_tangent REAL(wp), INTENT(INOUT) :: R(:), Kb(:, :), Kvb(:, :) + INTEGER, INTENT(IN) :: n_lo, n_hi ! only the attachments at nodes n_lo..n_hi INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg INTEGER :: k, base, a, b @@ -1070,6 +1145,7 @@ SUBROUTINE assemble_attachments(model, q_cfg, v_cfg, want_residual, want_tangent REAL(wp) :: rk(3), jrel(3, 3), jtan(3, 3) ErrStat = CD_HCDYN_OK; ErrMsg = '' DO k = 1, SIZE(model%att_node) + IF (model%att_node(k) < n_lo .OR. model%att_node(k) > n_hi) CYCLE base = 6*(model%att_node(k) - 1) CALL attachment_residual(model, k, q_cfg, v_cfg, want_tangent, rk, has_jac, jrel, jtan, ErrStat, ErrMsg) IF (ErrStat /= CD_HCDYN_OK) RETURN @@ -1643,6 +1719,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_ForceBlend(model, enabled, ErrStat, ErrMsg) END IF model%force_blend = enabled model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. END SUBROUTINE CD_HermiteCable_Dyn_Set_ForceBlend @@ -1866,6 +1943,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_EndConnection(model, k_rot, d0, ErrStat, ErrM ErrStat = CD_HCDYN_OK; ErrMsg = '' ! A load-configuration change invalidates the cached committed force F_n. model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. IF (.NOT. model%initialized) THEN ErrStat = CD_HCDYN_BADINPUT @@ -2236,6 +2314,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Drag(model, rho_w, diam, cdn, cdt, waterline_ model%rc_epoch = model%rc_epoch + 1 ! A load-configuration change invalidates the cached committed force F_n. model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. IF (.NOT. model%initialized) THEN ErrStat = CD_HCDYN_BADINPUT; ErrMsg = 'CD_HermiteCable_Dyn_Set_Drag: model not initialised'; RETURN @@ -2335,6 +2414,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Axial_Damping(model, BA, ErrStat, ErrMsg) model%rc_epoch = model%rc_epoch + 1 ! A load-configuration change invalidates the cached committed force F_n. model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. ErrMsg = '' IF (.NOT. model%initialized) THEN @@ -2406,6 +2486,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_AddedMass(model, rho_w, diam, can, cat, water model%rc_epoch = model%rc_epoch + 1 ! A load-configuration change invalidates the cached committed force F_n. model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. IF (.NOT. model%initialized) THEN ErrStat = CD_HCDYN_BADINPUT; ErrMsg = 'CD_HermiteCable_Dyn_Set_AddedMass: model not initialised'; RETURN @@ -2502,6 +2583,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Waves(model, height, period, direction_deg, d model%rc_epoch = model%rc_epoch + 1 ! A load-configuration change invalidates the cached committed force F_n. model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. IF (.NOT. model%initialized) THEN ErrStat = CD_HCDYN_BADINPUT; ErrMsg = 'CD_HermiteCable_Dyn_Set_Waves: model not initialised'; RETURN @@ -2622,6 +2704,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Irregular_Waves(model, amplitude, period, pha model%rc_epoch = model%rc_epoch + 1 ! A load-configuration change invalidates the cached committed force F_n. model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. IF (.NOT. model%initialized) THEN ErrStat = CD_HCDYN_BADINPUT @@ -2761,6 +2844,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Held_Fluid(model, fluid_velocity, fluid_accel ! factor stays usable (a per-step field update only perturbs the drag and inertia tangent; ! the first field's consistent-acceleration solve drops it). model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 IF (.NOT. model%initialized) THEN ErrStat = CD_HCDYN_BADINPUT ErrMsg = 'CD_HermiteCable_Dyn_Set_Held_Fluid: model not initialised'; RETURN @@ -3028,6 +3112,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Torsion_Frames(model, ends, ErrStat, ErrMsg, model%torsion%theta = th model%torsion%torque = (model%torsion%phi - th)/CD_HermiteTorsion_Compliance(model%torsion, model%l0) model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. END SUBROUTINE CD_HermiteCable_Dyn_Set_Torsion_Frames @@ -3502,7 +3587,8 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r CALL CD_Factor_Banded(model%ws_Keffb, KL_D, KU_D, model%ws_ipiv, es, em2, matrix_validated=.TRUE.) IF (prof_enabled) THEN CALL SYSTEM_CLOCK(pc1) - prof_t_solve = prof_t_solve + REAL(pc1 - pc0, wp)/REAL(pcr, wp) + prof_n_factor = prof_n_factor + 1 + prof_t_factor = prof_t_factor + REAL(pc1 - pc0, wp)/REAL(pcr, wp) END IF IF (es /= CD_LINALG_OK) THEN ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = 'CD_HermiteCable_Dyn_Step: tangent factor: '//TRIM(em2) @@ -3733,8 +3819,14 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r DO i = 1, ndof IF (.NOT. model%solve_mask(i)) model%ws_dq(i) = CD_ZERO END DO + IF (prof_enabled) CALL SYSTEM_CLOCK(pc0) CALL CD_Solve_Factored_Banded(model%ws_Keffb, KL_D, KU_D, model%ws_ipiv, model%ws_dq, es, em2, & factor_validated=.TRUE.) + IF (prof_enabled) THEN + CALL SYSTEM_CLOCK(pc1) + prof_n_correct = prof_n_correct + 1 + prof_t_solve = prof_t_solve + REAL(pc1 - pc0, wp)/REAL(pcr, wp) + END IF IF (es == CD_LINALG_OK) THEN IF (model%torsion%active) CALL torsion_sm_correct() CALL transform_rigid_vector_to_global(model, step_rigid_basis, model%ws_dq) @@ -5056,7 +5148,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Curvature_Exact(model, curv_out, ErrStat, ErrMsg) END DO END SUBROUTINE CD_HermiteCable_Dyn_Curvature_Exact - SUBROUTINE CD_HermiteCable_Dyn_Reaction(model, node, freac, ErrStat, ErrMsg) + SUBROUTINE CD_HermiteCable_Dyn_Reaction(model, node, freac, ErrStat, ErrMsg, full_assembly) !! Constraint reaction at a fixed/prescribed node: the force the LINE exerts ON its !! support (fairlead/anchor) at the current committed state, in global coordinates. !! @@ -5077,15 +5169,30 @@ SUBROUTINE CD_HermiteCable_Dyn_Reaction(model, node, freac, ErrStat, ErrMsg) REAL(wp), INTENT(OUT) :: freac(3) INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg + ! .TRUE.: evaluate from the full assembly, the reference the node's rows reproduce bit for bit + LOGICAL, INTENT(IN), OPTIONAL :: full_assembly INTEGER :: es, gi + LOGICAL :: full CHARACTER(1024) :: em2 ErrStat = CD_HCDYN_OK; ErrMsg = ''; freac = CD_ZERO + full = .FALSE. + IF (PRESENT(full_assembly)) full = full_assembly IF (.NOT. model%initialized) THEN ErrStat = CD_HCDYN_BADINPUT; ErrMsg = 'CD_HermiteCable_Dyn_Reaction: model not initialised'; RETURN END IF IF (node < 1 .OR. node > model%nn) THEN ErrStat = CD_HCDYN_BADINPUT; ErrMsg = 'CD_HermiteCable_Dyn_Reaction: node out of range'; RETURN END IF + ! Only the node's rows are needed: assemble them from the elements that reach the node + ! (bit-identical to the full assembly below, which torsion still needs). + IF (.NOT. (model%torsion%active .OR. full)) THEN + CALL node_reaction(model, node, freac, es, em2) + IF (es /= CD_HCDYN_OK) THEN + freac = CD_ZERO + ErrStat = es; ErrMsg = 'CD_HermiteCable_Dyn_Reaction: '//TRIM(em2) + END IF + RETURN + END IF ! Residual-only assembly at the committed state: no tangent buffer is touched, so the ! committed K/Kv (which callers may still hold between steps) are preserved. CALL assemble_residual(model, model%q, model%v, model%t, .TRUE., .FALSE., es, em2) @@ -5106,6 +5213,65 @@ SUBROUTINE CD_HermiteCable_Dyn_Reaction(model, node, freac, ErrStat, ErrMsg) freac = -(model%ws_inert(gi + 1:gi + 3) + model%ws_R(gi + 1:gi + 3)) END SUBROUTINE CD_HermiteCable_Dyn_Reaction + SUBROUTINE node_reaction(model, node, freac, ErrStat, ErrMsg) + !! CD_HermiteCable_Dyn_Reaction from the node's rows only: the residual of the one or two + !! elements that reach the node and of its nodal terms (assemble_residual node_only), and + !! the node's rows of the total mass times the acceleration, with the added mass of those + !! elements. Every row term is formed and summed in the order of the full assembly and of + !! band_matvec, so the reaction is bit-identical, at the cost of one or two elements + !! instead of the line, and the model's scratch is not touched. Not for a line with + !! torsion, which couples every element. + TYPE(CD_HermiteCableDynType), INTENT(INOUT) :: model + INTEGER, INTENT(IN) :: node + REAL(wp), INTENT(OUT) :: freac(3) + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + REAL(wp) :: Rn(SIZE(model%ws_R)), Me(12, 12), mam(3, -KL_D:KU_D), inert(3), m + INTEGER :: e, a, b, k, gi, gj, j, n, es + CHARACTER(1024) :: em + + ErrStat = CD_HCDYN_OK; ErrMsg = ''; freac = CD_ZERO + CALL assemble_residual(model, model%q, model%v, model%t, .TRUE., .FALSE., es, em, node_only=node, R_out=Rn) + IF (es /= CD_HCDYN_OK) THEN + ErrStat = es; ErrMsg = TRIM(em); RETURN + END IF + gi = 6*(node - 1) + n = SIZE(model%a) + ! the node's rows of the added mass, mam(k, j - (gi + k)) = Mamb(gi + k, j), summed element + ! by element from zero as assemble_added_mass scatters them + mam = CD_ZERO + IF (model%has_am) THEN + DO e = MAX(1, node - 1), MIN(model%ne, node) + CALL added_mass_element(model, model%q, e, Me, es, em) + IF (es /= CD_HCDYN_OK) THEN + ErrStat = es; ErrMsg = TRIM(em); RETURN + END IF + DO a = 1, 12 + k = elem_gdof(e, a) - gi + IF (k < 1 .OR. k > 3) CYCLE + DO b = 1, 12 + gj = elem_gdof(e, b) + mam(k, gj - (gi + k)) = mam(k, gj - (gi + k)) + Me(a, b) + END DO + END DO + END DO + IF (.NOT. CD_All_Finite(mam)) THEN + ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = 'assemble_added_mass: non-finite global added-mass matrix'; RETURN + END IF + END IF + ! (Mgb + Mamb) a on the node's rows, column by column as band_matvec accumulates them + inert = CD_ZERO + DO j = MAX(1, gi + 1 - KL_D), MIN(n, gi + 3 + KU_D) + DO k = 1, 3 + IF (j < gi + k - KL_D .OR. j > gi + k + KU_D) CYCLE + m = model%Mgb(KL_D + KU_D + 1 + gi + k - j, j) + IF (model%has_am) m = m + mam(k, j - (gi + k)) + inert(k) = inert(k) + m*model%a(j) + END DO + END DO + freac = -(inert + Rn(gi + 1:gi + 3)) + END SUBROUTINE node_reaction + SUBROUTINE CD_HermiteCable_Dyn_End_Force(model, node, force, ErrStat, ErrMsg) !! Force the line exerts on the point holding its end node (node = 1 or nn) at the !! committed state (q, v): the end element's internal force (elastic plus Kelvin-Voigt @@ -5346,14 +5512,19 @@ SUBROUTINE CD_HermiteCable_Dyn_Node_Values(model, node, tension, curvature, bend SUBROUTINE cached_end_residual(e, ge) INTEGER, INTENT(IN) :: e REAL(wp), INTENT(OUT) :: ge(12) - REAL(wp) :: gl(12) + REAL(wp) :: gl(12), qnb(6) INTEGER :: i0 - ! the element's residual depends on its own two nodes' q, v and a only (element_end_residual) + ! the element's residual depends on its own two nodes' q, v and a and on the floor contact + ! of the neighbouring nodes, a function of their positions (element_end_residual) i0 = 6*(e - 1) + qnb = CD_ZERO + IF (e > 1) qnb(1:3) = model%q(i0 - 5:i0 - 3) + IF (e + 2 <= model%nn) qnb(4:6) = model%q(i0 + 13:i0 + 15) IF (model%rc_have(e)) THEN IF (ALL(reals_equal(model%rc_q(:, e), model%q(i0 + 1:i0 + 12))) .AND. & ALL(reals_equal(model%rc_v(:, e), model%v(i0 + 1:i0 + 12))) .AND. & - ALL(reals_equal(model%rc_a(:, e), model%a(i0 + 1:i0 + 12)))) THEN + ALL(reals_equal(model%rc_a(:, e), model%a(i0 + 1:i0 + 12))) .AND. & + ALL(reals_equal(model%rc_qnb(:, e), qnb))) THEN ge = model%rc_g(:, e); RETURN END IF END IF @@ -5365,6 +5536,7 @@ SUBROUTINE cached_end_residual(e, ge) model%rc_q(:, e) = model%q(i0 + 1:i0 + 12) model%rc_v(:, e) = model%v(i0 + 1:i0 + 12) model%rc_a(:, e) = model%a(i0 + 1:i0 + 12) + model%rc_qnb(:, e) = qnb model%rc_g(:, e) = gl model%rc_have(e) = .TRUE. ge = gl @@ -5718,16 +5890,24 @@ SUBROUTINE element_end_residual(model, e, g, ErrStat, ErrMsg) IF (es /= CD_HCDYN_OK) GOTO 900 g = g - fx END IF - ! Seabed contact acts at the nodes; spread over each node's tributary length it is the + ! Seabed contact acts at the nodes; spread over the grounded line next to each node it is the ! distributed floor reaction it stands for. Its consistent share is taken out of the element, - ! so a line resting on the floor carries no fixed-end moment of its own weight. + ! so a line resting on the floor carries no fixed-end moment of its own weight. A node's + ! reaction is shared by the half-elements beside it whose far node is on the floor as well + ! (grounded elements), by length. The touchdown element, on the floor at one end only, is + ! mostly suspended and takes none of it; spreading the reaction onto it understated the + ! tension of the last suspended node by the floor reaction of half an element (5 % on a + ! 10 m lazy-wave mesh). A node with no grounded element beside it (a node just touching + ! down, or an isolated contact) shares its reaction by tributary length. IF (model%has_contact .OR. model%kn > CD_ZERO) THEN CALL node_contact_normal(model, e, fca, es, em2) IF (es /= CD_HCDYN_OK) GOTO 900 CALL node_contact_normal(model, e + 1, fcb, es, em2) IF (es /= CD_HCDYN_OK) GOTO 900 - IF (model%trib(e) > CD_ZERO) fca = fca*l0/model%trib(e) - IF (model%trib(e + 1) > CD_ZERO) fcb = fcb*l0/model%trib(e + 1) + CALL grounded_share(e, e, fca, es) + IF (es /= CD_HCDYN_OK) GOTO 900 + CALL grounded_share(e + 1, e, fcb, es) + IF (es /= CD_HCDYN_OK) GOTO 900 ! A held end whose end element lies on the floor (assemble_residual): the seabed carries the ! element's weight with its consistent end moment, which the held end node's own contact ! (a point reaction at an anchor sitting on the floor) cannot. The floor reaction of the @@ -5774,6 +5954,75 @@ SUBROUTINE element_end_residual(model, e, g, ErrStat, ErrMsg) RETURN 900 ErrStat = CD_HCDYN_NOCONVERGE ErrMsg = 'CD_HermiteCable_Dyn_Node_Recovery: element '//TRIM(em2) + + CONTAINS + + SUBROUTINE grounded_share(node, el, fc, esx) + !! Scale the floor reaction fc of node (an end of element el) to el's share of it, as the + !! factor l0(el)/(grounded length beside the node) that the half-element shape integrals + !! below expect: zero when el is not grounded but another element beside the node is, the + !! tributary-length share when no element beside the node is grounded. + INTEGER, INTENT(IN) :: node, el + REAL(wp), INTENT(INOUT) :: fc(3) + INTEGER, INTENT(OUT) :: esx + REAL(wp) :: grounded_len + LOGICAL :: on_el, grounded + INTEGER :: k + esx = CD_HCDYN_OK + IF (.NOT. ANY(ABS(fc) > CD_ZERO)) RETURN + grounded_len = CD_ZERO + on_el = .FALSE. + DO k = MAX(1, node - 1), MIN(model%ne, node) + CALL element_on_floor(k, grounded, esx) + IF (esx /= CD_HCDYN_OK) RETURN + IF (grounded) THEN + grounded_len = grounded_len + 0.5_wp*model%l0(k) + IF (k == el) on_el = .TRUE. + END IF + END DO + IF (grounded_len > CD_ZERO) THEN + IF (on_el) THEN + fc = fc*model%l0(el)/grounded_len + ELSE + fc = CD_ZERO + END IF + ELSE IF (model%trib(node) > CD_ZERO) THEN + fc = fc*model%l0(el)/model%trib(node) + END IF + END SUBROUTINE grounded_share + + SUBROUTINE element_on_floor(k, grounded, esx) + !! Element k is grounded when both its nodes are on the floor (node_on_floor). + INTEGER, INTENT(IN) :: k + LOGICAL, INTENT(OUT) :: grounded + INTEGER, INTENT(OUT) :: esx + LOGICAL :: on1 + grounded = .FALSE. + CALL node_on_floor(k, on1, esx) + IF (esx /= CD_HCDYN_OK .OR. .NOT. on1) RETURN + CALL node_on_floor(k + 1, grounded, esx) + END SUBROUTINE element_on_floor + + SUBROUTINE node_on_floor(node, on, esx) + !! A node is on the floor when it carries a floor reaction, or when it is a held line end + !! whose end element the floor supports (held_end_floor_support): an anchor held exactly + !! on the floor has no contact force of its own, yet its laid end element is grounded. + INTEGER, INTENT(IN) :: node + LOGICAL, INTENT(OUT) :: on + INTEGER, INTENT(OUT) :: esx + REAL(wp) :: fn(3), sup(3), dsup(3), nn_in(3) + INTEGER :: in_node + on = .FALSE. + CALL node_contact_normal(model, node, fn, esx, em2) + IF (esx /= CD_HCDYN_OK) RETURN + on = ANY(ABS(fn) > CD_ZERO) + IF (on) RETURN + IF (node == 1) THEN + on = held_end_floor_support(model, model%q, 1, sup, dsup, in_node, nn_in) + ELSE IF (node == model%nn) THEN + on = held_end_floor_support(model, model%q, 2, sup, dsup, in_node, nn_in) + END IF + END SUBROUTINE node_on_floor END SUBROUTINE element_end_residual PURE REAL(wp) FUNCTION CD_HermiteCable_Dyn_Max_Step_Rotation(model) RESULT(angle) @@ -6002,6 +6251,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Torsion(model, torsion, ErrStat, ErrMsg) ! the snapshot predates the new load set (as for CD_HermiteCable_Dyn_Set_EndConnection) model%snap_valid = .FALSE. model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. CALL CD_HermiteCable_Dyn_Recompute_Acceleration(model, es, em) IF (es /= CD_HCDYN_OK) THEN @@ -6193,6 +6443,16 @@ SUBROUTINE CD_HermiteCable_Dyn_Snapshot(model, ErrStat, ErrMsg) model%snap_torsion_phi = model%torsion%phi model%snap_torsion_ends = model%torsion%ends IF (model%fr_active) model%snap_fr_anchor = model%fr_anchor + ! 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 + IF (model%snap_fc_valid) THEN + model%snap_fc_R = model%fc_R + model%snap_fc_q = model%fc_q + model%snap_fc_v = model%fc_v + model%snap_fc_t = model%fc_t + model%snap_fc_d0 = model%fc_d0 + END IF model%snap_na_steps_done = model%na_steps_done model%snap_na_iter_sum = model%na_iter_sum model%snap_na_mn_active = model%na_mn_active @@ -6272,7 +6532,21 @@ 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 - model%fc_valid = .FALSE. + ! 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 + model%fc_R = model%snap_fc_R + model%fc_q = model%snap_fc_q + model%fc_v = model%snap_fc_v + model%fc_t = model%snap_fc_t + model%fc_d0 = model%snap_fc_d0 + model%fc_valid = .TRUE. + ELSE + model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 + END IF model%tr_valid = .FALSE. END SUBROUTINE CD_HermiteCable_Dyn_Restore @@ -6321,7 +6595,7 @@ SUBROUTINE CD_HermiteCable_Dyn_End(model) model%ws_elem_Kwave, model%ws_elem_Kheld, & model%ws_elem_es, model%ws_elem_em) END IF - IF (ALLOCATED(model%rc_q)) DEALLOCATE (model%rc_q, model%rc_v, model%rc_a, model%rc_g, model%rc_have) + IF (ALLOCATED(model%rc_q)) DEALLOCATE (model%rc_q, model%rc_v, model%rc_a, model%rc_g, model%rc_have, model%rc_qnb) model%rc_key_ok = .FALSE. IF (ALLOCATED(model%freemask)) DEALLOCATE (model%freemask) IF (ALLOCATED(model%solve_mask)) DEALLOCATE (model%solve_mask) @@ -6354,7 +6628,10 @@ SUBROUTINE CD_HermiteCable_Dyn_End(model) model%endconn_k = CD_ZERO model%endconn_d0 = CD_ZERO IF (ALLOCATED(model%snap_q)) DEALLOCATE (model%snap_q, model%snap_v, model%snap_a) + IF (ALLOCATED(model%snap_fc_R)) DEALLOCATE (model%snap_fc_R, model%snap_fc_q, model%snap_fc_v) + model%snap_fc_valid = .FALSE. model%fc_valid = .FALSE. + model%fc_clears = model%fc_clears + 1 model%tr_valid = .FALSE. model%snap_t = CD_ZERO model%snap_endconn_d0 = CD_ZERO @@ -6394,7 +6671,7 @@ END SUBROUTINE CD_HermiteCable_Dyn_End ! ------------------------------------------------------------------------------------------ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_tangent, ErrStat, ErrMsg, & - endconn_direction_eval) + endconn_direction_eval, node_only, R_out) !! Residual R = f_int(q) - f_ext(q, v, t), banded position tangent Kb = dR/dq, and banded !! velocity tangent Kvb = dR/dv at configuration q_cfg with nodal velocity v_cfg at time !! t_eval (LAPACK band layout, see the module note), written into the MODEL'S OWN @@ -6419,8 +6696,17 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg REAL(wp), INTENT(IN), OPTIONAL :: endconn_direction_eval(3, 2) + ! node_only: assemble only the residual rows of that node, into R_out (sized like the + ! residual) instead of the model's residual: the elements that reach the node and its own + ! nodal terms, each added in the order of the full assembly, so those rows are bit-identical + ! and the model's scratch is untouched (node_reaction). Residual only, without torsion + ! (which couples every element). The other rows of R_out are partial. + INTEGER, INTENT(IN), OPTIONAL :: node_only + REAL(wp), INTENT(INOUT), TARGET, OPTIONAL :: R_out(:) REAL(wp), POINTER :: R(:), Kb(:, :), Kvb(:, :) INTEGER :: ne, e, a, gi, es, omp_threads + INTEGER :: e_lo, e_hi, n_lo, n_hi ! the elements and nodes assembled (all unless node_only) + INTEGER(INT64) :: asm_t ! clock at the last section mark (asm_mark; profiled runs only) LOGICAL :: tangent_only REAL(wp) :: qe(12), ve(12), Ee, fe(12), Ke(12, 12), pen REAL(wp) :: fdrag(12), djq(12, 12), djv(12, 12), ffk(12), fjq(12, 12) @@ -6447,12 +6733,34 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta R => model%ws_R Kb => model%ws_Kb Kvb => model%ws_Kvb + e_lo = 1; e_hi = ne; n_lo = 1; n_hi = model%nn + IF (PRESENT(node_only)) THEN + IF (want_tangent .OR. model%torsion%active .OR. .NOT. PRESENT(R_out)) THEN + ErrStat = CD_HCDYN_BADINPUT + ErrMsg = 'a single-node assembly is residual-only, without torsion, into a caller residual' + RETURN + END IF + IF (node_only < 1 .OR. node_only > model%nn) THEN + ErrStat = CD_HCDYN_BADINPUT; ErrMsg = 'single-node assembly: node out of range'; RETURN + END IF + e_lo = MAX(1, node_only - 1); e_hi = MIN(ne, node_only) + n_lo = node_only; n_hi = node_only + END IF + IF (PRESENT(R_out)) THEN + IF (SIZE(R_out) /= SIZE(model%ws_R)) THEN + ErrStat = CD_HCDYN_BADINPUT; ErrMsg = 'assemble_residual: R_out must be sized like the residual'; RETURN + END IF + R => R_out + END IF endconn_d0_eval = model%endconn_d0 IF (PRESENT(endconn_direction_eval)) endconn_d0_eval = endconn_direction_eval + asm_t = 0_INT64 + IF (prof_enabled) CALL SYSTEM_CLOCK(asm_t) IF (want_residual) R = CD_ZERO IF (want_tangent) THEN Kb = CD_ZERO; Kvb = CD_ZERO END IF + CALL asm_mark(HC_ASM_ZERO, asm_t) ! Tangent-only element kernels are independent. Evaluate them in parallel into persistent ! buffers, then validate and scatter serially in the original element order. Residual and @@ -6470,14 +6778,16 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta END DO !$OMP END DO !$OMP END PARALLEL + CALL asm_mark(HC_ASM_TAN_KERNELS, asm_t) DO e = 1, ne IF (model%ws_elem_es(e) /= CD_HCABLE_OK) THEN ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = 'element: '//TRIM(model%ws_elem_em(e)); RETURN END IF CALL scatter12_band(Kb, model%ws_elem_K(:, :, e), e) END DO + CALL asm_mark(HC_ASM_SCATTER, asm_t) ELSE - DO e = 1, ne + DO e = e_lo, e_hi qe(1:3) = q_cfg(6*(e - 1) + 1:6*(e - 1) + 3) qe(4:6) = q_cfg(6*(e - 1) + 4:6*(e - 1) + 6) qe(7:9) = q_cfg(6*e + 1:6*e + 3) @@ -6503,11 +6813,12 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta END IF IF (want_tangent) CALL scatter12_band(Kb, Ke, e) END DO + CALL asm_mark(HC_ASM_STRUCT, asm_t) END IF IF (want_residual) THEN ! consistent cubic-Hermite self-weight on z DOFs (R = fint - fext => -fext adds +w) - DO e = 1, ne + DO e = e_lo, e_hi R(6*(e - 1) + 3) = R(6*(e - 1) + 3) + model%w(e)*0.5_wp*model%l0(e) R(6*(e - 1) + 6) = R(6*(e - 1) + 6) + model%w(e)*model%l0(e)*model%l0(e)/12.0_wp R(6*e + 3) = R(6*e + 3) + model%w(e)*0.5_wp*model%l0(e) @@ -6523,12 +6834,14 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta R(6*ne + 4:6*ne + 6) = R(6*ne + 4:6*ne + 6) + (model%w(ne)*model%l0(ne)*model%l0(ne)/12.0_wp)*support END IF END IF + CALL asm_mark(HC_ASM_WEIGHT, asm_t) IF (model%has_attach) THEN - CALL assemble_attachments(model, q_cfg, v_cfg, want_residual, want_tangent, R, Kb, Kvb, es, em2) + CALL assemble_attachments(model, q_cfg, v_cfg, want_residual, want_tangent, R, Kb, Kvb, n_lo, n_hi, es, em2) IF (es /= CD_HCDYN_OK) THEN ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = TRIM(em2); RETURN END IF END IF + CALL asm_mark(HC_ASM_ATTACH, asm_t) ! Dry-part buoyancy recovery. w is the submerged weight; the part of an element above the ! free surface of the drag configuration regains its displaced-water buoyancy. The surface @@ -6537,7 +6850,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! drag configuration the added-mass configuration defines the surface and section, so ! the buoyancy is restored whenever either hydrodynamic configuration is present. IF (model%has_drag .OR. model%has_am) THEN - DO e = 1, ne + DO e = e_lo, e_hi IF (model%has_drag) THEN dry_wl = model%waterline_z dry_b = model%rho_w*model%gravity*0.25_wp*PI_L*model%diam(e)**2 @@ -6564,16 +6877,17 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta END DO END IF + CALL asm_mark(HC_ASM_BUOY, asm_t) ! Standalone production contact uses the same nodal C1 normal/damper/friction ! law as the deck compatibility path, including structured-bed slope terms. ! Retain the scalar flat penalty for existing direct API callers. IF (model%has_contact) THEN - CALL assemble_contact(model, q_cfg, v_cfg, want_residual, want_tangent, es, em2) + CALL assemble_contact(model, q_cfg, v_cfg, want_residual, want_tangent, R, n_lo, n_hi, es, em2) IF (es /= CD_HCDYN_OK) THEN ErrStat = es; ErrMsg = 'contact: '//TRIM(em2); RETURN END IF ELSE IF (model%kn > CD_ZERO) THEN - DO a = 1, model%nn + DO a = n_lo, n_hi gi = 6*(a - 1) + 3 pen = model%seabed_z - q_cfg(gi) IF (pen > CD_ZERO) THEN @@ -6603,6 +6917,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta END IF END IF + CALL asm_mark(HC_ASM_CONTACT, asm_t) ! Scatter the element-private hydrodynamic tangents in the same phase and element ! order as the serial formulation, retaining its floating-point accumulation order. IF (tangent_only .AND. (model%has_drag .OR. model%has_axial_damping)) THEN @@ -6622,10 +6937,11 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta END DO END IF + CALL asm_mark(HC_ASM_SCATTER, asm_t) ! Axial Kelvin-Voigt damping is an internal constitutive force. It uses the same ! reference-arc Hermite interpolation and axial quadrature as the elastic resultant. IF (model%has_axial_damping .AND. .NOT. tangent_only) THEN - DO e = 1, ne + DO e = e_lo, e_hi qe(1:3) = q_cfg(6*(e - 1) + 1:6*(e - 1) + 3); qe(4:6) = q_cfg(6*(e - 1) + 4:6*(e - 1) + 6) qe(7:9) = q_cfg(6*e + 1:6*e + 3); qe(10:12) = q_cfg(6*e + 4:6*e + 6) ve(1:3) = v_cfg(6*(e - 1) + 1:6*(e - 1) + 3); ve(4:6) = v_cfg(6*(e - 1) + 4:6*(e - 1) + 6) @@ -6664,13 +6980,14 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta END DO END IF + CALL asm_mark(HC_ASM_DAMP, asm_t) ! Consistent Morison quadratic drag on the submerged cable, distributed over the full cubic- ! Hermite shape functions (so the material-tangent DOFs, which move the element interior, also ! carry drag). The element returns the 12-DOF generalized drag LOAD fdrag (f_ext) and its force ! Jacobians djq = d(fdrag)/dq, djv = d(fdrag)/dv. R = f_int - f_ext, so R -= fdrag, K -= djq, ! Kv -= djv over all 12 element DOFs. IF (model%has_drag .AND. .NOT. tangent_only) THEN - DO e = 1, ne + DO e = e_lo, e_hi qe(1:3) = q_cfg(6*(e - 1) + 1:6*(e - 1) + 3); qe(4:6) = q_cfg(6*(e - 1) + 4:6*(e - 1) + 6) qe(7:9) = q_cfg(6*e + 1:6*e + 3); qe(10:12) = q_cfg(6*e + 4:6*e + 6) ve(1:3) = v_cfg(6*(e - 1) + 1:6*(e - 1) + 3); ve(4:6) = v_cfg(6*(e - 1) + 4:6*(e - 1) + 6) @@ -6747,33 +7064,39 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta END DO END IF + CALL asm_mark(HC_ASM_DRAG, asm_t) ! ---- rotational line-end connections ---- ! A node-local generalized load on the end tangents: the 3x3 tangent block sits on the ! band diagonal (|gi - gj| <= 2 against KU_D = 11), so it cannot widen the band. The ! spring is a potential, so it enters the residual with the same sign as the internal ! force, and f . d == 0 keeps it off the stretch component by construction. IF (model%has_endconn) THEN - CALL add_endconn_band(R, Kb, q_cfg, 1, model%endconn_k(1), endconn_d0_eval(:, 1), & - want_residual, want_tangent, es, em2) - IF (es /= CD_ENDCONN_OK) THEN - ErrStat = CD_HCDYN_NOCONVERGE - ErrMsg = 'end connection at node 1: '//TRIM(em2) - RETURN + IF (n_lo == 1) THEN + CALL add_endconn_band(R, Kb, q_cfg, 1, model%endconn_k(1), endconn_d0_eval(:, 1), & + want_residual, want_tangent, es, em2) + IF (es /= CD_ENDCONN_OK) THEN + ErrStat = CD_HCDYN_NOCONVERGE + ErrMsg = 'end connection at node 1: '//TRIM(em2) + RETURN + END IF END IF - CALL add_endconn_band(R, Kb, q_cfg, ne + 1, model%endconn_k(2), endconn_d0_eval(:, 2), & - want_residual, want_tangent, es, em2) - IF (es /= CD_ENDCONN_OK) THEN - ErrStat = CD_HCDYN_NOCONVERGE - ErrMsg = 'end connection at final node: '//TRIM(em2) - RETURN + IF (n_hi == ne + 1) THEN + CALL add_endconn_band(R, Kb, q_cfg, ne + 1, model%endconn_k(2), endconn_d0_eval(:, 2), & + want_residual, want_tangent, es, em2) + IF (es /= CD_ENDCONN_OK) THEN + ErrStat = CD_HCDYN_NOCONVERGE + ErrMsg = 'end connection at final node: '//TRIM(em2) + RETURN + END IF END IF END IF + CALL asm_mark(HC_ASM_ENDCONN, asm_t) ! Froude-Krylov + fluid-inertia wave load (from the wave ACCELERATION), with the added-mass ! configuration's coefficients. An external load: R -= ffk, K -= fjq (geometric tangent chain; ! the wave-field spatial gradient is neglected in K -- see the element note). IF (model%has_wave .AND. model%has_am .AND. .NOT. tangent_only) THEN - DO e = 1, ne + DO e = e_lo, e_hi qe(1:3) = q_cfg(6*(e - 1) + 1:6*(e - 1) + 3); qe(4:6) = q_cfg(6*(e - 1) + 4:6*(e - 1) + 6) qe(7:9) = q_cfg(6*e + 1:6*e + 3); qe(10:12) = q_cfg(6*e + 4:6*e + 6) IF (model%wv_ncomp > 0) THEN @@ -6822,11 +7145,12 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta END DO END IF + CALL asm_mark(HC_ASM_WAVE, asm_t) ! HELD-FIELD Froude-Krylov + fluid-inertia load: the host-sampled fluid acceleration ! (per-element two-node average, frozen over the step) with the added-mass ! configuration's coefficients -- the held-mode twin of the wave FK block above. IF (model%has_held_field .AND. model%has_am .AND. .NOT. tangent_only) THEN - DO e = 1, ne + DO e = e_lo, e_hi qe(1:3) = q_cfg(6*(e - 1) + 1:6*(e - 1) + 3); qe(4:6) = q_cfg(6*(e - 1) + 4:6*(e - 1) + 6) qe(7:9) = q_cfg(6*e + 1:6*e + 3); qe(10:12) = q_cfg(6*e + 4:6*e + 6) IF (want_tangent) THEN @@ -6865,6 +7189,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta END DO END IF + CALL asm_mark(HC_ASM_HELD, asm_t) ! Condensed torsion: R <- R - M_t dTheta/dq and Kb <- Kb - M_t d2Theta/dq2 at q_cfg (the ! rank-one g g^T / C joins the solve by Sherman-Morrison; g is kept in tors_g). IF (model%torsion%active .AND. (want_residual .OR. want_tangent)) THEN @@ -6873,6 +7198,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ErrStat = es; ErrMsg = TRIM(em2); RETURN END IF END IF + CALL asm_mark(HC_ASM_TORSION, asm_t) END SUBROUTINE assemble_residual SUBROUTINE assemble_torsion(model, q_cfg, want_residual, want_tangent, ErrStat, ErrMsg) @@ -6963,12 +7289,15 @@ SUBROUTINE add_endconn_band(R, Kb, q_cfg, node, k_rot, d0, want_residual, want_t END IF END SUBROUTINE add_endconn_band - SUBROUTINE assemble_contact(model, q_cfg, v_cfg, want_residual, want_tangent, ErrStat, ErrMsg) - !! Assemble nodal contact directly into the model-owned residual bands. The - !! Jacobians are d(f_int-f_ext)/dq and d(f_int-f_ext)/dv. + SUBROUTINE assemble_contact(model, q_cfg, v_cfg, want_residual, want_tangent, R, n_lo, n_hi, ErrStat, ErrMsg) + !! Assemble the nodal contact of nodes n_lo..n_hi into the residual R (assemble_residual's; + !! the model's own residual is touched only through R) and the model-owned tangent bands. + !! The Jacobians are d(f_int-f_ext)/dq and d(f_int-f_ext)/dv. TYPE(CD_HermiteCableDynType), INTENT(INOUT) :: model REAL(wp), INTENT(IN) :: q_cfg(:), v_cfg(:) LOGICAL, INTENT(IN) :: want_residual, want_tangent + REAL(wp), INTENT(INOUT) :: R(:) + INTEGER, INTENT(IN) :: n_lo, n_hi INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg @@ -6990,7 +7319,7 @@ SUBROUTINE assemble_contact(model, q_cfg, v_cfg, want_residual, want_tangent, Er .NOT. ALLOCATED(model%contact_cn)) THEN ErrStat = CD_HCDYN_BADINPUT; ErrMsg = 'contact configuration is incomplete'; RETURN END IF - DO i = 1, model%nn + DO i = n_lo, n_hi ix = 6*i - 5; iy = ix + 1; iz = ix + 2 gx = CD_ZERO; gy = CD_ZERO IF (model%has_contact_bathymetry) THEN @@ -7036,7 +7365,7 @@ SUBROUTINE assemble_contact(model, q_cfg, v_cfg, want_residual, want_tangent, Er dnormal_dq = -dnormal_dgap*nvec dnormal_dv = dnormal_dvz*nvec - IF (want_residual) model%ws_R(ix:iz) = model%ws_R(ix:iz) - normal*nvec + IF (want_residual) R(ix:iz) = R(ix:iz) - normal*nvec IF (want_tangent) THEN DO jc = 1, 3 DO jr = 1, 3 @@ -7046,7 +7375,7 @@ SUBROUTINE assemble_contact(model, q_cfg, v_cfg, want_residual, want_tangent, Er END DO END IF IF (creased) THEN - IF (want_residual) model%ws_R(ix:iz) = model%ws_R(ix:iz) - fcrease + IF (want_residual) R(ix:iz) = R(ix:iz) - fcrease IF (want_tangent) THEN DO jc = 1, 3 DO jr = 1, 3 @@ -7072,7 +7401,7 @@ SUBROUTINE assemble_contact(model, q_cfg, v_cfg, want_residual, want_tangent, Er model%contact_mu*normal, ffr, dfr_dd, dfr_dc) mu_c = model%contact_mu END IF - IF (want_residual) model%ws_R(ix:iy) = model%ws_R(ix:iy) + ffr + IF (want_residual) R(ix:iy) = R(ix:iy) + ffr IF (want_tangent) THEN DO jr = 1, 2 CALL scatter_contact_band(model%ws_Kb, ix + jr - 1, ix, dfr_dd(jr, 1) + & @@ -8062,22 +8391,13 @@ SUBROUTINE assemble_added_mass(model, q_cfg, ErrStat, ErrMsg) INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg INTEGER :: e, es - REAL(wp) :: qe(12), Me(12, 12), wl_e + REAL(wp) :: Me(12, 12) CHARACTER(1024) :: em2 ErrStat = CD_HCDYN_OK; ErrMsg = '' model%ws_Mamb = CD_ZERO IF (.NOT. model%has_am) RETURN DO e = 1, model%ne - qe(1:3) = q_cfg(6*(e - 1) + 1:6*(e - 1) + 3); qe(4:6) = q_cfg(6*(e - 1) + 4:6*(e - 1) + 6) - qe(7:9) = q_cfg(6*e + 1:6*e + 3); qe(10:12) = q_cfg(6*e + 4:6*e + 6) - ! Held-field mode: the inertia matrix must wet against the SAME free surface the - ! drag and Froude-Krylov use (the host-sampled elevation, node-averaged per - ! element) -- against the init-time still-water am_wl a surface-piercing element - ! would carry the wrong added mass exactly when the host drives waves. - wl_e = model%am_wl - IF (model%has_held_field) wl_e = 0.5_wp*(model%hf_wl(e) + model%hf_wl(e + 1)) - CALL CD_HermiteCable_AddedMass_Element(qe, model%l0(e), wl_e, model%am_rho, & - model%am_diam(e), model%am_can(e), model%am_cat(e), Me, es, em2) + CALL added_mass_element(model, q_cfg, e, Me, es, em2) IF (es /= CD_HCDYN_OK) THEN ErrStat = es; ErrMsg = TRIM(em2); RETURN END IF @@ -8092,6 +8412,28 @@ SUBROUTINE assemble_added_mass(model, q_cfg, ErrStat, ErrMsg) END IF END SUBROUTINE assemble_added_mass + SUBROUTINE added_mass_element(model, q_cfg, e, Me, ErrStat, ErrMsg) + !! Consistent added-mass matrix of element e at configuration q_cfg (assemble_added_mass, + !! node_reaction). + TYPE(CD_HermiteCableDynType), INTENT(IN) :: model + REAL(wp), INTENT(IN) :: q_cfg(:) + INTEGER, INTENT(IN) :: e + REAL(wp), INTENT(OUT) :: Me(12, 12) + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + REAL(wp) :: qe(12), wl_e + qe(1:3) = q_cfg(6*(e - 1) + 1:6*(e - 1) + 3); qe(4:6) = q_cfg(6*(e - 1) + 4:6*(e - 1) + 6) + qe(7:9) = q_cfg(6*e + 1:6*e + 3); qe(10:12) = q_cfg(6*e + 4:6*e + 6) + ! Held-field mode: the inertia matrix must wet against the SAME free surface the + ! drag and Froude-Krylov use (the host-sampled elevation, node-averaged per + ! element) -- against the init-time still-water am_wl a surface-piercing element + ! would carry the wrong added mass exactly when the host drives waves. + wl_e = model%am_wl + IF (model%has_held_field) wl_e = 0.5_wp*(model%hf_wl(e) + model%hf_wl(e + 1)) + CALL CD_HermiteCable_AddedMass_Element(qe, model%l0(e), wl_e, model%am_rho, & + model%am_diam(e), model%am_can(e), model%am_cat(e), Me, ErrStat, ErrMsg) + END SUBROUTINE added_mass_element + SUBROUTINE build_rigid_bases(model, directions, bases, ErrStat, ErrMsg) TYPE(CD_HermiteCableDynType), INTENT(IN) :: model REAL(wp), INTENT(IN) :: directions(3, 2) diff --git a/src/CableDyn_LoadPredictor.f90 b/src/CableDyn_LoadPredictor.f90 new file mode 100644 index 0000000..2638fed --- /dev/null +++ b/src/CableDyn_LoadPredictor.f90 @@ -0,0 +1,45 @@ +! File: src/CableDyn_LoadPredictor.f90 +! SPDX-License-Identifier: Apache-2.0 +! Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +MODULE CableDyn_LoadPredictor + !! Coupled loads between supercycled mooring solves. When the mooring steps on dtM, a whole + !! number of host steps, the host still asks for the loads at every one of its steps. Held at + !! the last committed solve they lag the platform by up to dtM; extrapolated linearly from + !! the last two committed solves, + !! + !! F(t) = F_c + tau (F_c - F_p), tau = (t - t_c)/dtM in [0, 1], + !! + !! they follow it to second order: the error is of order dtM^2 F'' instead of dtM F'. The + !! extrapolation is used only when the two solves are exactly one dtM apart (consecutive + !! commits), so the first interval of a run, or after a gap, keeps the held loads. + USE CableDyn_Precision, ONLY: wp + IMPLICIT NONE + PRIVATE + PUBLIC :: CD_Predictor_Ready, CD_Predictor_Fraction, CD_Predict_Loads + +CONTAINS + + PURE LOGICAL FUNCTION CD_Predictor_Ready(t_commit, t_prev, dt) RESULT(ready) + !! The previous committed solve (time t_prev; negative when there is none) is the one + !! immediately before the last (time t_commit): exactly dt earlier, within 1e-6 dt. + REAL(wp), INTENT(IN) :: t_commit, t_prev, dt + ready = .FALSE. + IF (.NOT. (dt > 0.0_wp .AND. t_prev >= 0.0_wp)) RETURN + ready = ABS((t_commit - t_prev) - dt) <= 1.0e-6_wp*dt + END FUNCTION CD_Predictor_Ready + + PURE REAL(wp) FUNCTION CD_Predictor_Fraction(t, t_commit, dt) RESULT(tau) + !! The fraction of the mooring step elapsed since the last committed solve, clamped to + !! [0, 1] (no extrapolation beyond one step, none backwards). + REAL(wp), INTENT(IN) :: t, t_commit, dt + tau = MAX(0.0_wp, MIN(1.0_wp, (t - t_commit)/dt)) + END FUNCTION CD_Predictor_Fraction + + PURE SUBROUTINE CD_Predict_Loads(f_last, f_prev, tau, f) + !! F = F_c + tau (F_c - F_p), element by element. + REAL(wp), INTENT(IN) :: f_last(:, :), f_prev(:, :), tau + REAL(wp), INTENT(OUT) :: f(:, :) + f = f_last + tau*(f_last - f_prev) + END SUBROUTINE CD_Predict_Loads + +END MODULE CableDyn_LoadPredictor diff --git a/src/CableDyn_OpenFAST_Aggregate.f90 b/src/CableDyn_OpenFAST_Aggregate.f90 index e60e5ab..c0d6f78 100644 --- a/src/CableDyn_OpenFAST_Aggregate.f90 +++ b/src/CableDyn_OpenFAST_Aggregate.f90 @@ -141,6 +141,9 @@ MODULE CableDyn_OpenFAST_Aggregate ! cable march, environmental loading, and output channels active but zeros ! its moving-point load and moment for a controlled one-way comparison. LOGICAL :: cable_load_feedback = .TRUE. + ! Deck OPTION predictLoads: the OpenFAST shell extrapolates the coupled loads between + ! supercycled solves (TRUE) or holds the last solve's (FALSE) + LOGICAL :: predict_loads = .TRUE. ! Deck OPTION maxStrain: the per-step plausibility bound on the EI=0 system lines. REAL(wp) :: max_strain = 0.5_wp INTEGER :: ncp_sys = 0 ! moving points owned by sys @@ -330,6 +333,7 @@ SUBROUTINE CD_AGG_Init_From_Deck(self, deck_path, dt, ErrStat, ErrMsg, env_gravi candidate%host_wave_enabled = parts%host_wave_enabled candidate%host_current_enabled = parts%host_current_enabled candidate%cable_load_feedback = parts%cable_load_feedback + candidate%predict_loads = parts%predict_loads candidate%max_strain = parts%max_strain IF (ALLOCATED(parts%host_current_profile_z)) THEN candidate%host_current_profile_z = parts%host_current_profile_z @@ -561,7 +565,7 @@ SUBROUTINE CD_AGG_TurbineOfMoving(self, turbine_of, ErrStat, ErrMsg) END SUBROUTINE CD_AGG_TurbineOfMoving SUBROUTINE CD_AGG_GetMovingPointMesh(self, position, velocity, acceleration, load, ErrStat, ErrMsg, & - orientation, moment, angular_velocity, angular_acceleration) + orientation, moment, angular_velocity, angular_acceleration, cable_loads) !! Copy the moving-point state and loads out. System columns come from !! CD_FMF_GetMovingPointMesh; each cable column carries its coupled-node !! position/velocity/acceleration (committed state) and its fairlead reaction from @@ -581,11 +585,19 @@ SUBROUTINE CD_AGG_GetMovingPointMesh(self, position, velocity, acceleration, loa CHARACTER(*), INTENT(OUT) :: ErrMsg REAL(wp), INTENT(OUT), OPTIONAL :: orientation(:, :, :), moment(:, :) REAL(wp), INTENT(OUT), OPTIONAL :: angular_velocity(:, :), angular_acceleration(:, :) + ! .FALSE. skips the reactions of the finite-EI cables (one full residual assembly each) and + ! returns their load and moment columns as zero: for a caller that wants only the + ! kinematics, such as the OpenFAST CalcOutput saving the committed kinematics. The + ! reactions write only the cable's scratch, so the other outputs are unchanged. + LOGICAL, INTENT(IN), OPTIONAL :: cable_loads INTEGER :: c, col, es REAL(wp) :: y_fair(3) + LOGICAL :: want_cable_loads CHARACTER(1024) :: em + want_cable_loads = .TRUE. + IF (PRESENT(cable_loads)) want_cable_loads = cable_loads position = CD_ZERO velocity = CD_ZERO acceleration = CD_ZERO @@ -664,6 +676,7 @@ SUBROUTINE CD_AGG_GetMovingPointMesh(self, position, velocity, acceleration, loa CALL map_hfmf_status(es, em, ErrStat, ErrMsg) RETURN END IF + IF (.NOT. want_cable_loads) CYCLE IF (PRESENT(moment)) THEN CALL CD_HFMF_CalcOutput(self%cables(c), y_fair, es, em, moment(:, col)) ELSE @@ -2040,7 +2053,8 @@ SUBROUTINE CD_AGG_GetInitLine(self, i, line_id, tension, force, inclination, dec note) !! Return one line's unconditional converged-static fairlead result in deck order, and !! (optional note) its static-initialisation notes, one per text line: those of a finite-EI - !! cable, or the fold note of an EI = 0 line ('' if none). + !! cable or the fold note of an EI = 0 line, the static strain note and the dtM advisory + !! ('' if none). TYPE(CD_AGG_ModuleType), INTENT(IN) :: self INTEGER, INTENT(IN) :: i INTEGER, INTENT(OUT) :: line_id diff --git a/src/CableDyn_OpenFAST_HermiteFMF.f90 b/src/CableDyn_OpenFAST_HermiteFMF.f90 index 3fde501..ee0a94f 100644 --- a/src/CableDyn_OpenFAST_HermiteFMF.f90 +++ b/src/CableDyn_OpenFAST_HermiteFMF.f90 @@ -1694,6 +1694,7 @@ SUBROUTINE CD_HFMF_UnpackMirror(self, buf, t, ErrStat, ErrMsg) END IF ! the force-blend committed force reloads with the state it was evaluated at self%line%fc_valid = .FALSE. + self%line%fc_clears = self%line%fc_clears + 1 IF (self%line%force_blend) THEN IF (buf(k + 1) > 0.5_wp) THEN self%line%fc_R = buf(k + 2:k + 1 + self%line%ndof) diff --git a/src/CableDyn_Profile.f90 b/src/CableDyn_Profile.f90 index c40fcb9..d0fe6db 100644 --- a/src/CableDyn_Profile.f90 +++ b/src/CableDyn_Profile.f90 @@ -11,17 +11,23 @@ MODULE CableDyn_Profile !! a run that stops with an error has its time up to the stop in the phase it stopped in. !! Each time march enters its fluid update, its step, its checks, its output and its !! progress report in turn; CD_Profile_Step counts the committed steps. CD_Step_Model counts - !! the EI = 0 line steps and their Newton iterations. Unarmed, every entry point returns after - !! one test, so a run that does not ask for the profile, and a host that loads the library, - !! are not affected. The state is process-global and serves one run: the phase marks are - !! taken on the driver's thread, and the line-step counters are updated atomically because - !! lines step concurrently. + !! the EI = 0 line steps and their Newton iterations, and the step of an EI = 0 line times its + !! kernels between CD_Profile_Kernel_Begin and CD_Profile_Kernel_End: the residual-only + !! evaluations, the evaluations that also assemble the effective tangent, the factorisations of + !! that tangent and the back-substitutions with it (CD_Profile_Kernels reads them; their times + !! are summed over the lines, so they exceed the wall time when lines step concurrently). + !! Unarmed, every entry point returns after one test, so a run that does not ask for the + !! profile, and a host that loads the library, are not affected. The state is process-global + !! and serves one run: the phase marks are taken on the driver's thread, and the line-step and + !! kernel counters are updated atomically because lines step concurrently. The OpenFAST module + !! arms it with its own profiler and disarms it (CD_Profile_Disarm) when the host does not ask. USE, INTRINSIC :: ISO_FORTRAN_ENV, ONLY: int64 USE CableDyn_Precision, ONLY: wp IMPLICIT NONE PRIVATE - PUBLIC :: CD_Profile_Requested, CD_Profile_Arm, CD_Profile_Armed, CD_Profile_Enter, CD_Profile_Flush, & - CD_Profile_Step, CD_Profile_Count_Line_Step, CD_Profile_Seconds, CD_Profile_Counts + PUBLIC :: CD_Profile_Requested, CD_Profile_Arm, CD_Profile_Disarm, CD_Profile_Armed, CD_Profile_Enter, & + CD_Profile_Flush, CD_Profile_Step, CD_Profile_Count_Line_Step, CD_Profile_Seconds, CD_Profile_Counts, & + CD_Profile_Kernel_Begin, CD_Profile_Kernel_End, CD_Profile_Kernels INTEGER, PARAMETER, PUBLIC :: CD_PROF_INPUT = 1 ! deck parsing and auxiliary input files INTEGER, PARAMETER, PUBLIC :: CD_PROF_INIT = 2 ! model build, static equilibrium, initial outputs @@ -33,12 +39,20 @@ MODULE CableDyn_Profile INTEGER, PARAMETER, PUBLIC :: CD_PROF_FINAL = 8 ! after the march: closing files, range files INTEGER, PARAMETER, PUBLIC :: CD_PROF_NPHASE = 8 + ! Kernels of the EI = 0 line step (CD_Cable_Gen_Alpha_Step) + INTEGER, PARAMETER, PUBLIC :: CD_PROF_K_RESIDUAL = 1 ! residual-only evaluation + INTEGER, PARAMETER, PUBLIC :: CD_PROF_K_TANGENT = 2 ! residual and effective-tangent evaluation + INTEGER, PARAMETER, PUBLIC :: CD_PROF_K_FACTOR = 3 ! LU factorisation of the effective tangent + INTEGER, PARAMETER, PUBLIC :: CD_PROF_K_SOLVE = 4 ! back-substitution with a factored tangent + INTEGER, PARAMETER, PUBLIC :: CD_PROF_NKERNEL = 4 + LOGICAL, SAVE :: armed = .FALSE. INTEGER, SAVE :: current = CD_PROF_INPUT INTEGER(int64), SAVE :: rate = 0_int64, last = 0_int64 INTEGER(int64), SAVE :: ticks(CD_PROF_NPHASE) = 0_int64 INTEGER(int64), SAVE :: n_march_steps = 0_int64, n_line_steps = 0_int64, n_line_iter = 0_int64 INTEGER, SAVE :: max_line_iter = 0 + INTEGER(int64), SAVE :: k_ticks(CD_PROF_NKERNEL) = 0_int64, k_calls(CD_PROF_NKERNEL) = 0_int64 CONTAINS @@ -58,11 +72,18 @@ SUBROUTINE CD_Profile_Arm() n_line_steps = 0_int64 n_line_iter = 0_int64 max_line_iter = 0 + k_ticks = 0_int64 + k_calls = 0_int64 current = CD_PROF_INPUT CALL SYSTEM_CLOCK(last, rate) armed = rate > 0_int64 END SUBROUTINE CD_Profile_Arm + SUBROUTINE CD_Profile_Disarm() + !! Stop recording; the counts and times so far stay readable until the next CD_Profile_Arm. + armed = .FALSE. + END SUBROUTINE CD_Profile_Disarm + LOGICAL FUNCTION CD_Profile_Armed() RESULT(is_armed) is_armed = armed END FUNCTION CD_Profile_Armed @@ -107,6 +128,39 @@ SUBROUTINE CD_Profile_Count_Line_Step(n_iter) max_line_iter = MAX(max_line_iter, n_iter) END SUBROUTINE CD_Profile_Count_Line_Step + SUBROUTINE CD_Profile_Kernel_Begin(start) + !! The clock at the start of a kernel (zero when the profile is not armed). + INTEGER(int64), INTENT(OUT) :: start + start = 0_int64 + IF (.NOT. armed) RETURN + CALL SYSTEM_CLOCK(start) + END SUBROUTINE CD_Profile_Kernel_Begin + + SUBROUTINE CD_Profile_Kernel_End(kernel, start) + !! Count one call of kernel (a CD_PROF_K_* value) and add the time since start, taken by + !! CD_Profile_Kernel_Begin. Lines step concurrently, so the updates are atomic. + INTEGER, INTENT(IN) :: kernel + INTEGER(int64), INTENT(IN) :: start + INTEGER(int64) :: now + IF (.NOT. armed) RETURN + ! a kernel that began before the profile was armed has no start time + IF (start == 0_int64 .OR. kernel < 1 .OR. kernel > CD_PROF_NKERNEL) RETURN + CALL SYSTEM_CLOCK(now) + !$OMP ATOMIC UPDATE + k_calls(kernel) = k_calls(kernel) + 1_int64 + !$OMP ATOMIC UPDATE + k_ticks(kernel) = k_ticks(kernel) + MAX(0_int64, now - start) + END SUBROUTINE CD_Profile_Kernel_End + + SUBROUTINE CD_Profile_Kernels(calls, seconds) + !! Calls and summed time [s] of each CD_PROF_K_* kernel since the profile was armed. + INTEGER(int64), INTENT(OUT) :: calls(CD_PROF_NKERNEL) + REAL(wp), INTENT(OUT) :: seconds(CD_PROF_NKERNEL) + calls = k_calls + seconds = 0.0_wp + IF (rate > 0_int64) seconds = REAL(k_ticks, wp)/REAL(rate, wp) + END SUBROUTINE CD_Profile_Kernels + REAL(wp) FUNCTION CD_Profile_Seconds(phase) RESULT(seconds) !! Wall time attributed to phase so far [s]; zero when the profile is not armed. INTEGER, INTENT(IN) :: phase diff --git a/src/cabledyn_cpu_guard.c b/src/cabledyn_cpu_guard.c new file mode 100644 index 0000000..d3d98be --- /dev/null +++ b/src/cabledyn_cpu_guard.c @@ -0,0 +1,165 @@ +/* File: src/cabledyn_cpu_guard.c + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University + * + * Start-up check of the processor for builds that target x86-64-v3 (CABLEDYN_TARGET_AVX2). + * + * Such a build lets the Fortran compiler and its vector maths use AVX2, FMA, BMI1/BMI2, LZCNT, + * MOVBE and F16C anywhere, so on an older processor it would stop at its first such + * instruction with an illegal-instruction fault and no explanation. This file is compiled without those + * instructions; when CABLEDYN_REQUIRE_X86_64_V3 is defined it registers a load-time + * initializer (a CRT initializer with MSVC-style toolchains, a constructor with GNU-style + * ones) that runs before any Fortran code of the executable or shared library. On a + * processor (or operating system) without the x86-64-v3 features it writes which are + * missing to standard error and ends the process with status 3. + * + * The message is written with the operating system's own calls and no C library string or + * stdio function: in the Windows GNU build those names can resolve to the same exports of + * openblas.dll, which would then be imported at load time (CABLEDYN_BLAS_RUNTIME_LOAD loads + * it on first use instead). + * + * CABLEDYN_CPU_GUARD_SIMULATE, when set to a non-empty value in an armed build, is reported + * as a missing feature, so the release gates and the tests can exercise the stop on any + * processor. + */ + +/* Writes the missing x86-64-v3 features, space-separated, to buf (empty when none is + * missing or the target is not x86-64) and returns their number. */ +int cabledyn_cpu_missing_features(char *buf, int len); +/* 1 when this build carries the start-up check, 0 otherwise. */ +int cabledyn_cpu_guard_armed(void); + +/* Appends text (and a separating space when buf is not empty) if it fits with the + * terminator; returns 1 when it did. */ +static int append(char *buf, int len, const char *text, int separate) { + int used = 0, n = 0, i; + if (!buf || len <= 0) return 0; + while (used < len && buf[used]) ++used; + while (text[n]) ++n; + if (separate && used > 0) { + if (used + 1 + n + 1 > len) return 0; + buf[used++] = ' '; + } else if (used + n + 1 > len) { + return 0; + } + for (i = 0; i < n; ++i) buf[used + i] = text[i]; + buf[used + n] = '\0'; + return 1; +} + +#if defined(_M_X64) || defined(__x86_64__) +#if defined(_MSC_VER) +#include +static void cpuid_count(unsigned leaf, unsigned sub, unsigned r[4]) { + int v[4]; + __cpuidex(v, (int)leaf, (int)sub); + r[0] = (unsigned)v[0]; r[1] = (unsigned)v[1]; r[2] = (unsigned)v[2]; r[3] = (unsigned)v[3]; +} +static unsigned long long read_xcr0(void) { return _xgetbv(0); } +#else +#include +static void cpuid_count(unsigned leaf, unsigned sub, unsigned r[4]) { + __cpuid_count(leaf, sub, r[0], r[1], r[2], r[3]); +} +static unsigned long long read_xcr0(void) { + unsigned lo, hi; + __asm__ volatile("xgetbv" : "=a"(lo), "=d"(hi) : "c"(0)); + return ((unsigned long long)hi << 32) | lo; +} +#endif + +static void note(char *buf, int len, int *count, const char *name) { + ++*count; + append(buf, len, name, 1); +} + +int cabledyn_cpu_missing_features(char *buf, int len) { + unsigned r0[4], r1[4], r7[4] = {0, 0, 0, 0}, rx[4] = {0, 0, 0, 0}; + int count = 0, os_avx = 0; + if (buf && len > 0) buf[0] = '\0'; + cpuid_count(0, 0, r0); + cpuid_count(1, 0, r1); + if (r0[0] >= 7) cpuid_count(7, 0, r7); + cpuid_count(0x80000000u, 0, rx); + if (rx[0] >= 0x80000001u) cpuid_count(0x80000001u, 0, rx); + else rx[2] = 0; + /* AVX state enabled by the operating system: OSXSAVE, then XMM and YMM in XCR0 */ + if (r1[2] & (1u << 27)) os_avx = (read_xcr0() & 6u) == 6u; + if (!(r1[2] & (1u << 28))) note(buf, len, &count, "AVX"); + if (!(r7[1] & (1u << 5))) note(buf, len, &count, "AVX2"); + if (!(r1[2] & (1u << 12))) note(buf, len, &count, "FMA"); + if (!(r7[1] & (1u << 3))) note(buf, len, &count, "BMI1"); + if (!(r7[1] & (1u << 8))) note(buf, len, &count, "BMI2"); + if (!(rx[2] & (1u << 5))) note(buf, len, &count, "LZCNT"); + if (!(r1[2] & (1u << 22))) note(buf, len, &count, "MOVBE"); + if (!(r1[2] & (1u << 29))) note(buf, len, &count, "F16C"); + if (!os_avx) note(buf, len, &count, "OS-AVX-state"); + return count; +} +#else +int cabledyn_cpu_missing_features(char *buf, int len) { + if (buf && len > 0) buf[0] = '\0'; + return 0; +} +#endif + +#if defined(CABLEDYN_REQUIRE_X86_64_V3) +int cabledyn_cpu_guard_armed(void) { return 1; } + +#if defined(_WIN32) +#include +static int simulated_missing(char *buf, int len) { + DWORD n = GetEnvironmentVariableA("CABLEDYN_CPU_GUARD_SIMULATE", buf, (DWORD)len); + return n > 0 && n < (DWORD)len; +} +static void stop(const char *text, int n) { + DWORD written = 0; + HANDLE err = GetStdHandle(STD_ERROR_HANDLE); + if (err != NULL && err != INVALID_HANDLE_VALUE) WriteFile(err, text, (DWORD)n, &written, NULL); + ExitProcess(3); +} +#else +#include +#include +static int simulated_missing(char *buf, int len) { + const char *value = getenv("CABLEDYN_CPU_GUARD_SIMULATE"); + if (!value || !value[0]) return 0; + buf[0] = '\0'; + return append(buf, len, value, 0); +} +static void stop(const char *text, int n) { + ssize_t ignored = write(STDERR_FILENO, text, (size_t)n); + (void)ignored; + _exit(3); +} +#endif + +static void cabledyn_cpu_guard_check(void) { + char missing[128], message[512]; + int n = 0; + if (!simulated_missing(missing, (int)sizeof missing) && + cabledyn_cpu_missing_features(missing, (int)sizeof missing) == 0) + return; + message[0] = '\0'; + append(message, (int)sizeof message, + "CableDyn: this build needs an x86-64 processor with AVX2 (x86-64-v3: Intel Haswell " + "or AMD Excavator and newer). This computer lacks: ", 0); + append(message, (int)sizeof message, missing, 0); + append(message, (int)sizeof message, + ".\nCableDyn: build CableDyn from source without CABLEDYN_TARGET_AVX2 to run here.\n", 0); + while (message[n]) ++n; + stop(message, n); +} + +#if defined(_MSC_VER) +/* A CRT initializer: runs during C run-time start-up of the executable or DLL, before + * main and before any Fortran code. The object is linked wherever cabledyn_cpu_guard_armed + * is referenced (the driver, the OpenFAST module, the shared library). */ +#pragma section(".CRT$XCU", read) +__declspec(allocate(".CRT$XCU")) void (*cabledyn_cpu_guard_init)(void) = cabledyn_cpu_guard_check; +#else +__attribute__((constructor)) static void cabledyn_cpu_guard_ctor(void) { cabledyn_cpu_guard_check(); } +#endif +#else +int cabledyn_cpu_guard_armed(void) { return 0; } +#endif diff --git a/src/openfast/CableDyn_OF.f90 b/src/openfast/CableDyn_OF.f90 index 694d40c..c963a00 100644 --- a/src/openfast/CableDyn_OF.f90 +++ b/src/openfast/CableDyn_OF.f90 @@ -95,8 +95,12 @@ MODULE CableDyn USE CableDyn_HermiteCableDynamic, ONLY: CD_HermiteCable_Dyn_Reset_Profile, & CD_HermiteCable_Dyn_Disable_Profile, & CD_HermiteCable_Dyn_Get_Profile, & + CD_HermiteCable_Dyn_Get_Assembly_Profile, HC_ASM_NSEC, HC_ASM_KEYS, & CD_HermiteCable_Dyn_Recovery_Count, & CD_HermiteCable_Dyn_Recovery_Reset + USE CableDyn_LoadPredictor, ONLY: CD_Predictor_Ready, CD_Predictor_Fraction, CD_Predict_Loads + USE CableDyn_Profile, ONLY: CD_Profile_Arm, CD_Profile_Disarm, CD_Profile_Kernels, CD_PROF_K_RESIDUAL, & + CD_PROF_K_TANGENT, CD_PROF_K_FACTOR, CD_PROF_K_SOLVE, CD_PROF_NKERNEL USE CableDyn_System, ONLY: CD_System_NLines, CD_System_Line_NDOF, CD_System_Line_NElem, & CD_Get_System_DynamicPoint_States, CD_Set_System_DynamicPoint_States, & CD_System_NSystemCoupledDOF, CD_Get_System_CoupledMotion, & @@ -117,7 +121,16 @@ MODULE CableDyn PRIVATE - TYPE(ProgDesc), PARAMETER :: CableDyn_ProgDesc = ProgDesc( 'CableDyn', 'v0.2.0', '2026-10-20' ) + TYPE(ProgDesc), PARAMETER :: CableDyn_ProgDesc = ProgDesc( 'CableDyn', 'v0.3.0', '2026-10-23' ) + 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 + ! check in a static link of the core into the OpenFAST executable + FUNCTION cd_cpu_guard_armed() BIND(C, name='cabledyn_cpu_guard_armed') RESULT(armed) + USE, INTRINSIC :: ISO_C_BINDING, ONLY: C_INT + INTEGER(C_INT) :: armed + END FUNCTION cd_cpu_guard_armed + END INTERFACE !> Module-level instance registry: the CableDyn solver objects cannot live inside the !> registry-generated types, so each initialized module holds an integer handle @@ -133,9 +146,10 @@ MODULE CableDyn ! dt_moor = nsuper * (glue DT), ONE step per nsuper glue steps, instead of being ! chained to the turbine's small DT. The deck's OPTIONS dtM sets it (clamped to at ! least the glue DT); absent a deck dtM the mooring solves every glue step. Between - ! mooring solves CD_CalcOutput returns the last - ! committed load state as a zero-order hold; feeding instantaneous endpoints into a - ! lagged mooring interior creates artificial stiff-EA load chatter. + ! mooring solves CD_CalcOutput returns the last committed load state, extrapolated from + ! the solve before it (deck OPTION predictLoads, the default) or held; feeding + ! instantaneous endpoints into a lagged mooring interior creates artificial stiff-EA + ! load chatter. INTEGER(IntKi) :: nsuper = 1 ! glue steps per mooring step REAL(wp) :: dt_moor = 0.0_wp ! the mooring step frozen into the aggregate LOGICAL :: active = .FALSE. @@ -154,6 +168,12 @@ MODULE CableDyn LOGICAL :: loads_cached = .FALSE. INTEGER(IntKi) :: loads_at_n = -1 REAL(wp), ALLOCATABLE :: ws_chan(:) + ! Load predictor (nsuper > 1 with predictLoads; CableDyn_LoadPredictor): the coupled + ! loads and moments of the committed solve before the last and its time (pred_t < 0: + ! none), shifted in by CD_UpdateStates when a mooring step starts and mirrored into the + ! OtherState for restarts; ws_pfld/ws_pmom hold the extrapolated loads CalcOutput serves. + REAL(wp), ALLOCATABLE :: pred_fld(:,:), pred_mom(:,:), ws_pfld(:,:), ws_pmom(:,:) + REAL(DbKi) :: pred_t = -1.0_DbKi ! Persistent interpolated-input container: CD_UpdateStates interpolates the coupled ! kinematics to the mooring interval end; the mesh copy is built ONCE and reused ! (ExtrapInterp overwrites the fields in place), never created/destroyed per step. @@ -214,6 +234,13 @@ MODULE CableDyn REAL(wp) :: prof_t_wave = 0.0_wp, prof_t_step = 0.0_wp REAL(wp) :: prof_t_calcout = 0.0_wp, prof_t_loads = 0.0_wp REAL(wp) :: prof_t_native = 0.0_wp + ! parts of calcout_s (the overlay and restore of the current kinematics, at nsuper = 1, and + ! the channels of a fresh evaluation) and of wave_s (the SeaState node queries with the + ! current decomposition, the grid checks, the hand-over of the fields to the aggregate) + REAL(wp) :: prof_t_overlay = 0.0_wp, prof_t_channels = 0.0_wp + REAL(wp) :: prof_t_restore = 0.0_wp + REAL(wp) :: prof_t_wave_kin = 0.0_wp, prof_t_wave_check = 0.0_wp + REAL(wp) :: prof_t_wave_set = 0.0_wp END TYPE CD_OF_Instance TYPE(CD_OF_Instance), ALLOCATABLE, SAVE :: Inst(:) @@ -494,6 +521,7 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er ! Disarm before any static/dynamic initialization work. This also covers an ! initialization that later fails and never reaches the live-instance setup. CALL CD_HermiteCable_Dyn_Disable_Profile() + CALL CD_Profile_Disarm() END IF ! ------------------------------------------------------------------------------ @@ -509,6 +537,7 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er CALL WrScr( ' Author Prof. Jae Hoon Seo, Inha University. License Apache-2.0.' ) CALL WrScr( ' CompMooring = 5: mooring and dynamic power-cable loads are computed by CableDyn,' ) CALL WrScr( ' an OpenFAST mooring module alongside MoorDyn (CompMooring = 3) in this binary.' ) + IF (cd_cpu_guard_armed() /= 0) CALL WrScr( ' CableDyn build: x86-64-v3 (AVX2).' ) ! ------------------------------------------------------------------------------ ! Scope guards (named fail-closed messages; see the module header) @@ -629,13 +658,6 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er dt_moor = REAL(nsuper, wp)*dtC CALL WrScr( ' CableDyn time step dtM = '//TRIM(Num2LStr(dt_moor))//' s ('//TRIM(Num2LStr(nsuper))// & ' x glue DT'//TRIM(MERGE('; deck dtM ', '; the default', has_dtm))//')' ) - IF (nsuper > 1_IntKi) THEN - CALL WrScr( ' CableDyn: coupled loads are held between dtM solves and reach the platform about dtM/2 '// & - 'late, which matters for stiff (taut or tension-leg) moorings and for tension statistics; '// & - 'the separate .CD.out records committed CableDyn samples only. Verify dtM convergence for '// & - 'fatigue, snap, contact, or local cable-response calculations; omit dtM or set it to DT for '// & - 'glue-rate solves.' ) - END IF ! The run must END on a mooring boundary: with supercycling, a trailing partial ! interval would take only skipped glue steps, leaving the committed mooring state -- ! and the held (zero-order-hold) loads -- up to dtM stale at TMax, silently. Fail @@ -782,6 +804,20 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er Inst(id)%nsuper = nsuper Inst(id)%dt_moor = dt_moor Inst(id)%use_extfluid = use_extf + IF (nsuper > 1_IntKi) THEN + IF (Inst(id)%agg%predict_loads) THEN + CALL WrScr( ' CableDyn: between dtM solves the coupled loads are extrapolated from the last two '// & + 'solves (OPTIONS predictLoads); the output channels and the .CD.out record the committed '// & + 'samples. Verify dtM convergence for fatigue, snap, contact, or local cable-response '// & + 'calculations; omit dtM or set it to DT for glue-rate solves.' ) + ELSE + CALL WrScr( ' CableDyn: coupled loads are held between dtM solves (OPTIONS predictLoads False) and '// & + 'reach the platform about dtM/2 late, which matters for stiff (taut or tension-leg) '// & + 'moorings and for tension statistics; the separate .CD.out records committed CableDyn '// & + 'samples only. Verify dtM convergence for fatigue, snap, contact, or local cable-response '// & + '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 @@ -822,6 +858,7 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er ! state. Seeding after the static solve matches the stock-MoorDyn convention: ! the IC is the still-water equilibrium, wave kinematics act from t = 0. CALL sample_wavefield( Inst(id), p, m, 0.0_DbKi, ErrStat2, ErrMsg2, at_init=.TRUE. ) + IF (ErrStat2 < AbortErrLev) CALL save_held_fluid( Inst(id), other ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat >= AbortErrLev) RETURN END IF @@ -1107,6 +1144,7 @@ SUBROUTINE CD_Init(InitInp, u, p, x, xd, z, other, y, m, DTcoupling, InitOut, Er Inst(id)%t_commit = 0.0_DbKi ! committed at the static IC (t = 0) other%restart_serial = g_serial other%t_commit = Inst(id)%t_commit + other%pred_t = -1.0_DbKi ! no previous commit yet (load predictor) ! ------------------------------------------------------------------------------ ! Output channels: the deck OUTPUTS list in the OrcaFlex vocabulary, emitted through @@ -1288,6 +1326,7 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) 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 :: restored ! held fluid fields restored from the checkpoint CHARACTER(ErrMsgLen) :: em INTEGER(IntKi) :: ErrStat2 CHARACTER(ErrMsgLen) :: ErrMsg2 @@ -1466,14 +1505,20 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) 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), so sampling here reproduces those fields on the restored - ! positions. Without this the first post-restart load evaluations would see still - ! water and ring for a few steps. + ! 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 + ! 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; + ! re-sampling at the restored, end-of-step positions remains only as a defensive + ! fallback (it matches only at dtM = DT, where the next solve re-samples before any load + ! is served). Without either the first post-restart load evaluations would see still + ! water and ring for a few steps. (OpenFAST checkpoints are not versioned: one written by + ! an earlier CableDyn, without these fields, cannot be restarted with this one.) Inst(id)%active = .TRUE. ! sample_wavefield reads through the live instance IF (use_extf) THEN - CALL sample_wavefield( Inst(id), p, m, t, ErrStat2, ErrMsg2 ) + 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 ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat >= AbortErrLev) THEN CALL release_instance( id ) @@ -1518,6 +1563,20 @@ SUBROUTINE rebuild_from_checkpoint( t, u1, p, x, other, m, ErrStat, ErrMsg ) Inst(id)%last_n = -1_IntKi Inst(id)%last_solve_n = -1_IntKi Inst(id)%loads_cached = .FALSE. + ! 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 + IF (ALLOCATED(other%pred_loads)) THEN + IF (SIZE(other%pred_loads) == 6*ncp) THEN + Inst(id)%pred_fld = RESHAPE(REAL(other%pred_loads(1:3*ncp), wp), [3, ncp]) + Inst(id)%pred_mom = RESHAPE(REAL(other%pred_loads(3*ncp + 1:6*ncp), wp), [3, ncp]) + ELSE + Inst(id)%pred_t = -1.0_DbKi + END IF + ELSE + Inst(id)%pred_t = -1.0_DbKi + END IF + END IF m%CDInst = id CALL WrScr( ' CableDyn: rebuilt the solver instance from the checkpoint at t = '// & TRIM(Num2LStr(t))//' s ('//TRIM(Num2LStr(ncp))//' coupled points, '// & @@ -1634,6 +1693,10 @@ 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 + ! 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. & + Inst(id)%last_solve_n /= n) CALL shift_predictor_loads( Inst(id), other ) ! Snapshot the step-start state before advancing: a later correction re-entry ! over this same n restores it and re-advances. Idempotent (buffers reused), ! and on the corrected re-advance it re-captures the SAME restored state. @@ -1655,6 +1718,7 @@ SUBROUTINE CD_UpdateStates( t, n, u, t_array, p, x, xd, z, other, m, ErrStat, Er IF (Inst(id)%use_extfluid) THEN IF (Inst(id)%prof_enabled) CALL SYSTEM_CLOCK(prof_phase0) CALL sample_wavefield( Inst(id), p, m, t + p%dtCoupling, ErrStat2, ErrMsg2 ) + IF (ErrStat2 < AbortErrLev) CALL save_held_fluid( Inst(id), other ) IF (Inst(id)%prof_enabled) THEN CALL SYSTEM_CLOCK(prof_c1) Inst(id)%prof_n_wave = Inst(id)%prof_n_wave + 1_IntKi @@ -1741,7 +1805,8 @@ SUBROUTINE sample_wavefield( inst_e, p, m, t_sample, ErrStat, ErrMsg, at_init ) character(*), parameter :: RoutineName = 'sample_wavefield' INTEGER(IntKi) :: i, l, es, nodeInWater, n_current_nodes - LOGICAL :: init_call + INTEGER(IntKi) :: prof_c0, prof_c1, prof_rate + LOGICAL :: init_call, timed REAL(SiKi) :: zeta1, zeta2, zeta, pdyn, fv(3), fa(3), famcf(3) REAL(SiKi) :: wave_zeta1, wave_zeta2, wave_zeta, wave_pdyn REAL(SiKi) :: wave_fv(3), wave_fa(3), wave_famcf(3) @@ -1760,6 +1825,9 @@ SUBROUTINE sample_wavefield( inst_e, p, m, t_sample, ErrStat, ErrMsg, at_init ) ErrMsg = '' init_call = .FALSE. IF (PRESENT(at_init)) init_call = at_init + ! the parts of wave_s, timed like it outside the Init seeding call (a checkpoint restart + ! re-sampling once is counted in the parts only) + timed = inst_e%prof_enabled .AND. .NOT. init_call IF (.NOT. ASSOCIATED(p%WaveField)) THEN CALL SetErrStat( ErrID_Fatal, 'CableDyn mooring module: the WaveField pointer is null but '// & 'SeaState kinematics were requested at Init.', ErrStat, ErrMsg, RoutineName ) @@ -1791,6 +1859,7 @@ SUBROUTINE sample_wavefield( inst_e, p, m, t_sample, ErrStat, ErrMsg, at_init ) RETURN END IF IF (.NOT. inst_e%grid_warned) ALLOCATE (wl_sampled(inst_e%nfluid)) + IF (timed) CALL SYSTEM_CLOCK(prof_c0, prof_rate) DO i = 1, inst_e%nfluid IF (.NOT. inst_e%agg%host_wave_enabled .AND. .NOT. inst_e%agg%host_current_enabled) THEN ! An explicit WaterKin file may disable both host components even @@ -1886,6 +1955,11 @@ SUBROUTINE sample_wavefield( inst_e, p, m, t_sample, ErrStat, ErrMsg, at_init ) inst_e%fx_pd(i) = 0.0_wp IF (inst_e%agg%host_wave_enabled) inst_e%fx_pd(i) = REAL(pdyn, wp) END DO + IF (timed) THEN + CALL SYSTEM_CLOCK(prof_c1) + inst_e%prof_t_wave_kin = inst_e%prof_t_wave_kin + REAL(prof_c1 - prof_c0, wp)/REAL(prof_rate, wp) + prof_c0 = prof_c1 + END IF ! Grid coverage: checked on the positions just sampled (with their free-surface ! elevations), at Init (the static equilibrium) and then every mooring step until the one ! warning has been issued. @@ -1894,8 +1968,17 @@ SUBROUTINE sample_wavefield( inst_e, p, m, t_sample, ErrStat, ErrMsg, at_init ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) END IF IF (.NOT. inst_e%curr_grid_warned) CALL check_current_field_grid( inst_e, p%WaveField, t_sample ) + IF (timed) THEN + CALL SYSTEM_CLOCK(prof_c1) + inst_e%prof_t_wave_check = inst_e%prof_t_wave_check + REAL(prof_c1 - prof_c0, wp)/REAL(prof_rate, wp) + prof_c0 = prof_c1 + END IF CALL CD_AGG_SetFluidFields( inst_e%agg, inst_e%fx_vel, inst_e%fx_acc, inst_e%fx_wl, es, em, & dynamic_pressure=inst_e%fx_pd ) + IF (timed) THEN + CALL SYSTEM_CLOCK(prof_c1) + inst_e%prof_t_wave_set = inst_e%prof_t_wave_set + REAL(prof_c1 - prof_c0, wp)/REAL(prof_rate, wp) + END IF IF (es /= CD_AGG_OK) THEN CALL SetErrStat( ErrID_Fatal, 'CableDyn mooring module: '//TRIM(em), ErrStat, ErrMsg, RoutineName ) END IF @@ -2140,6 +2223,7 @@ SUBROUTINE CD_CalcOutput( t, u, p, x, xd, z, other, y, m, ErrStat, ErrMsg ) CHARACTER(ErrMsgLen) :: ErrMsg2 CHARACTER(ErrMsgLen) :: em REAL(wp) :: cval + REAL(wp) :: tau_pred ! load predictor: fraction of the mooring step elapsed LOGICAL :: fresh, state_overlaid ! Report this call's OWN status: clear at entry (ErrStat is INTENT(INOUT) and @@ -2194,10 +2278,13 @@ SUBROUTINE CD_CalcOutput( t, u, p, x, xd, z, other, y, m, ErrStat, ErrMsg ) ! evaluations, probes), and committing those boundary kinematics would move the ! module's committed state. Save the committed coupled kinematics, transfer, ! evaluate, restore: the direct-feedthrough loads keep their semantics and the - ! committed state is untouched on exit. + ! committed state is untouched on exit. The save needs only the kinematics: the + ! cable reactions (a full cable assembly each) are skipped, and ws_fld is overwritten + ! by the loads evaluated below. 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 ) + 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 @@ -2225,6 +2312,10 @@ SUBROUTINE CD_CalcOutput( t, u, p, x, xd, z, other, y, m, ErrStat, ErrMsg ) CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) RETURN END IF + IF (Inst(id)%prof_enabled) THEN + CALL SYSTEM_CLOCK(prof_c1) + Inst(id)%prof_t_overlay = Inst(id)%prof_t_overlay + REAL(prof_c1 - prof_c0, wp)/REAL(prof_rate, wp) + END IF ELSE ! Supercycled: the committed state changes only when a mooring step completes, so the ! held loads/channels are re-evaluated only then (serving the cache is bit-identical @@ -2274,6 +2365,7 @@ SUBROUTINE CD_CalcOutput( t, u, p, x, xd, z, other, y, m, ErrStat, ErrMsg ) ! Deck OUTPUTS channels are evaluated at the same aggregate state as the returned ! loads: with supercycling the last committed zero-order-hold mooring state, at ! nsuper = 1 the state including the current coupled kinematics. + IF (Inst(id)%prof_enabled) CALL SYSTEM_CLOCK(prof_phase0) DO i = 1, p%NumOuts CALL CD_AGG_EvalChannel( Inst(id)%agg, i, cval, es, em ) IF (es /= CD_AGG_OK) THEN @@ -2287,6 +2379,11 @@ SUBROUTINE CD_CalcOutput( t, u, p, x, xd, z, other, y, m, ErrStat, ErrMsg ) END IF Inst(id)%ws_chan(i) = cval END DO + IF (Inst(id)%prof_enabled) THEN + CALL SYSTEM_CLOCK(prof_c1) + Inst(id)%prof_t_channels = Inst(id)%prof_t_channels + & + REAL(prof_c1 - prof_phase0, wp)/REAL(prof_rate, wp) + END IF Inst(id)%loads_cached = .TRUE. Inst(id)%loads_at_n = Inst(id)%last_solve_n END IF @@ -2296,7 +2393,13 @@ SUBROUTINE CD_CalcOutput( t, u, p, x, xd, z, other, y, m, ErrStat, ErrMsg ) ! re-derived interior accelerations, and only the mirror carries the committed ! gen-alpha values. IF (state_overlaid) THEN + IF (Inst(id)%prof_enabled) CALL SYSTEM_CLOCK(prof_phase0) CALL restore_calcoutput_probe( Inst(id), m, ErrStat2, ErrMsg2 ) + IF (Inst(id)%prof_enabled) THEN + CALL SYSTEM_CLOCK(prof_c1) + Inst(id)%prof_t_restore = Inst(id)%prof_t_restore + & + REAL(prof_c1 - prof_phase0, wp)/REAL(prof_rate, wp) + END IF CALL SetErrStat( ErrStat2, ErrMsg2, ErrStat, ErrMsg, RoutineName ) IF (ErrStat >= AbortErrLev) RETURN END IF @@ -2354,6 +2457,31 @@ SUBROUTINE CD_CalcOutput( t, u, p, x, xd, z, other, y, m, ErrStat, ErrMsg ) y%CoupledLoads(1)%Moment(:, i) = REAL(Inst(id)%ws_moment(:, i), ReKi) END DO END IF + ! Load predictor (supercycled, predictLoads): the committed loads extrapolated from the + ! solve before, at the fraction of the mooring step elapsed (CableDyn_LoadPredictor). The + ! output channels keep the committed samples. + IF (Inst(id)%nsuper > 1_IntKi .AND. Inst(id)%agg%predict_loads) THEN + IF (CD_Predictor_Ready(REAL(Inst(id)%t_commit, wp), REAL(Inst(id)%pred_t, wp), Inst(id)%dt_moor)) THEN + IF (.NOT. ALLOCATED(Inst(id)%ws_pfld)) & + ALLOCATE (Inst(id)%ws_pfld(3, Inst(id)%ncp), Inst(id)%ws_pmom(3, Inst(id)%ncp)) + tau_pred = CD_Predictor_Fraction(REAL(t, wp), REAL(Inst(id)%t_commit, wp), Inst(id)%dt_moor) + CALL CD_Predict_Loads(Inst(id)%ws_fld, Inst(id)%pred_fld, tau_pred, Inst(id)%ws_pfld) + CALL CD_Predict_Loads(Inst(id)%ws_moment, Inst(id)%pred_mom, tau_pred, Inst(id)%ws_pmom) + IF (Inst(id)%nT > 0) THEN + DO i = 1, Inst(id)%ncp + y%CoupledLoads(Inst(id)%tmap(i))%Force(:, Inst(id)%node_of_slot(i)) = & + REAL(Inst(id)%ws_pfld(:, i), ReKi) + y%CoupledLoads(Inst(id)%tmap(i))%Moment(:, Inst(id)%node_of_slot(i)) = & + REAL(Inst(id)%ws_pmom(:, i), ReKi) + END DO + ELSE + DO i = 1, Inst(id)%ncp + y%CoupledLoads(1)%Force(:, i) = REAL(Inst(id)%ws_pfld(:, i), ReKi) + y%CoupledLoads(1)%Moment(:, i) = REAL(Inst(id)%ws_pmom(:, i), ReKi) + END DO + END IF + END IF + END IF DO i = 1, p%NumOuts y%WriteOutput(i) = REAL(Inst(id)%ws_chan(i), ReKi) END DO @@ -2367,6 +2495,80 @@ SUBROUTINE CD_CalcOutput( t, u, p, x, xd, z, other, y, m, ErrStat, ErrMsg ) END SUBROUTINE CD_CalcOutput !---------------------------------------------------------------------------------------------------------------------------------- + SUBROUTINE save_held_fluid( inst_e, other ) + !! Mirror the held ambient-fluid fields of the committed step (fx_vel, fx_acc, fx_wl, + !! fx_pd, as last handed to the aggregate by sample_wavefield) into the OtherState, so a + !! restart serves its loads from the same fields (restore_held_fluid). + TYPE(CD_OF_Instance), INTENT(IN ) :: inst_e + TYPE(CD_OtherStateType), INTENT(INOUT) :: other + INTEGER(IntKi) :: nf + nf = inst_e%nfluid + IF (ALLOCATED(other%fluid_held)) THEN + IF (SIZE(other%fluid_held) /= 8*nf) DEALLOCATE (other%fluid_held) + END IF + IF (.NOT. ALLOCATED(other%fluid_held)) ALLOCATE (other%fluid_held(8*nf)) + other%fluid_held(1:3*nf) = RESHAPE(REAL(inst_e%fx_vel, DbKi), [3*nf]) + other%fluid_held(3*nf + 1:6*nf) = RESHAPE(REAL(inst_e%fx_acc, DbKi), [3*nf]) + other%fluid_held(6*nf + 1:7*nf) = REAL(inst_e%fx_wl, DbKi) + other%fluid_held(7*nf + 1:8*nf) = REAL(inst_e%fx_pd, DbKi) + END SUBROUTINE save_held_fluid + + SUBROUTINE restore_held_fluid( inst_e, other, restored, ErrStat, ErrMsg ) + !! Hand the held ambient-fluid fields of a checkpoint (save_held_fluid) back to the + !! aggregate; restored is .FALSE. when the checkpoint does not carry them for this + !! instance's fluid nodes. + TYPE(CD_OF_Instance), INTENT(INOUT) :: inst_e + TYPE(CD_OtherStateType), INTENT(IN ) :: other + LOGICAL, INTENT( OUT) :: restored + INTEGER(IntKi), INTENT( OUT) :: ErrStat + CHARACTER(*), INTENT( OUT) :: ErrMsg + INTEGER(IntKi) :: nf, es + CHARACTER(ErrMsgLen) :: em + ErrStat = ErrID_None + ErrMsg = '' + restored = .FALSE. + nf = inst_e%nfluid + IF (.NOT. ALLOCATED(other%fluid_held)) RETURN + IF (SIZE(other%fluid_held) /= 8*nf) RETURN + inst_e%fx_vel = RESHAPE(REAL(other%fluid_held(1:3*nf), wp), [3, nf]) + inst_e%fx_acc = RESHAPE(REAL(other%fluid_held(3*nf + 1:6*nf), wp), [3, nf]) + inst_e%fx_wl = REAL(other%fluid_held(6*nf + 1:7*nf), wp) + inst_e%fx_pd = REAL(other%fluid_held(7*nf + 1:8*nf), wp) + CALL CD_AGG_SetFluidFields( inst_e%agg, inst_e%fx_vel, inst_e%fx_acc, inst_e%fx_wl, es, em, & + dynamic_pressure=inst_e%fx_pd ) + IF (es /= CD_AGG_OK) THEN + ErrStat = ErrID_Fatal + ErrMsg = 'CableDyn mooring module: restoring the held fluid fields: '//TRIM(em) + RETURN + END IF + restored = .TRUE. + END SUBROUTINE restore_held_fluid + + 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 + !! that commit's time; none otherwise. Mirrored into the OtherState, so a restart + !! 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 + END SUBROUTINE shift_predictor_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. @@ -2433,8 +2635,14 @@ SUBROUTINE CD_End(u, p, x, xd, z, other, y, m, ErrStat , ErrMsg) character(*), parameter :: RoutineName = 'CD_End' INTEGER(IntKi) :: ErrStat2 - INTEGER :: hn_step, hn_resid, hn_solve, hn_am, hn_alloc, hn_tan + INTEGER :: hn_step, hn_resid, hn_solve, hn_am, hn_alloc, hn_tan, hn_factor, hn_correct INTEGER(int64) :: sb_calls, sb_events, sb_capped, sb_fallbacks + INTEGER(int64) :: k_calls(CD_PROF_NKERNEL) + REAL(wp) :: k_seconds(CD_PROF_NKERNEL), ht_factor + CHARACTER(24) :: k_txt(CD_PROF_NKERNEL) + REAL(wp) :: ht_asm(HC_ASM_NSEC) + CHARACTER(:), ALLOCATABLE :: asm_txt + INTEGER :: k_i INTEGER :: sb_max, sb_ios CHARACTER(24) :: sb_txt(5) REAL(wp) :: ht_step, ht_resid, ht_eff, ht_solve, ht_am, ht_tan @@ -2473,30 +2681,64 @@ SUBROUTINE CD_End(u, p, x, xd, z, other, y, m, ErrStat , ErrMsg) TRIM(Num2LStr(REAL(Inst(m%CDInst)%prof_t_calcout, DbKi)))// & ' loads_s='//TRIM(Num2LStr(REAL(Inst(m%CDInst)%prof_t_loads, DbKi)))// & ' native_io_s='//TRIM(Num2LStr(REAL(Inst(m%CDInst)%prof_t_native, DbKi))) ) + CALL WrScr( ' CableDyn profile parts: overlay_s='// & + TRIM(Num2LStr(REAL(Inst(m%CDInst)%prof_t_overlay, DbKi)))// & + ' channels_s='//TRIM(Num2LStr(REAL(Inst(m%CDInst)%prof_t_channels, DbKi)))// & + ' restore_s='//TRIM(Num2LStr(REAL(Inst(m%CDInst)%prof_t_restore, DbKi)))// & + ' wave_kin_s='//TRIM(Num2LStr(REAL(Inst(m%CDInst)%prof_t_wave_kin, DbKi)))// & + ' wave_check_s='//TRIM(Num2LStr(REAL(Inst(m%CDInst)%prof_t_wave_check, DbKi)))// & + ' wave_set_s='//TRIM(Num2LStr(REAL(Inst(m%CDInst)%prof_t_wave_set, DbKi))) ) CALL WrScr( ' CableDyn profile counts: update='// & TRIM(Num2LStr(Inst(m%CDInst)%prof_n_update))// & ' solve='//TRIM(Num2LStr(Inst(m%CDInst)%prof_n_solve))// & ' wave='//TRIM(Num2LStr(Inst(m%CDInst)%prof_n_wave))// & ' calcout='//TRIM(Num2LStr(Inst(m%CDInst)%prof_n_calcout))// & ' fresh='//TRIM(Num2LStr(Inst(m%CDInst)%prof_n_fresh)) ) + ! the kernels of the EI = 0 line steps (summed over the lines) + CALL CD_Profile_Kernels(k_calls, k_seconds) + IF (SUM(k_calls) > 0_int64) THEN + DO k_i = 1, CD_PROF_NKERNEL + WRITE (k_txt(k_i), '(I0)', IOSTAT=sb_ios) k_calls(k_i) + END DO + CALL WrScr( ' CableDyn EI = 0 profile: residual_s='// & + TRIM(Num2LStr(REAL(k_seconds(CD_PROF_K_RESIDUAL), DbKi)))// & + ' tangent_s='//TRIM(Num2LStr(REAL(k_seconds(CD_PROF_K_TANGENT), DbKi)))// & + ' factor_s='//TRIM(Num2LStr(REAL(k_seconds(CD_PROF_K_FACTOR), DbKi)))// & + ' backsolve_s='//TRIM(Num2LStr(REAL(k_seconds(CD_PROF_K_SOLVE), DbKi))) ) + CALL WrScr( ' CableDyn EI = 0 counts: residual='//TRIM(k_txt(CD_PROF_K_RESIDUAL))// & + ' tangent='//TRIM(k_txt(CD_PROF_K_TANGENT))// & + ' factor='//TRIM(k_txt(CD_PROF_K_FACTOR))// & + ' backsolve='//TRIM(k_txt(CD_PROF_K_SOLVE)) ) + END IF IF (Inst(m%CDInst)%agg%ncable > 0) THEN CALL CD_HermiteCable_Dyn_Get_Profile(hn_step, hn_resid, hn_solve, hn_am, hn_alloc, & ht_step, ht_resid, ht_eff, ht_solve, ht_am, & - hn_tan, ht_tan) + hn_tan, ht_tan, hn_factor, ht_factor, hn_correct) CALL WrScr( ' CableDyn Hermite profile: step_s='//TRIM(Num2LStr(REAL(ht_step, DbKi)))// & ' residual_s='//TRIM(Num2LStr(REAL(ht_resid, DbKi)))// & ' tangent_s='//TRIM(Num2LStr(REAL(ht_tan, DbKi)))// & ' effective_s='//TRIM(Num2LStr(REAL(ht_eff, DbKi)))// & - ' solve_s='//TRIM(Num2LStr(REAL(ht_solve, DbKi)))// & + ' factor_s='//TRIM(Num2LStr(REAL(ht_factor, DbKi)))// & + ' backsolve_s='//TRIM(Num2LStr(REAL(ht_solve, DbKi)))// & ' added_mass_s='//TRIM(Num2LStr(REAL(ht_am, DbKi))) ) CALL WrScr( ' CableDyn Hermite counts: step='//TRIM(Num2LStr(hn_step))// & ' residual='//TRIM(Num2LStr(hn_resid))// & ' tangent='//TRIM(Num2LStr(hn_tan))// & + ' factor='//TRIM(Num2LStr(hn_factor))// & ' solve='//TRIM(Num2LStr(hn_solve))// & + ' correction='//TRIM(Num2LStr(hn_correct))// & ' added_mass='//TRIM(Num2LStr(hn_am))// & ' alloc='//TRIM(Num2LStr(hn_alloc)) ) CALL WrScr( ' CableDyn Hermite recoveries: intervals='// & TRIM(Num2LStr(CD_HermiteCable_Dyn_Recovery_Count())) ) + ! the residual and tangent assemblies split into their sections + CALL CD_HermiteCable_Dyn_Get_Assembly_Profile(ht_asm) + asm_txt = ' CableDyn Hermite assembly:' + DO k_i = 1, HC_ASM_NSEC + asm_txt = asm_txt//' '//TRIM(HC_ASM_KEYS(k_i))//'_s='// & + TRIM(Num2LStr(REAL(ht_asm(k_i), DbKi))) + END DO + CALL WrScr( asm_txt ) END IF END IF ! Propagate a teardown failure as SEVERE (not fatal): the glue's End path @@ -4231,12 +4473,21 @@ SUBROUTINE reset_instance_profile( id, enabled, init_seconds, reset_global ) Inst(id)%prof_t_calcout = 0.0_wp Inst(id)%prof_t_loads = 0.0_wp Inst(id)%prof_t_native = 0.0_wp + Inst(id)%prof_t_overlay = 0.0_wp + Inst(id)%prof_t_channels = 0.0_wp + Inst(id)%prof_t_restore = 0.0_wp + Inst(id)%prof_t_wave_kin = 0.0_wp + Inst(id)%prof_t_wave_check = 0.0_wp + Inst(id)%prof_t_wave_set = 0.0_wp IF (reset_global) THEN CALL CD_HermiteCable_Dyn_Recovery_Reset() IF (enabled) THEN CALL CD_HermiteCable_Dyn_Reset_Profile() + ! the EI = 0 line-step kernels (CableDyn_Profile, process-global like the above) + CALL CD_Profile_Arm() ELSE CALL CD_HermiteCable_Dyn_Disable_Profile() + CALL CD_Profile_Disarm() END IF END IF END SUBROUTINE reset_instance_profile @@ -4257,6 +4508,12 @@ SUBROUTINE copy_instance_profile( destination, source ) Inst(destination)%prof_t_calcout = Inst(source)%prof_t_calcout Inst(destination)%prof_t_loads = Inst(source)%prof_t_loads Inst(destination)%prof_t_native = Inst(source)%prof_t_native + Inst(destination)%prof_t_overlay = Inst(source)%prof_t_overlay + Inst(destination)%prof_t_channels = Inst(source)%prof_t_channels + Inst(destination)%prof_t_restore = Inst(source)%prof_t_restore + Inst(destination)%prof_t_wave_kin = Inst(source)%prof_t_wave_kin + Inst(destination)%prof_t_wave_check = Inst(source)%prof_t_wave_check + Inst(destination)%prof_t_wave_set = Inst(source)%prof_t_wave_set END SUBROUTINE copy_instance_profile !> Validate an instance handle (fails closed on 0 / stale handles, e.g. after a diff --git a/src/openfast/CableDyn_Registry.txt b/src/openfast/CableDyn_Registry.txt index 45180fc..a4c32cf 100644 --- a/src/openfast/CableDyn_Registry.txt +++ b/src/openfast/CableDyn_Registry.txt @@ -359,6 +359,9 @@ typedef ^ ConstraintStateType SiKi dummy - ## ============================== Define Other states here: ===================================================================================================================================== typedef ^ OtherStateType IntKi restart_serial - -1 - "instance-generation stamp minted at Init/rebuild; a restored OtherState whose stamp does not match the live instance's (a fresh process) triggers the checkpoint rebuild from p + the x state mirror" - typedef ^ ^ DbKi t_commit - -1 - "time of the last committed mooring advance; a live instance whose committed clock is AHEAD of a restored OtherState's (a same-process checkpoint rewind) also triggers the rebuild" - +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" - ## ============================== Parameters ============================================================================================================================================ typedef ^ ParameterType IntKi nLineTypes - 0 - "number of line types" "" diff --git a/src/openfast/CableDyn_Types.f90 b/src/openfast/CableDyn_Types.f90 index 25ec971..e654f30 100644 --- a/src/openfast/CableDyn_Types.f90 +++ b/src/openfast/CableDyn_Types.f90 @@ -393,6 +393,9 @@ MODULE CableDyn_Types TYPE, PUBLIC :: CD_OtherStateType INTEGER(IntKi) :: restart_serial = -1 !< instance-generation stamp minted at Init/rebuild; a restored OtherState whose stamp does not match the live instance's (a fresh process) triggers the checkpoint rebuild from p + the x state mirror [-] REAL(DbKi) :: t_commit = -1 !< time of the last committed mooring advance; a live instance whose committed clock is AHEAD of a restored OtherState's (a same-process checkpoint rewind) also triggers the rebuild [-] + 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 [-] END TYPE CD_OtherStateType ! ======================= ! ========= CD_ParameterType ======= @@ -2969,11 +2972,38 @@ subroutine CD_CopyOtherState(SrcOtherStateData, DstOtherStateData, CtrlCode, Err integer(IntKi), intent(in ) :: CtrlCode integer(IntKi), intent( out) :: ErrStat character(*), intent( out) :: ErrMsg + integer(B4Ki) :: LB(1), UB(1) + integer(IntKi) :: ErrStat2 character(*), parameter :: RoutineName = 'CD_CopyOtherState' ErrStat = ErrID_None ErrMsg = '' DstOtherStateData%restart_serial = SrcOtherStateData%restart_serial DstOtherStateData%t_commit = SrcOtherStateData%t_commit + DstOtherStateData%pred_t = SrcOtherStateData%pred_t + if (allocated(SrcOtherStateData%pred_loads)) then + LB(1:1) = lbound(SrcOtherStateData%pred_loads) + UB(1:1) = ubound(SrcOtherStateData%pred_loads) + if (.not. allocated(DstOtherStateData%pred_loads)) then + allocate(DstOtherStateData%pred_loads(LB(1):UB(1)), stat=ErrStat2) + if (ErrStat2 /= 0) then + call SetErrStat(ErrID_Fatal, 'Error allocating DstOtherStateData%pred_loads.', ErrStat, ErrMsg, RoutineName) + return + end if + end if + DstOtherStateData%pred_loads = SrcOtherStateData%pred_loads + end if + if (allocated(SrcOtherStateData%fluid_held)) then + LB(1:1) = lbound(SrcOtherStateData%fluid_held) + UB(1:1) = ubound(SrcOtherStateData%fluid_held) + if (.not. allocated(DstOtherStateData%fluid_held)) then + allocate(DstOtherStateData%fluid_held(LB(1):UB(1)), stat=ErrStat2) + if (ErrStat2 /= 0) then + call SetErrStat(ErrID_Fatal, 'Error allocating DstOtherStateData%fluid_held.', ErrStat, ErrMsg, RoutineName) + return + end if + end if + DstOtherStateData%fluid_held = SrcOtherStateData%fluid_held + end if end subroutine subroutine CD_DestroyOtherState(OtherStateData, ErrStat, ErrMsg) @@ -2983,6 +3013,12 @@ subroutine CD_DestroyOtherState(OtherStateData, ErrStat, ErrMsg) character(*), parameter :: RoutineName = 'CD_DestroyOtherState' ErrStat = ErrID_None ErrMsg = '' + if (allocated(OtherStateData%pred_loads)) then + deallocate(OtherStateData%pred_loads) + end if + if (allocated(OtherStateData%fluid_held)) then + deallocate(OtherStateData%fluid_held) + end if end subroutine subroutine CD_PackOtherState(RF, Indata) @@ -2992,6 +3028,9 @@ subroutine CD_PackOtherState(RF, Indata) if (RF%ErrStat >= AbortErrLev) return call RegPack(RF, InData%restart_serial) call RegPack(RF, InData%t_commit) + call RegPack(RF, InData%pred_t) + call RegPackAlloc(RF, InData%pred_loads) + call RegPackAlloc(RF, InData%fluid_held) if (RegCheckErr(RF, RoutineName)) return end subroutine @@ -2999,9 +3038,15 @@ subroutine CD_UnPackOtherState(RF, OutData) type(RegFile), intent(inout) :: RF type(CD_OtherStateType), intent(inout) :: OutData character(*), parameter :: RoutineName = 'CD_UnPackOtherState' + integer(B4Ki) :: LB(1), UB(1) + integer(IntKi) :: stat + logical :: IsAllocAssoc if (RF%ErrStat /= ErrID_None) return call RegUnpack(RF, OutData%restart_serial); if (RegCheckErr(RF, RoutineName)) return call RegUnpack(RF, OutData%t_commit); if (RegCheckErr(RF, RoutineName)) return + 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 end subroutine subroutine CD_CopyParam(SrcParamData, DstParamData, CtrlCode, ErrStat, ErrMsg) diff --git a/tests/check_driver_advisories.cmake b/tests/check_driver_advisories.cmake index aab9b32..4b653d4 100644 --- a/tests/check_driver_advisories.cmake +++ b/tests/check_driver_advisories.cmake @@ -3,7 +3,11 @@ # * a prescribed motion that starts moving (a harmonic started at t = 0 is at its peak # velocity) prints a start-up shock note; one that starts from rest does not; # * a slack anchored line on a deck with no seabed (an OpenFAST MooringFile run -# standalone) prints a no-seabed warning; a deck with a water depth does not. +# standalone) prints a no-seabed warning; a deck with a water depth does not; +# * a dtM that resolves a line's axial period (2 x 410 m / sqrt(1.674e9 / 390) = 0.396 s) in +# fewer than 6 steps prints the dtM advisory: 4.0 steps at 0.1 s, none at 0.05 s (7.9); +# * a finite-EI cable whose grounded elements lock more than 15 % of the touchdown tension in +# seabed friction prints the friction resolution note; a finer grounded mesh does not. foreach(_required DRIVER WORK NO_SEABED_DECK SEABED_DECK) if(NOT DEFINED ${_required} OR "${${_required}}" STREQUAL "") message(FATAL_ERROR "check_driver_advisories: missing ${_required}") @@ -93,9 +97,73 @@ require_text("sine start" "${_sine_log}" "Note: motionFile point 2 starts moving require_text("sine start" "${_sine_log}" "Start the motion from rest") run_driver("start from rest" "${WORK}/rest.dat" "${WORK}/rest" _rest_log) forbid_text("start from rest" "${_rest_log}" "starts moving at") +forbid_text("start from rest" "${_rest_log}" "axial period") + +string(REPLACE "0.05 dtM" "0.1 dtM" _coarse_head "${_deck_head}") +# the motion record on the coarser dtM grid +file(WRITE "${WORK}/coarse.txt" "0.0 2 0.0 0.0 0.000 0.0 0.0 0.0 0.0 0.0 0.0\n0.1 2 0.0 0.0 0.000 0.0 0.0 0.0 0.0 0.0 0.0\n0.2 2 0.0 0.0 0.000 0.0 0.0 0.0 0.0 0.0 0.0\n") +file(WRITE "${WORK}/coarse.dat" "${_coarse_head}coarse.txt motionFile\n${_deck_tail}") +run_driver("coarse dtM" "${WORK}/coarse.dat" "${WORK}/coarse" _coarse_log) +require_text("coarse dtM" "${_coarse_log}" + "Note: LINE 1: dtM = 0.1000 s resolves its axial period of 0.396 s (twice its length over sqrt(EA/m)) in 4.0 steps, fewer than 6") +require_text("coarse dtM" "${_coarse_log}" "A dtM of at most 0.0660 s resolves them.") run_driver("no seabed" "${NO_SEABED_DECK}" "${WORK}/no_seabed" _no_seabed_log) require_text("no seabed" "${_no_seabed_log}" "below its Fixed anchor and the deck has no seabed") require_text("no seabed" "${_no_seabed_log}" "for a standalone run add WtrDpth") run_driver("seabed" "${SEABED_DECK}" "${WORK}/seabed" _seabed_log) forbid_text("seabed" "${_seabed_log}" "WARNING") + +# The seabed-friction resolution note: a lazy-wave cable whose grounded run has 10 m elements +# locks up to mu w L = 0.35 x 157.8 N/m x 10 m = 552 N per node, 46 % of the 1204 N tension at +# touchdown, and is told the grounded element length that resolves it; with 2.5 m elements +# (11 %, below the 15 % limit) it is not. +set(_friction_deck [=[--------------------- CableDyn Input File ------------------------------------ +Driver advisory check: lazy-wave cable with seabed friction +--------------------- LINE TYPES --------------------------------------------- +Name Diam Mass EA BA EI GAs GJ Irt Irn Cdn Cdt Can Cat +(-) (m) (kg/m) (N) (-) (Nm2) (N) (Nm2) (kgm) (kgm) (-) (-) (-) (-) +bare 0.16 36.70 4.69e8 0.0 1.99e4 1.0e8 1.5e4 1.0 1.0 1.2 0.1 1.0 0.0 +buoy 0.29 59.53 4.69e8 0.0 1.99e4 1.0e8 1.5e4 1.0 1.0 1.2 0.1 1.0 0.0 +--------------------- POINTS ------------------------------------------------- +ID Type X Y Z +(-) (-) (m) (m) (m) +7 Coupled 30.0 0.0 -20.0 +8 Fixed 500.0 0.0 -200.0 +--------------------- LINES -------------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +4 7 8 - +--------------------- SECTIONS ----------------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +4 bare 80.0 20 +4 buoy 100.0 25 +4 bare 420.0 42 +--------------------- END CONNECTIONS ---------------------------------------- +LineID End Stiffness EzX EzY EzZ TorsStiffness NxX NxY NxZ Pretwist +(-) (-) (N-m/rad) (-) (-) (-) (N-m/rad) (-) (-) (-) (deg) +4 A Rigid 0.1058 0 -0.9944 Free 0 1 0 0 +4 B Rigid 1 0 0 Free 0 1 0 0 +--------------------- OPTIONS ------------------------------------------------ +9.80665 g +1025.0 rhoW +3.0e6 kBot +3.0e5 cBot +0.35 frictionMu +200.0 WtrDpth +0.05 dtM +0.1 TMax +--------------------- OUTPUTS ------------------------------------------------ +FairTen4 +--------------------- END ---------------------------------------------------- +]=]) +file(WRITE "${WORK}/friction_coarse.dat" "${_friction_deck}") +string(REPLACE "4 bare 420.0 42" "4 bare 420.0 168" _friction_fine "${_friction_deck}") +file(WRITE "${WORK}/friction_fine.dat" "${_friction_fine}") +run_driver("friction coarse" "${WORK}/friction_coarse.dat" "${WORK}/friction_coarse" _fc_log) +require_text("friction coarse" "${_fc_log}" + "Note: line 4: seabed friction acts at the nodes, so a grounded element of 10.0 m locks or releases up to 552 N") +require_text("friction coarse" "${_fc_log}" "Grounded elements of at most 3.27 m resolve it.") +run_driver("friction fine" "${WORK}/friction_fine.dat" "${WORK}/friction_fine" _ff_log) +forbid_text("friction fine" "${_ff_log}" "seabed friction acts at the nodes") diff --git a/tests/check_touchdown_recovery.py b/tests/check_touchdown_recovery.py new file mode 100644 index 0000000..7d59371 --- /dev/null +++ b/tests/check_touchdown_recovery.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Node tension at touchdown on a coarse mesh (driver_touchdown_recovery in CMakeLists.txt). + +A lazy-wave cable in 200 m of water is solved statically on a coarse mesh (10 m elements along +the grounded run) and on a reference mesh four times finer. The node tensions of the coarse +mesh, recovered from nodal equilibrium, must follow the reference within 3.5 % at every node +within 30 m of touchdown. The touchdown element is on the floor at one end only; spreading the +floor reaction of its grounded node over it understated the tension of the last suspended node +by 5.3 % (the rest of the profile is within 1.5 %). + +Usage: check_touchdown_recovery.py +""" + +import pathlib +import subprocess +import sys +import tempfile + +TOLERANCE = 0.035 +WINDOW = 30.0 +FLOOR_Z = -200.0 + +DECK = """--------------------- CableDyn Input File ------------------------------------ +Touchdown recovery check: lazy-wave cable, static +--------------------- LINE TYPES --------------------------------------------- +Name Diam Mass EA BA EI GAs GJ Irt Irn Cdn Cdt Can Cat +(-) (m) (kg/m) (N) (-) (Nm2) (N) (Nm2) (kgm) (kgm) (-) (-) (-) (-) +bare 0.16 36.70 4.69e8 0.0 1.99e4 1.0e8 1.5e4 1.0 1.0 1.2 0.1 1.0 0.0 +buoy 0.29 59.53 4.69e8 0.0 1.99e4 1.0e8 1.5e4 1.0 1.0 1.2 0.1 1.0 0.0 +--------------------- POINTS ------------------------------------------------- +ID Type X Y Z +(-) (-) (m) (m) (m) +7 Coupled 30.0 0.0 -20.0 +8 Fixed 500.0 0.0 -200.0 +--------------------- LINES -------------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +4 7 8 - +--------------------- SECTIONS ----------------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +4 bare 80.0 {n1} +4 buoy 100.0 {n2} +4 bare 420.0 {n3} +--------------------- END CONNECTIONS ---------------------------------------- +LineID End Stiffness EzX EzY EzZ TorsStiffness NxX NxY NxZ Pretwist +(-) (-) (N-m/rad) (-) (-) (-) (N-m/rad) (-) (-) (-) (deg) +4 A Rigid 0.1058 0 -0.9944 Free 0 1 0 0 +4 B Rigid 1 0 0 Free 0 1 0 0 +--------------------- OPTIONS ------------------------------------------------ +9.80665 g +1025.0 rhoW +3.0e6 kBot +3.0e5 cBot +200.0 WtrDpth +0.025 dtM +0.0 TMax +--------------------- OUTPUTS ------------------------------------------------ +FairTen4 +--------------------- END ---------------------------------------------------- +""" + + +def static_profile(exe, work, name, segments): + """Arc length, z and node tension of every node of the static configuration.""" + deck = work / f"{name}.dat" + n1, n2, n3 = segments + deck.write_text(DECK.format(n1=n1, n2=n2, n3=n3), encoding="utf-8") + run = subprocess.run([exe, str(deck), str(work / name)], capture_output=True, text=True, check=False) + if run.returncode != 0: + raise SystemExit(f"FAIL: the {name} deck did not solve (exit {run.returncode}):\n{run.stdout}{run.stderr}") + rows = [] + for line in (work / f"{name}.static.out").read_text(encoding="utf-8").splitlines(): + fields = line.split() + try: + values = [float(x) for x in fields] + except ValueError: + continue + rows.append(values) + # columns: LineID Node ArcLength X Y Z Tension ... + return [(r[2], r[5], r[6]) for r in rows] + + +def interpolate(arcs, values, s): + for k in range(1, len(arcs)): + if arcs[k] >= s: + f = (s - arcs[k - 1]) / (arcs[k] - arcs[k - 1]) + return values[k - 1] + f * (values[k] - values[k - 1]) + return values[-1] + + +def main(): + if len(sys.argv) != 2: + raise SystemExit(__doc__) + exe = sys.argv[1] + with tempfile.TemporaryDirectory() as tmp: + work = pathlib.Path(tmp) + coarse = static_profile(exe, work, "coarse", (20, 25, 42)) + fine = static_profile(exe, work, "fine", (80, 100, 168)) + touchdown = next(s for s, z, _ in fine if z <= FLOOR_Z + 0.01) + arcs = [s for s, _, _ in fine] + tensions = [t for _, _, t in fine] + worst = 0.0 + failures = [] + for s, z, t in coarse: + if abs(s - touchdown) > WINDOW: + continue + ref = interpolate(arcs, tensions, s) + err = (t - ref) / ref + worst = max(worst, abs(err)) + print(f" s = {s:7.1f} m z = {z:8.2f} m T = {t:8.1f} N reference {ref:8.1f} N {100 * err:+6.2f} %") + if abs(err) > TOLERANCE: + failures.append(f"s = {s:.1f} m: {100 * err:+.2f} %") + print(f"touchdown at s = {touchdown:.1f} m; largest deviation {100 * worst:.2f} % (limit {100 * TOLERANCE:.1f} %)") + if failures: + raise SystemExit("FAIL: coarse-mesh node tension near touchdown off the reference: " + "; ".join(failures)) + print("PASS: coarse-mesh node tension follows the reference through touchdown") + + +if __name__ == "__main__": + main() diff --git a/tests/test_c_api.f90 b/tests/test_c_api.f90 index c189dda..4384b5e 100644 --- a/tests/test_c_api.f90 +++ b/tests/test_c_api.f90 @@ -57,7 +57,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 == 2_C_INT .AND. patch == 0_C_INT .AND. abi_version == 1_C_INT, & + CALL require(major == 0_C_INT .AND. minor == 3_C_INT .AND. patch == 0_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, & diff --git a/tests/test_cpu_guard.f90 b/tests/test_cpu_guard.f90 new file mode 100644 index 0000000..257c77b --- /dev/null +++ b/tests/test_cpu_guard.f90 @@ -0,0 +1,65 @@ +! File: tests/test_cpu_guard.f90 +! SPDX-License-Identifier: Apache-2.0 +! Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +PROGRAM test_cpu_guard + !! The x86-64-v3 start-up processor check (src/cabledyn_cpu_guard.c). + !! + !! Argument 1 is 1 when the build sets CABLEDYN_TARGET_AVX2 and 0 otherwise: the guard must + !! be armed exactly then. An armed guard has already let this process start, so the processor + !! must then report no missing feature. The feature list and its count must agree, and a + !! short buffer must be truncated, never overrun. + USE, INTRINSIC :: ISO_C_BINDING, ONLY: C_INT, C_CHAR, C_NULL_CHAR + IMPLICIT NONE + INTERFACE + FUNCTION cpu_guard_armed() BIND(C, name='cabledyn_cpu_guard_armed') RESULT(armed) + IMPORT :: C_INT + INTEGER(C_INT) :: armed + END FUNCTION cpu_guard_armed + FUNCTION cpu_missing_features(buf, length) BIND(C, name='cabledyn_cpu_missing_features') RESULT(n) + IMPORT :: C_INT, C_CHAR + CHARACTER(KIND=C_CHAR), INTENT(OUT) :: buf(*) + INTEGER(C_INT), VALUE :: length + INTEGER(C_INT) :: n + END FUNCTION cpu_missing_features + END INTERFACE + CHARACTER(KIND=C_CHAR) :: buf(160), tiny(4) + CHARACTER(16) :: arg + INTEGER :: expected, n, n_tiny, used, i, nfail + + nfail = 0 + CALL GET_COMMAND_ARGUMENT(1, arg) + READ (arg, *) expected + + IF (cpu_guard_armed() /= expected) THEN + PRINT '(A,I0,A,I0)', 'FAIL: guard armed = ', cpu_guard_armed(), ', build expects ', expected + nfail = nfail + 1 + END IF + + buf = 'x' + n = cpu_missing_features(buf, SIZE(buf)) + used = 0 + DO i = 1, SIZE(buf) + IF (buf(i) == C_NULL_CHAR) EXIT + used = i + END DO + PRINT '(A,I0,A,160A)', 'missing x86-64-v3 features: ', n, ' ', buf(1:used) + IF ((n == 0) .NEQV. (used == 0)) THEN + PRINT '(A)', 'FAIL: the feature count and the feature list disagree' + nfail = nfail + 1 + END IF + IF (expected == 1 .AND. n /= 0) THEN + PRINT '(A)', 'FAIL: an armed guard let a processor without x86-64-v3 start' + nfail = nfail + 1 + END IF + + ! a 4-character buffer holds at most 3 characters and the terminator + tiny = 'y' + n_tiny = cpu_missing_features(tiny, SIZE(tiny)) + IF (n_tiny /= n .OR. ALL(tiny /= C_NULL_CHAR)) THEN + PRINT '(A)', 'FAIL: a short buffer changed the count or lost its terminator' + nfail = nfail + 1 + END IF + + IF (nfail > 0) ERROR STOP 1 + PRINT '(A)', 'PASS: cpu guard' +END PROGRAM test_cpu_guard diff --git a/tests/test_dtm_advisor.f90 b/tests/test_dtm_advisor.f90 new file mode 100644 index 0000000..1931d73 --- /dev/null +++ b/tests/test_dtm_advisor.f90 @@ -0,0 +1,75 @@ +! File: tests/test_dtm_advisor.f90 +! SPDX-License-Identifier: Apache-2.0 +! Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +PROGRAM test_dtm_advisor + !! The dtM advisor on the coupled (aggregate) route: the IEA-15 MW mixed mooring example built + !! with the mooring step of an OpenFAST run. Its power cable (80 m bare, 100 m buoyant, 420 m + !! bare) has an axial period of 0.351 s, which dtM = 0.1 s resolves in 3.5 steps: its + !! initialisation note names the period and a dtM that resolves it, while the chains (0.778 s, + !! 7.8 steps) are not noted. At dtM = 0.05 s (7.0 steps) no line is noted. + USE CableDyn_Precision, ONLY: wp + USE CableDyn_OpenFAST_Aggregate, ONLY: CD_AGG_ModuleType, CD_AGG_Init_From_Deck, CD_AGG_GetInitLine, & + CD_AGG_End, CD_AGG_OK + IMPLICIT NONE + + CHARACTER(1024) :: deck + INTEGER :: nfail + + nfail = 0 + IF (COMMAND_ARGUMENT_COUNT() < 1) THEN + WRITE (*, '(A)') 'FAIL: the mixed example deck path argument is required' + ERROR STOP 1 + END IF + CALL GET_COMMAND_ARGUMENT(1, deck) + CALL check_step(0.1_wp, .TRUE.) + CALL check_step(0.05_wp, .FALSE.) + IF (nfail > 0) THEN + WRITE (*, '(A,I0,A)') 'FAIL: ', nfail, ' assertion(s) failed' + ERROR STOP 1 + END IF + WRITE (*, '(A)') 'PASS: the dtM advisor notes the cable at dtM = 0.1 s and nothing at 0.05 s' + +CONTAINS + + SUBROUTINE require(cond, msg) + LOGICAL, INTENT(IN) :: cond + CHARACTER(*), INTENT(IN) :: msg + IF (.NOT. cond) THEN + WRITE (*, '(A)') 'FAIL: '//TRIM(msg) + nfail = nfail + 1 + END IF + END SUBROUTINE require + + SUBROUTINE check_step(dt, cable_noted) + REAL(wp), INTENT(IN) :: dt + LOGICAL, INTENT(IN) :: cable_noted + TYPE(CD_AGG_ModuleType) :: agg + CHARACTER(2048) :: note + CHARACTER(512) :: em + CHARACTER(16) :: tag + REAL(wp) :: tension, force(3), incl, decl, azi + INTEGER :: es, i, line_id + LOGICAL :: noted + + WRITE (tag, '(F5.3)') dt + CALL CD_AGG_Init_From_Deck(agg, TRIM(deck), dt, es, em) + CALL require(es == CD_AGG_OK, 'dtM '//TRIM(tag)//': init: '//TRIM(em)) + IF (es /= CD_AGG_OK) RETURN + DO i = 1, 4 + CALL CD_AGG_GetInitLine(agg, i, line_id, tension, force, incl, decl, azi, es, em, note=note) + CALL require(es == CD_AGG_OK, 'dtM '//TRIM(tag)//': line summary: '//TRIM(em)) + noted = INDEX(note, 'axial period') > 0 + IF (line_id == 4 .AND. cable_noted) THEN + CALL require(noted, 'dtM '//TRIM(tag)//': the cable is noted') + CALL require(INDEX(note, 'LINE 4: dtM = 0.1000 s resolves its axial period of 0.351 s') > 0 .AND. & + INDEX(note, 'in 3.5 steps, fewer than 6') > 0 .AND. & + INDEX(note, 'A dtM of at most 0.0585 s resolves them') > 0, & + 'dtM '//TRIM(tag)//': the cable note gives the period, the steps and the remedy: '//TRIM(note)) + ELSE + CALL require(.NOT. noted, 'dtM '//TRIM(tag)//': line '//CHAR(48 + line_id)//' is not noted: '//TRIM(note)) + END IF + END DO + CALL CD_AGG_End(agg, es, em) + END SUBROUTINE check_step + +END PROGRAM test_dtm_advisor diff --git a/tests/test_hermite_cable_control.f90 b/tests/test_hermite_cable_control.f90 index ba87404..590a74f 100644 --- a/tests/test_hermite_cable_control.f90 +++ b/tests/test_hermite_cable_control.f90 @@ -244,14 +244,15 @@ SUBROUTINE check_model_contract() CALL require(unchanged(a, ref) .AND. same_bits(a%l0_dot, ref%l0_dot) .AND. same_bits(a%q, ref%q) .AND. & same_bits(a%v, ref%v) .AND. same_bits(a%a, ref%a) .AND. same_bits2(a%fr_anchor, ref%fr_anchor), & 'A: restore brings back the lengths, rates, caches and state bit for bit') - ! Restore clears the carried Newton factor and the cached committed force, which ref still - ! holds. Where a step stops depends on the factor's age, so a restart is exact only with - ! tangent reuse off; and ref's next step would take its committed force from the cache - ! while a evaluates it afresh. Both models march with reuse off from an uncached - ! committed force, so they take the same path. + ! Restore clears the carried Newton factor, which ref still holds; where a step stops + ! depends on the factor's age, so a restart is exact only with tangent reuse off. The + ! cached committed force comes back with the snapshot (the length command after it does + ! not invalidate it), so a takes the same cached path as ref: on a friction line the cache + ! differs from a fresh evaluation in its last bits. a%tangent_reuse = .FALSE. ref%tangent_reuse = .FALSE. - ref%fc_valid = .FALSE. + CALL require(a%fc_valid .AND. ref%fc_valid .AND. same_bits(a%fc_R, ref%fc_R), & + 'A: restore brings back the cached committed force of the snapshot') DO k = 1, 3 CALL CD_HermiteCable_Dyn_Step(a, DT, 50, 1.0e-10_wp, es, em) CALL CD_HermiteCable_Dyn_Step(ref, DT, 50, 1.0e-10_wp, es, em) diff --git a/tests/test_hermite_cable_static.f90 b/tests/test_hermite_cable_static.f90 index c1b6d44..0dd1b2a 100644 --- a/tests/test_hermite_cable_static.f90 +++ b/tests/test_hermite_cable_static.f90 @@ -18,7 +18,8 @@ PROGRAM test_hermite_cable_static USE CableDyn_HermiteCableDynamic, ONLY: CD_HermiteCableDynType, CD_HermiteCable_Dyn_Init, & CD_HermiteCable_Dyn_Set_Drag, CD_HermiteCable_Dyn_Reaction, & CD_HermiteCable_Dyn_End, CD_HCDYN_OK, CD_HermiteCable_Dyn_End_Force, & - CD_HermiteCable_Drag_Element, CD_HermiteCable_Dyn_Step + CD_HermiteCable_Drag_Element, CD_HermiteCable_Dyn_Step, & + CD_HermiteCable_Dyn_Node_Recovery USE CableDyn_SeabedContact, ONLY: CD_HermiteCable_Friction_Spring => CD_Seabed_Friction_Spring, & CD_Seabed_End_Element_Share, CD_Seabed_Normal_Law USE CableDyn_Line, ONLY: CD_Nodal_Seabed_Stiffness, CD_LINE_OK @@ -41,6 +42,7 @@ PROGRAM test_hermite_cable_static CALL check_seabed_mesh_jump() CALL check_scalar_contact_on_slope() CALL check_taut_line_anchored_on_floor() + CALL check_laid_line_node_recovery() CALL check_end_support_energy_gradient() CALL check_frictionless_slope_run() CALL check_long_sequence_hierarchy() @@ -385,6 +387,55 @@ SUBROUTINE check_taut_line_anchored_on_floor() END DO END SUBROUTINE check_taut_line_anchored_on_floor + SUBROUTINE check_laid_line_node_recovery() + !! A straight line laid on the scalar penalty floor of CD_HermiteCable_Dyn_Init (no contact + !! model), both ends held exactly on the floor: the floor carries the weight, so the + !! recovered bend moment is only that of the line settling into the floor, at the held + !! ends too. An end node on the + !! floor has no contact force of its own; its laid end element is still grounded (the + !! held-end floor support), so the reaction of the next node stays shared with it. Counted + !! as suspended, the end element lost that share and the anchor and its neighbour showed a + !! weight moment of order w l0^2/12 (521 N.m on this line, 31 %; the bending of the line + !! settling 4 mm into the penalty floor next to its held ends is 4.6 N.m, 0.3 %). + INTEGER, PARAMETER :: NE = 20, NN = NE + 1 + REAL(wp) :: l0(NE), EA(NE), EI(NE), w(NE), rho_a(NE), seed(6*NN), q(6*NN), curv(NN), res, kn + REAL(wp) :: trib(NN), tension(NN), bend(NN), scale + INTEGER :: fixed(6 + 2*NN), nfix, i, es, it + TYPE(CD_HermiteCableDynType) :: dyn + CHARACTER(512) :: em + l0 = 5.0_wp; EA = 5.0e8_wp; EI = 2.0e4_wp; w = 800.0_wp; rho_a = 120.0_wp; kn = 2.0e5_wp + trib = 0.0_wp + DO i = 1, NE + trib(i) = trib(i) + 0.5_wp*l0(i); trib(i + 1) = trib(i + 1) + 0.5_wp*l0(i) + END DO + ! both ends on the 100 m floor, 100 m apart: a 100 m line laid straight + CALL CD_HermiteCable_Trivial_Seed([0.0_wp, 0.0_wp, -100.0_wp], [100.0_wp, 0.0_wp, -100.0_wp], l0, -100.0_wp, & + seed, es, em) + CALL require(es == CD_HCSTAT_OK, 'laid line seed: '//TRIM(em)) + fixed(1:6) = [1, 2, 3, 6*NE + 1, 6*NE + 2, 6*NE + 3] + nfix = 6 + DO i = 1, NN + fixed(nfix + 1:nfix + 2) = [6*i - 4, 6*i - 1] + nfix = nfix + 2 + END DO + CALL CD_HermiteCable_Static_Solve(l0, EA, EI, w, seed, fixed(1:nfix), -100.0_wp, 0.0_wp, 4, 200, 1.0e-10_wp, & + 1.0_wp, q, curv, res, it, es, em, contact_kn=kn*trib) + CALL require(es == CD_HCSTAT_OK, 'laid line on the floor: '//TRIM(em)) + IF (es /= CD_HCSTAT_OK) RETURN + CALL CD_HermiteCable_Dyn_Init(dyn, l0, EA, EI, rho_a, w, q, fixed(1:nfix), -100.0_wp, kn, 0.8_wp, es, em) + CALL require(es == CD_HCDYN_OK, 'laid line dynamics: '//TRIM(em)) + IF (es /= CD_HCDYN_OK) RETURN + CALL CD_HermiteCable_Dyn_Node_Recovery(dyn, es, em, tension=tension, bend_moment=bend) + CALL require(es == CD_HCDYN_OK, 'laid line node recovery: '//TRIM(em)) + scale = w(1)*l0(1)**2/12.0_wp + WRITE (*, '(A,ES10.3,A,I0,A,ES10.3,A)') ' laid line: max |bend moment| ', MAXVAL(ABS(bend)), & + ' N.m at node ', MAXLOC(ABS(bend)), & + ' (w l0^2/12 = ', scale, ' N.m)' + CALL require(MAXVAL(ABS(bend)) <= 0.02_wp*scale, & + 'laid line: no weight moment at the held ends or along the line') + CALL CD_HermiteCable_Dyn_End(dyn) + END SUBROUTINE check_laid_line_node_recovery + SUBROUTINE check_end_support_energy_gradient() !! The energy line search (energy_minimization) tests the potential against R.dq, so the !! residual must be the gradient of the potential it evaluates. With the end element's diff --git a/tests/test_hermite_node_reaction.f90 b/tests/test_hermite_node_reaction.f90 new file mode 100644 index 0000000..9f4f49e --- /dev/null +++ b/tests/test_hermite_node_reaction.f90 @@ -0,0 +1,264 @@ +! File: tests/test_hermite_node_reaction.f90 +! SPDX-License-Identifier: Apache-2.0 +! Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +PROGRAM test_hermite_node_reaction + !! CD_HermiteCable_Dyn_Reaction assembles only the rows of its node (the one or two elements + !! that reach it and the node's own terms). It must equal the reaction of the full assembly + !! bit for bit at every node and leave the model's residual and mass scratch untouched: + !! A. hand-built cables with every load section a node can receive (structure, weight, + !! dry buoyancy, contact with friction on a controlled seabed, axial damping, drag in a + !! current, added mass and Froude-Krylov loads; Airy waves, or a held fluid field with + !! attachments; finite, rigid or no end connections), at scattered states; + !! B. the maintained IEA-15 MW mixed mooring example through the OpenFAST aggregate, along a + !! coupled surge and heave trajectory in a current, and after a fairlead transfer without + !! a step (the state the OpenFAST CalcOutput evaluates). + USE CableDyn_Precision, ONLY: wp + USE CableDyn_HermiteCableDynamic, ONLY: CD_HermiteCableDynType, CD_HermiteCable_Dyn_Init, & + CD_HermiteCable_Dyn_Reaction, CD_HermiteCable_Dyn_Set_Contact, & + CD_HermiteCable_Dyn_Set_Control_Seabed, & + CD_HermiteCable_Dyn_Set_Attachments, CD_HermiteCable_Dyn_Set_Axial_Damping, & + CD_HermiteCable_Dyn_Set_Drag, CD_HermiteCable_Dyn_Set_AddedMass, & + CD_HermiteCable_Dyn_Set_Waves, CD_HermiteCable_Dyn_Set_Held_Fluid, & + CD_HermiteCable_Dyn_Set_EndConnection, CD_HermiteCable_Dyn_End, & + CD_HCDYN_OK + USE CableDyn_EndConnection, ONLY: CD_ENDCONN_FINITE, CD_ENDCONN_RIGID + USE CableDyn_Line, ONLY: CD_Nodal_Seabed_Stiffness, CD_LINE_OK + USE CableDyn_OpenFAST_Aggregate, ONLY: CD_AGG_ModuleType, CD_AGG_Init_From_Deck, CD_AGG_NMovingPoints, & + CD_AGG_NFluidNodes, CD_AGG_GetMovingPointMesh, CD_AGG_SetFluidFields, & + CD_AGG_Step_Moving, CD_AGG_UpdateStates_Moving, CD_AGG_End, CD_AGG_OK + USE, INTRINSIC :: ISO_FORTRAN_ENV, ONLY: INT64 + IMPLICIT NONE + + INTEGER :: nfail, ncompared + INTEGER(INT64) :: seed + + nfail = 0 + ncompared = 0 + seed = 20261021_INT64 + CALL case_hand_built(waves=.TRUE., mode=CD_ENDCONN_FINITE) + CALL case_hand_built(waves=.FALSE., mode=CD_ENDCONN_FINITE) + CALL case_hand_built(waves=.FALSE., mode=CD_ENDCONN_RIGID) + CALL case_hand_built(waves=.TRUE., mode=-1) + IF (COMMAND_ARGUMENT_COUNT() >= 1) CALL case_mixed_example() + IF (nfail > 0) THEN + WRITE (*, '(A,I0,A)') 'FAIL: ', nfail, ' assertion(s) failed' + ERROR STOP 1 + END IF + WRITE (*, '(A,I0,A)') 'PASS: node reactions equal the full assembly bit for bit (', ncompared, ' nodes)' + +CONTAINS + + SUBROUTINE require(cond, msg) + LOGICAL, INTENT(IN) :: cond + CHARACTER(*), INTENT(IN) :: msg + IF (.NOT. cond) THEN + WRITE (*, '(A)') 'FAIL: '//TRIM(msg) + nfail = nfail + 1 + END IF + END SUBROUTINE require + + REAL(wp) FUNCTION uniform() RESULT(u) + !! Deterministic uniform deviate in [-1, 1) (xorshift64, no integer overflow), the same on + !! every platform. + seed = IEOR(seed, ISHFT(seed, 13)) + seed = IEOR(seed, ISHFT(seed, -7)) + seed = IEOR(seed, ISHFT(seed, 17)) + u = REAL(ISHFT(seed, -11), wp)/REAL(ISHFT(1_INT64, 53), wp) + u = 2.0_wp*u - 1.0_wp + END FUNCTION uniform + + LOGICAL FUNCTION same_bits(a, b) RESULT(same) + REAL(wp), INTENT(IN) :: a(:), b(:) + same = SIZE(a) == SIZE(b) + IF (same) same = ALL(TRANSFER(a, 0_INT64, SIZE(a)) == TRANSFER(b, 0_INT64, SIZE(b))) + END FUNCTION same_bits + + SUBROUTINE compare_nodes(model, label, nodes) + !! At each node: the node reaction equals the full-assembly reaction bit for bit, and it + !! leaves the residual and total-mass scratch of the full one untouched. + TYPE(CD_HermiteCableDynType), INTENT(INOUT) :: model + CHARACTER(*), INTENT(IN) :: label + INTEGER, INTENT(IN) :: nodes(:) + REAL(wp) :: f_full(3), f_node(3) + REAL(wp), ALLOCATABLE :: r_saved(:), m_saved(:) + INTEGER :: k, es1, es2 + CHARACTER(512) :: em + CHARACTER(16) :: tag + DO k = 1, SIZE(nodes) + WRITE (tag, '(I0)') nodes(k) + CALL CD_HermiteCable_Dyn_Reaction(model, nodes(k), f_full, es1, em, full_assembly=.TRUE.) + CALL require(es1 == CD_HCDYN_OK, label//' node '//TRIM(tag)//': full reaction: '//TRIM(em)) + r_saved = model%ws_R + m_saved = RESHAPE(model%ws_Msumb, [SIZE(model%ws_Msumb)]) + CALL CD_HermiteCable_Dyn_Reaction(model, nodes(k), f_node, es2, em) + CALL require(es2 == CD_HCDYN_OK, label//' node '//TRIM(tag)//': node reaction: '//TRIM(em)) + CALL require(same_bits(f_node, f_full), label//' node '//TRIM(tag)//': node reaction equals the full one') + CALL require(same_bits(model%ws_R, r_saved) .AND. & + same_bits(RESHAPE(model%ws_Msumb, [SIZE(model%ws_Msumb)]), m_saved), & + label//' node '//TRIM(tag)//': node reaction leaves the scratch untouched') + IF (.NOT. same_bits(f_node, f_full)) WRITE (*, '(2A,3ES24.16/A,3ES24.16)') ' node ', TRIM(tag), f_node, & + ' full ', f_full + ncompared = ncompared + 1 + END DO + END SUBROUTINE compare_nodes + + SUBROUTINE case_hand_built(waves, mode) + !! A 20-element cable from a fixed anchor on a controlled seabed rising to a fixed top, with + !! every node section, compared at all nodes over scattered positions (some below the bed), + !! velocities and accelerations. Attachments take the held field, so they come with it; + !! mode < 0 leaves the ends without a connection. + LOGICAL, INTENT(IN) :: waves + INTEGER, INTENT(IN) :: mode + INTEGER, PARAMETER :: NE = 20, NN = NE + 1, NDOF = 6*NN + REAL(wp), PARAMETER :: L = 2.0_wp, ZBED = -20.0_wp, KBOT = 1.0e5_wp, CBOT = 1.0e4_wp + TYPE(CD_HermiteCableDynType) :: m + REAL(wp) :: l0(NE), diam(NE), q(NDOF), d0(3, 2), k_rot(2), fv(3, NN), fa(3, NN), wl(NN), s, z + REAL(wp), ALLOCATABLE :: kn(:), cn(:) + INTEGER :: i, k, trial, es + CHARACTER(512) :: em + CHARACTER(32) :: label + + WRITE (label, '(A,L1,A,I0)') 'A waves=', waves, ' mode=', mode + l0 = L + diam = 0.15_wp + q = 0.0_wp + DO i = 1, NN + ! laid on the bed for the first half, then rising at 45 degrees + s = REAL(i - 1, wp)*L + IF (i <= NN/2) THEN + q(6*(i - 1) + 1) = s + q(6*(i - 1) + 3) = ZBED + q(6*(i - 1) + 4) = 1.0_wp + ELSE + q(6*(i - 1) + 1) = REAL(NN/2 - 1, wp)*L + (s - REAL(NN/2 - 1, wp)*L)/SQRT(2.0_wp) + q(6*(i - 1) + 3) = ZBED + (s - REAL(NN/2 - 1, wp)*L)/SQRT(2.0_wp) + q(6*(i - 1) + 4) = 1.0_wp/SQRT(2.0_wp) + q(6*(i - 1) + 6) = 1.0_wp/SQRT(2.0_wp) + END IF + END DO + CALL CD_HermiteCable_Dyn_Init(m, l0, [(1.0e6_wp, k=1, NE)], [(100.0_wp, k=1, NE)], [(5.0_wp, k=1, NE)], & + [(30.0_wp, k=1, NE)], q, [1, 2, 3, NDOF - 5, NDOF - 4, NDOF - 3], ZBED, 0.0_wp, & + 0.5_wp, es, em) + CALL require(es == CD_HCDYN_OK, TRIM(label)//': init: '//TRIM(em)) + IF (es /= CD_HCDYN_OK) RETURN + CALL CD_HermiteCable_Dyn_Set_Axial_Damping(m, [(2.0e4_wp, k=1, NE)], es, em) + CALL require(es == CD_HCDYN_OK, TRIM(label)//': damping: '//TRIM(em)) + CALL CD_Nodal_Seabed_Stiffness(KBOT, diam, l0, kn, es, em) + CALL require(es == CD_LINE_OK, TRIM(label)//': seabed stiffness: '//TRIM(em)) + cn = kn*(CBOT/KBOT) + CALL CD_HermiteCable_Dyn_Set_Contact(m, kn, cn, 0.3_wp, es, em) + CALL require(es == CD_HCDYN_OK, TRIM(label)//': contact: '//TRIM(em)) + CALL CD_HermiteCable_Dyn_Set_Control_Seabed(m, KBOT, diam, CBOT/KBOT, es, em) + CALL require(es == CD_HCDYN_OK, TRIM(label)//': control seabed: '//TRIM(em)) + CALL CD_HermiteCable_Dyn_Set_Drag(m, 1025.0_wp, diam, [(1.2_wp, k=1, NE)], [(0.008_wp, k=1, NE)], 0.0_wp, & + [0.6_wp, 0.2_wp, 0.0_wp], es, em) + CALL require(es == CD_HCDYN_OK, TRIM(label)//': drag: '//TRIM(em)) + CALL CD_HermiteCable_Dyn_Set_AddedMass(m, 1025.0_wp, diam, [(1.0_wp, k=1, NE)], [(0.1_wp, k=1, NE)], 0.0_wp, es, em) + CALL require(es == CD_HCDYN_OK, TRIM(label)//': added mass: '//TRIM(em)) + IF (waves) THEN + CALL CD_HermiteCable_Dyn_Set_Waves(m, 3.0_wp, 8.0_wp, 30.0_wp, -ZBED, 9.80665_wp, es, em) + CALL require(es == CD_HCDYN_OK, TRIM(label)//': waves: '//TRIM(em)) + ELSE + DO i = 1, NN + fv(:, i) = [0.5_wp, 0.1_wp*REAL(i, wp)/REAL(NN, wp), 0.05_wp] + fa(:, i) = [0.2_wp, -0.1_wp, 0.3_wp] + wl(i) = 0.4_wp*SIN(REAL(i, wp)) + END DO + CALL CD_HermiteCable_Dyn_Set_Held_Fluid(m, fv, fa, wl, es, em) + CALL require(es == CD_HCDYN_OK, TRIM(label)//': held fluid: '//TRIM(em)) + ! attachments next to each end and in the span, so both end rows receive one + CALL CD_HermiteCable_Dyn_Set_Attachments(m, [2, 7, NE], [50.0_wp, 80.0_wp, 20.0_wp], & + [0.01_wp, 0.03_wp, 0.02_wp], [0.1_wp, 0.2_wp, 0.0_wp], & + [0.05_wp, 0.1_wp, 0.0_wp], [1.0_wp, 1.0_wp, 0.5_wp], & + 1025.0_wp, 9.80665_wp, es, em) + CALL require(es == CD_HCDYN_OK, TRIM(label)//': attachments: '//TRIM(em)) + END IF + IF (mode >= 0) THEN + d0(:, 1) = [1.0_wp, 0.0_wp, 0.0_wp] + d0(:, 2) = [1.0_wp, 0.0_wp, 1.0_wp]/SQRT(2.0_wp) + IF (mode == CD_ENDCONN_RIGID) THEN + k_rot = 0.0_wp + ELSE + k_rot = [5.0e3_wp, 8.0e3_wp] + END IF + CALL CD_HermiteCable_Dyn_Set_EndConnection(m, k_rot, d0, es, em, connection_mode=[mode, mode]) + CALL require(es == CD_HCDYN_OK, TRIM(label)//': end connections: '//TRIM(em)) + END IF + IF (nfail > 0) RETURN + + CALL compare_nodes(m, TRIM(label)//' rest', [(i, i=1, NN)]) + DO trial = 1, 6 + ! scattered state: positions within +-5 cm (bed nodes partly penetrating), velocities and + ! accelerations of order 1, at a later time + DO i = 1, NDOF + m%q(i) = q(i) + 0.05_wp*uniform() + m%v(i) = uniform() + m%a(i) = 2.0_wp*uniform() + END DO + DO i = 1, NN + z = q(6*(i - 1) + 3) + IF (z <= ZBED) m%q(6*(i - 1) + 3) = ZBED - 0.02_wp*ABS(uniform()) + END DO + m%t = 0.37_wp*REAL(trial, wp) + CALL compare_nodes(m, TRIM(label)//' scattered', [(i, i=1, NN)]) + END DO + CALL CD_HermiteCable_Dyn_End(m) + END SUBROUTINE case_hand_built + + SUBROUTINE case_mixed_example() + !! The mixed mooring example (EI = 0 chains and a grounded finite-EI power cable with contact, + !! friction, drag and added mass, host fluid held per step) along 4 s of coupled surge and + !! heave in a current, at every node of the cable every 0.5 s. + REAL(wp), PARAMETER :: DT = 0.025_wp, DURATION = 4.0_wp, PERIOD = 4.0_wp + TYPE(CD_AGG_ModuleType) :: agg + CHARACTER(1024) :: deck + CHARACTER(512) :: em + REAL(wp), ALLOCATABLE :: p0(:, :), p(:, :), v(:, :), a(:, :), load(:, :), fv(:, :), fa(:, :), wl(:) + REAL(wp) :: t, omega, pi + INTEGER :: es, ncp, nf, s, ni, nn, i, ccol + LOGICAL :: converged, stalled + + CALL GET_COMMAND_ARGUMENT(1, deck) + CALL CD_AGG_Init_From_Deck(agg, TRIM(deck), DT, es, em, external_fluid=.TRUE.) + CALL require(es == CD_AGG_OK, 'B: init: '//TRIM(em)) + IF (es /= CD_AGG_OK) RETURN + CALL require(ALLOCATED(agg%cables), 'B: the example builds a finite-EI cable') + IF (.NOT. ALLOCATED(agg%cables)) RETURN + nn = agg%cables(1)%line%nn + ncp = CD_AGG_NMovingPoints(agg, es, em) + nf = CD_AGG_NFluidNodes(agg, es, em) + ALLOCATE (p0(3, ncp), p(3, ncp), v(3, ncp), a(3, ncp), load(3, ncp), fv(3, nf), fa(3, nf), wl(nf)) + CALL CD_AGG_GetMovingPointMesh(agg, p0, v, a, load, es, em) + CALL require(es == CD_AGG_OK, 'B: initial mesh: '//TRIM(em)) + CALL compare_nodes(agg%cables(1)%line, 'B static', [(i, i=1, nn)]) + fv = 0.0_wp + fv(1, :) = 0.5_wp + fa = 0.0_wp + wl = 0.0_wp + pi = ACOS(-1.0_wp) + omega = 2.0_wp*pi/PERIOD + DO s = 1, NINT(DURATION/DT) + t = REAL(s, wp)*DT + p = p0 + p(1, :) = p0(1, :) + 1.5_wp*SIN(omega*t)*MIN(t/PERIOD, 1.0_wp) + p(3, :) = p0(3, :) + 0.8_wp*(1.0_wp - COS(omega*t))*MIN(t/PERIOD, 1.0_wp) + v = 0.0_wp + a = 0.0_wp + CALL CD_AGG_SetFluidFields(agg, fv, fa, wl, es, em) + IF (es == CD_AGG_OK) CALL CD_AGG_Step_Moving(agg, DT, p, v, a, converged, stalled, ni, es, em, t_committed=t) + CALL require(es == CD_AGG_OK .AND. converged, 'B: step: '//TRIM(em)) + IF (es /= CD_AGG_OK .OR. .NOT. converged) EXIT + IF (MOD(s, 20) == 0) CALL compare_nodes(agg%cables(1)%line, 'B march', [(i, i=1, nn)]) + END DO + ! a fairlead transfer without a step: the end element carries the current kinematics against + ! the committed interior, as in the OpenFAST CalcOutput + ccol = agg%ncp_sys + 1 + p(:, ccol) = p(:, ccol) + [0.3_wp, -0.2_wp, 0.25_wp] + v(:, ccol) = [0.4_wp, 0.1_wp, -0.3_wp] + CALL CD_AGG_UpdateStates_Moving(agg, p, v, a, es, em) + CALL require(es == CD_AGG_OK, 'B: transfer: '//TRIM(em)) + CALL compare_nodes(agg%cables(1)%line, 'B transfer', [1, 2, 3, nn - 2, nn - 1, nn]) + CALL CD_AGG_End(agg, es, em) + END SUBROUTINE case_mixed_example + +END PROGRAM test_hermite_node_reaction diff --git a/tests/test_load_predictor.f90 b/tests/test_load_predictor.f90 new file mode 100644 index 0000000..906bfeb --- /dev/null +++ b/tests/test_load_predictor.f90 @@ -0,0 +1,77 @@ +! File: tests/test_load_predictor.f90 +! SPDX-License-Identifier: Apache-2.0 +! Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +PROGRAM test_load_predictor + !! The load predictor between supercycled solves (CableDyn_LoadPredictor): + !! * it is used only between consecutive commits (exactly one dtM apart, within 1e-6 dtM), + !! never without a previous commit or with a non-positive dtM; + !! * the elapsed fraction is clamped to [0, 1]; + !! * at the commit (tau = 0) the served loads are the committed ones bit for bit; + !! * a load linear in time is reproduced, and a quadratic one t^2 is in error by exactly + !! tau (1 + tau) dtM^2 (second order; the hold is first order). + USE CableDyn_Precision, ONLY: wp + USE CableDyn_LoadPredictor, ONLY: CD_Predictor_Ready, CD_Predictor_Fraction, CD_Predict_Loads + IMPLICIT NONE + + REAL(wp), PARAMETER :: DT = 0.1_wp, TC = 37.3_wp + REAL(wp) :: f_last(3, 2), f_prev(3, 2), f(3, 2), tau, t, expect + INTEGER :: nfail, k + + nfail = 0 + + CALL require(CD_Predictor_Ready(TC, TC - DT, DT), 'consecutive commits are ready') + CALL require(CD_Predictor_Ready(TC, TC - DT*(1.0_wp + 0.5e-6_wp), DT), 'within 1e-6 dtM is ready') + CALL require(.NOT. CD_Predictor_Ready(TC, TC - DT*(1.0_wp + 2.0e-6_wp), DT), 'beyond 1e-6 dtM is not ready') + CALL require(.NOT. CD_Predictor_Ready(TC, TC - 2.0_wp*DT, DT), 'a skipped commit is not ready') + CALL require(.NOT. CD_Predictor_Ready(TC, -1.0_wp, DT), 'no previous commit is not ready') + CALL require(CD_Predictor_Ready(DT, 0.0_wp, DT), 'the static state at t = 0 is a previous commit') + CALL require(.NOT. CD_Predictor_Ready(TC, TC - DT, 0.0_wp), 'a zero dtM is not ready') + + CALL require(CD_Predictor_Fraction(TC - 0.01_wp, TC, DT) == 0.0_wp, 'the fraction is not negative') + CALL require(CD_Predictor_Fraction(TC + 2.0_wp*DT, TC, DT) == 1.0_wp, 'the fraction is at most one') + CALL require(ABS(CD_Predictor_Fraction(TC + 0.075_wp, TC, DT) - 0.75_wp) <= 1.0e-12_wp, 'three quarters') + + ! a load linear in time, a + b t, sampled at the two commits + DO k = 1, 2 + f_prev(:, k) = [1.0e6_wp, -2.5e5_wp, 3.0e4_wp]*REAL(k, wp) + [4.0e5_wp, 1.0e3_wp, -7.0e4_wp]*(TC - DT) + f_last(:, k) = [1.0e6_wp, -2.5e5_wp, 3.0e4_wp]*REAL(k, wp) + [4.0e5_wp, 1.0e3_wp, -7.0e4_wp]*TC + END DO + CALL CD_Predict_Loads(f_last, f_prev, 0.0_wp, f) + CALL require(ALL(f == f_last), 'at the commit the committed loads are served bit for bit') + DO k = 1, 3 + tau = 0.25_wp*REAL(k, wp) + t = TC + tau*DT + CALL CD_Predict_Loads(f_last, f_prev, tau, f) + CALL require(ALL(ABS(f(:, 2) - ([2.0e6_wp, -5.0e5_wp, 6.0e4_wp] + [4.0e5_wp, 1.0e3_wp, -7.0e4_wp]*t)) <= & + 1.0e-9_wp*MAXVAL(ABS(f_last))), 'a linear load is reproduced') + END DO + + ! a quadratic load t^2: error tau (1 + tau) dtM^2 + f_prev = (TC - DT)**2 + f_last = TC**2 + DO k = 1, 3 + tau = 0.25_wp*REAL(k, wp) + t = TC + tau*DT + CALL CD_Predict_Loads(f_last, f_prev, tau, f) + expect = tau*(1.0_wp + tau)*DT**2 + CALL require(ABS((t**2 - f(1, 1)) - expect) <= 1.0e-9_wp*expect, 'the quadratic error is tau (1 + tau) dtM^2') + END DO + + IF (nfail > 0) THEN + WRITE (*, '(A,I0,A)') 'FAIL: ', nfail, ' assertion(s) failed' + ERROR STOP 1 + END IF + WRITE (*, '(A)') 'PASS: load predictor (readiness, fraction, extrapolation order)' + +CONTAINS + + SUBROUTINE require(cond, msg) + LOGICAL, INTENT(IN) :: cond + CHARACTER(*), INTENT(IN) :: msg + IF (.NOT. cond) THEN + WRITE (*, '(A)') 'FAIL: '//TRIM(msg) + nfail = nfail + 1 + END IF + END SUBROUTINE require + +END PROGRAM test_load_predictor diff --git a/tests/test_openfast_aggregate.f90 b/tests/test_openfast_aggregate.f90 index 5dd249b..7736be0 100644 --- a/tests/test_openfast_aggregate.f90 +++ b/tests/test_openfast_aggregate.f90 @@ -1222,6 +1222,16 @@ SUBROUTINE case_cable_no_advance_transfer() CALL require(NORM2(l1(:, 1) - l0m(:, 1)) < 1.0e-6_wp, 'transfer:mooring-load-unchanged') WRITE (*, '(A,ES12.4,A,ES12.4)') 'no-advance transfer: cable |dLoad| = ', & NORM2(l1(:, ccol) - l0m(:, ccol)), ' N; mooring |dLoad| = ', NORM2(l1(:, 1) - l0m(:, 1)) + ! A kinematics-only read (cable_loads = .FALSE., the OpenFAST CalcOutput save) returns the + ! same kinematics and mooring load bit for bit, zero cable load, and leaves the cable's next + ! reaction unchanged. + CALL CD_AGG_GetMovingPointMesh(agg, pos, vel, acc, l0m, es, em, cable_loads=.FALSE.) + CALL require(es == CD_AGG_OK, 'transfer:mesh-kinematics-only: '//TRIM(em)) + CALL require(ALL(pos == p1) .AND. ALL(vel == v1) .AND. ALL(acc == a1), 'transfer:kinematics-only-same-kinematics') + CALL require(ALL(l0m(:, 1) == l1(:, 1)), 'transfer:kinematics-only-mooring-load') + CALL require(ALL(l0m(:, ccol) == 0.0_wp), 'transfer:kinematics-only-cable-load-zero') + CALL CD_AGG_GetMovingPointMesh(agg, pos, vel, acc, l0m, es, em) + CALL require(es == CD_AGG_OK .AND. ALL(l0m == l1), 'transfer:kinematics-only-leaves-reaction') CALL CD_AGG_End(agg, es, em) END SUBROUTINE case_cable_no_advance_transfer diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py new file mode 100644 index 0000000..44cbf9f --- /dev/null +++ b/tests/test_packaging_metadata.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The published distribution (pyproject.toml) and the development package (python/pyproject.toml) +describe the same project: their [project] tables agree on everything but the readme and licence +files (paths relative to each file) and the classifiers (the platform wheels name their operating +systems and Fortran; the pure package is OS independent).""" + +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + try: + import tomli as tomllib + except ModuleNotFoundError: + tomllib = None + +ROOT = Path(__file__).resolve().parents[1] +PER_FILE = {"readme", "license-files", "classifiers"} + + +def _project(path: Path) -> dict: + with path.open("rb") as fh: + return tomllib.load(fh)["project"] + + +@unittest.skipIf(tomllib is None, "needs tomllib (Python 3.11+) or tomli") +class PackagingMetadataTest(unittest.TestCase): + def setUp(self): + self.published = _project(ROOT / "pyproject.toml") + self.development = _project(ROOT / "python" / "pyproject.toml") + + def test_the_two_projects_agree(self): + keys = (set(self.published) | set(self.development)) - PER_FILE + for key in sorted(keys): + with self.subTest(key=key): + self.assertEqual(self.published.get(key), self.development.get(key)) + + def test_per_file_fields_point_at_existing_files(self): + self.assertTrue((ROOT / self.published["readme"]).is_file()) + self.assertTrue((ROOT / "python" / self.development["readme"]).is_file()) + for name in self.published["license-files"]: + self.assertTrue((ROOT / name).is_file(), name) + for name in self.development["license-files"]: + self.assertTrue((ROOT / "python" / name).is_file(), name) + + def test_the_wheel_classifiers_differ_only_in_the_platforms(self): + published = set(self.published["classifiers"]) + development = set(self.development["classifiers"]) + self.assertEqual(development - published, {"Operating System :: OS Independent"}) + self.assertEqual( + published - development, + { + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Programming Language :: Fortran", + }, + ) + + +def _lapack_pins(script: Path) -> dict: + """The reference LAPACK version, archive checksum and Fortran flags a build script pins.""" + text = script.read_text(encoding="utf-8") + pins = {} + for key, pattern in ( + ("version", r"\$lapackVersion = '([^']+)'"), + ("sha256", r"\$lapackSha256 = '([0-9a-f]{64})'"), + ("flags", r'"-DCMAKE_Fortran_FLAGS=([^"]+)"'), + ): + found = re.findall(pattern, text) + if len(found) != 1: + raise AssertionError(f"{script.name}: expected one {key} pin, found {found}") + pins[key] = found[0] + return pins + + +class WindowsWheelLapackTest(unittest.TestCase): + """The Windows wheel links the same reference LAPACK as the static release executables: + one version, one archive checksum, one set of Fortran flags (x86-64-v3 included).""" + + def test_the_wheel_and_the_release_pin_the_same_lapack(self): + release = _lapack_pins(ROOT / "release" / "build_static_windows.ps1") + wheel = _lapack_pins(ROOT / "release" / "build_windows_wheel.ps1") + self.assertEqual(wheel, release) + self.assertIn("/arch:CORE-AVX2", release["flags"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_profile.f90 b/tests/test_profile.f90 index ed1244a..a47b00b 100644 --- a/tests/test_profile.f90 +++ b/tests/test_profile.f90 @@ -12,6 +12,10 @@ PROGRAM test_profile !! finalisation of such a run) stays exactly zero. !! 4. The EI = 0 line-step counters are exact when the lines of an OpenMP team update them !! at once. + !! 5. The EI = 0 kernel counters: unarmed, a kernel records nothing; armed, the calls of each + !! kernel are exact under an OpenMP team and their times add up; a kernel that began + !! before the profile was armed is not counted; disarmed, nothing more is recorded and + !! the counts so far stay readable; arming again zeroes them. USE CableDyn_Precision, ONLY: wp USE CableDyn_Profile USE CableDyn_FatalReport, ONLY: CD_Fatal_Thread_Init @@ -23,6 +27,7 @@ PROGRAM test_profile CALL case_unarmed() CALL case_partition_and_failure() CALL case_concurrent_counts() + CALL case_kernels() IF (nfail > 0) THEN WRITE (*, '(A,I0,A)') 'test_profile: ', nfail, ' failure(s)' ERROR STOP 1 @@ -123,4 +128,52 @@ SUBROUTINE case_concurrent_counts() CALL require(max_iterations == 6, 'counts:most-iterations') END SUBROUTINE case_concurrent_counts + SUBROUTINE case_kernels() + INTEGER, PARAMETER :: N = 20000 + INTEGER(int64) :: calls(CD_PROF_NKERNEL), c0 + REAL(wp) :: seconds(CD_PROF_NKERNEL) + INTEGER :: i, kernel + ! unarmed: nothing is recorded + CALL CD_Profile_Disarm() + CALL CD_Profile_Kernel_Begin(c0) + CALL require(c0 == 0_int64, 'kernels:unarmed-begin-has-no-start') + CALL CD_Profile_Arm() + CALL CD_Profile_Disarm() + CALL CD_Profile_Kernel_End(CD_PROF_K_FACTOR, 1_int64) + CALL CD_Profile_Kernels(calls, seconds) + CALL require(ALL(calls == 0_int64) .AND. .NOT. ANY(seconds > 0.0_wp), 'kernels:unarmed-records-nothing') + ! armed: exact calls of each kernel from an OpenMP team, and a measurable time + CALL CD_Profile_Arm() + !$OMP PARALLEL NUM_THREADS(4) DEFAULT(SHARED) PRIVATE(i, kernel, c0) + CALL CD_Fatal_Thread_Init() + !$OMP DO SCHEDULE(STATIC) + DO i = 1, N + kernel = 1 + MOD(i, CD_PROF_NKERNEL) + CALL CD_Profile_Kernel_Begin(c0) + CALL CD_Profile_Kernel_End(kernel, c0) + END DO + !$OMP END DO + !$OMP END PARALLEL + CALL CD_Profile_Kernel_Begin(c0) + CALL busy(0.01_wp) + CALL CD_Profile_Kernel_End(CD_PROF_K_TANGENT, c0) + ! a kernel that began unarmed (start 0) and an unknown kernel are not counted + CALL CD_Profile_Kernel_End(CD_PROF_K_SOLVE, 0_int64) + CALL CD_Profile_Kernel_End(CD_PROF_NKERNEL + 1, c0) + CALL CD_Profile_Kernels(calls, seconds) + CALL require(calls(CD_PROF_K_RESIDUAL) == N/CD_PROF_NKERNEL, 'kernels:residual-calls-exact') + CALL require(calls(CD_PROF_K_TANGENT) == N/CD_PROF_NKERNEL + 1, 'kernels:tangent-calls-exact') + CALL require(calls(CD_PROF_K_FACTOR) == N/CD_PROF_NKERNEL, 'kernels:factor-calls-exact') + CALL require(calls(CD_PROF_K_SOLVE) == N/CD_PROF_NKERNEL, 'kernels:solve-calls-exact') + CALL require(seconds(CD_PROF_K_TANGENT) >= 0.009_wp .AND. ALL(seconds >= 0.0_wp), 'kernels:times') + ! disarmed: nothing more is recorded, the counts stay readable; arming again zeroes them + CALL CD_Profile_Disarm() + CALL CD_Profile_Kernel_End(CD_PROF_K_FACTOR, c0) + CALL CD_Profile_Kernels(calls, seconds) + CALL require(calls(CD_PROF_K_FACTOR) == N/CD_PROF_NKERNEL, 'kernels:disarmed-keeps-counts') + CALL CD_Profile_Arm() + CALL CD_Profile_Kernels(calls, seconds) + CALL require(ALL(calls == 0_int64), 'kernels:arm-zeroes') + END SUBROUTINE case_kernels + END PROGRAM test_profile diff --git a/tests/test_supercycle_ab.py b/tests/test_supercycle_ab.py new file mode 100644 index 0000000..08a3dd6 --- /dev/null +++ b/tests/test_supercycle_ab.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The rainflow count and damage-equivalent load of validation/scripts/openfast_supercycle_ab.py.""" + +from __future__ import annotations + +import importlib.util +import math +import unittest +from collections import Counter +from pathlib import Path + +_SCRIPT = Path(__file__).resolve().parents[1] / "validation" / "scripts" / "openfast_supercycle_ab.py" +_SPEC = importlib.util.spec_from_file_location("openfast_supercycle_ab", _SCRIPT) +ab = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(ab) + +# ASTM E1049-85 (2017), Fig. 6: the rainflow counts of this history +_ASTM_HISTORY = [-2.0, 1.0, -3.0, 5.0, -1.0, 3.0, -4.0, 4.0, -2.0] +_ASTM_COUNTS = {3.0: 0.5, 4.0: 1.5, 6.0: 0.5, 8.0: 1.0, 9.0: 0.5} + + +def _counts(history): + c = Counter() + for r, n in ab.rainflow(history): + c[r] += n + return dict(c) + + +class RainflowTest(unittest.TestCase): + def test_astm_example(self): + self.assertEqual(_counts(_ASTM_HISTORY), _ASTM_COUNTS) + + def test_plateaus_count_once(self): + # equal neighbours (a tension written to seven digits) neither add nor drop a turning point + plateaued = [-2.0, 1.0, 1.0, -3.0, -3.0, 5.0, 5.0, 5.0, -1.0, 3.0, -4.0, 4.0, -2.0] + self.assertEqual(_counts(plateaued), _ASTM_COUNTS) + self.assertEqual(ab.turning_points([1.0, 2.0, 2.0, 1.0]), [1.0, 2.0, 1.0]) + + def test_damage_equivalent_load(self): + # a sine of amplitude A over N whole periods in T seconds: N full cycles of range 2A + a, n, t = 3.0, 40, 200.0 + history = [a * ((-1.0) ** k) for k in range(2 * n + 1)] + expected = (n * (2.0 * a) ** 3 / t) ** (1.0 / 3.0) + self.assertAlmostEqual(ab.damage_equivalent_load(history, t, 3.0), expected, places=12) + + +class CompareChannelTest(unittest.TestCase): + @staticmethod + def _grid(values): + return {k: [0.1 * k, v] for k, v in enumerate(values)} + + def test_truncated_twin_is_compared_over_its_overlap(self): + # a run identical to the reference but ending at a tenth of it: no difference at all, + # and the partial coverage is reported (the DEL is not set against the whole reference) + ref = [3.0 * ((-1.0) ** k) * (1.0 + 0.01 * k) for k in range(1000)] + c = ab.compare_channel(self._grid(ref[:100]), 1, self._grid(ref), 1, 0.1, 3.0) + self.assertEqual(c["rel"], 0.0) + self.assertEqual(c["ddel"], 0.0) + self.assertAlmostEqual(c["covered"], 0.1) + + def test_a_scaled_run_changes_its_del_by_the_scale(self): + ref = [3.0 * ((-1.0) ** k) for k in range(400)] + c = ab.compare_channel(self._grid([1.02 * v for v in ref]), 1, self._grid(ref), 1, 0.1, 3.0) + self.assertAlmostEqual(c["ddel"], 2.0, places=9) + self.assertEqual(c["covered"], 1.0) + + def test_zero_damage_reference(self): + # a constant tension has no cycles: identical constant histories change by 0 %, and a run + # with cycles against a constant reference has an undefined change (not a crash) + flat = [1000.0] * 200 + same = ab.compare_channel(self._grid(flat), 1, self._grid(flat), 1, 0.1, 3.0) + self.assertEqual(same["del"], 0.0) + self.assertEqual(same["ddel"], 0.0) + wavy = ab.compare_channel(self._grid([1000.0 + (-1.0) ** k for k in range(200)]), 1, + self._grid(flat), 1, 0.1, 3.0) + self.assertGreater(wavy["del"], 0.0) + self.assertTrue(math.isnan(wavy["ddel"])) + + def test_no_common_samples(self): + self.assertIsNone(ab.compare_channel({0: [0.0, 1.0]}, 1, self._grid([1.0, 2.0, 3.0]), 1, 0.1, 3.0)) + self.assertEqual(ab.damage_equivalent_load([1.0, 2.0], 0.0, 3.0), 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/validation/RELEASE_0_3_0.md b/validation/RELEASE_0_3_0.md new file mode 100644 index 0000000..800feca --- /dev/null +++ b/validation/RELEASE_0_3_0.md @@ -0,0 +1,96 @@ + + +# CableDyn v0.3.0 release record + +This record lists what was built and tested for the v0.3.0 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.2.0](RELEASE_0_2_0.md). + +## Source and pins + +| Item | Value | +| --- | --- | +| CableDyn | `0.3.0` (2026-10-23), C ABI 1, minor extension 2 | +| Source | The release-preparation tree (`release/0.3.0` at `b9c01eb`): dev with #43 to #47 and the version and documentation changes of the release. The assets and the Windows gates were produced from this tree; afterwards only this record and the two experimental `provenance.json` files changed, which no build reads | +| OpenFAST base | `2895884d2be01862173c88d70f86b358d2f1a50a`; the 20 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`, now built for x86-64-v3 (`/arch:CORE-AVX2`) like the CableDyn core | + +## Toolchains + +| Item | Version | +| --- | --- | +| GNU Fortran (test builds) | conda-forge GCC 16.2.0, with OpenBLAS and LAPACK from the conda environment | +| Intel Fortran (Windows release) | IFX 2025.3.2, `/arch:CORE-AVX2 /Qfma-` (x86-64-v3, no contraction into fused multiply-adds) | +| Microsoft C/C++ | Visual Studio 2022, Windows SDK 10.0.22621.0 (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.16, build 1.6.1, Twine 7.0.0 (release workflow) | + +## Test results + +| Gate | Result | +| --- | --- | +| GNU Release CTest, including the slow tier (Windows, local) | 298 of 298 passed on the release tree | +| Python package and repository tests (local, native library and driver included) | 2052 passed, 114 skipped (platform- and tool-dependent cases) | +| CI matrix of #47 (the last change before the release tree) | Linux and Windows, Release and Debug; Python on three platforms with coverage and strict type checks | +| Sphinx documentation (`-W -n`) | built with no warnings | +| pre-commit, all files | passed | +| Release metadata | `check_release_metadata.py` passed (CableDyn v0.3.0, 2026-10-23) | +| Robustness campaigns (2026-10-15 decks, re-run on dev with #46) | 356 static cases, the 384-case rough-seabed sweep and 776 dynamic runs completed; no pass became a failure. Statics failures 47 -> 41, the rough sweep converges 192/192 for finite-EI lines (153 before), dynamic failures and time-outs 77 -> 43 | +| 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 `5f1d3b15b414ce7b7eb884db76e50901da6a0094c5548489f58cbfed7ac0546a`): every summary, metric and refinement result is reproduced byte for byte | +| Bodies suite (102 metrics) | 102 of 102 pass with a GNU Release build of dev with #46 | +| All shipped examples, dev vs the release tree (GNU) | all 51 run; 85 of 99 output files bit-identical; the others differ in touchdown node values of static profiles and range graphs (#47, at most 1.8 % of a channel's range); no time series changes | + +## Windows executable gates + +Every gate of `build_static_windows.ps1` passed in the release workflow (`release-windows-static.yml`, +run 39898803772). The smoke runs used a system-only `PATH`. + +| Gate | Result | +| --- | --- | +| Compiler | IFX 2025.3.2 (minimum 2025.3) | +| OpenFAST solution build | the first pass stopped in the NWTC library with the intermittent IFX parallel-compilation failure the script resumes after (`build_static_windows.ps1`); the resumed build compiled the 31 remaining projects with 0 errors | +| `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; reports `CableDyn build: x86-64-v3 (AVX2).` | +| Processor check | both executables stop at start with exit 3 and the message on a simulated missing x86-64-v3 feature | +| Standalone smoke | the shallow-chain static deck (`chain_catenary_shallow_30m.dat`) converges | +| Memory growth over a 10x longer run | 5.0 -> 5.0 MiB (`dynamic_chain_current.dat`), 21.2 -> 21.3 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, 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, 13 standalone decks (the torsion example included), +three `CompMooring = 5` runs (a 70 s JONSWAP sea, a 400 s line failure, and a checkpoint at 30 s +with a restart whose last 10 s of output must be bit-identical), and the Python wheel in a new +virtual environment, where it imports as 0.3.0 and runs the release driver. All 26 checks +passed. Every shipped example deck (51) also runs to completion with the release +`CableDyn_driver.exe`. + +## 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 (39898806566) +built the files below; the release run rebuilds them from the tag before the upload. The 0.2.0 +dry run through TestPyPI installed the Windows wheel into a new environment and passed the same +smoke test. + +| File | SHA-256 (preflight) | +| --- | --- | +| `cabledyn-0.3.0.tar.gz` | `2978cd01f1f981ff4ddf133f3e8aa742002bcae74f85e4645d1b081eed6ff510` | +| `cabledyn-0.3.0-py3-none-win_amd64.whl` | `9aaee5e0e07fd2f2d6e462dea61c88d13bf7a1e8f7c2a1b2cc66041cb0800c04` | +| `cabledyn-0.3.0-py3-none-macosx_14_0_arm64.whl` | `ceb4acedae58df952b7fffa4aafe03f7cdfbcfc95253dcaa57fb398add920c0e` | +| `cabledyn-0.3.0-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl` | `5cc58414a685cbfc7b19d25a422e61a671053dfcaf8a559e3e24e15a1be46cc6` | + +## Assets + +| File | SHA-256 | +| --- | --- | +| `CableDyn_driver.exe` | `f3878776d862a758a5a6bcb7d18f5c01ae394e480951d45c61b6719e9a6484ff` | +| `openfast.exe` | `16cd67bf349aec4a6170edf881f234aa2749b1bb7efee85bd5c9d4f3a76e1643` | +| `cabledyn-0.3.0-py3-none-any.whl` | `867a8d4b2a99ca9bcd8cc5cc64f0c46b626f74710cb9155e3b51abd86462fe16` | +| `cabledyn-0.3.0.tar.gz` | `106245fe7b6aa1a0205295cdc9c04c5ea874369bac73546e5a0d80c09de30160` | diff --git a/validation/experiments/bergdahl/provenance.json b/validation/experiments/bergdahl/provenance.json index 5bcddb9..1d647c4 100644 --- a/validation/experiments/bergdahl/provenance.json +++ b/validation/experiments/bergdahl/provenance.json @@ -1,8 +1,8 @@ { - "CableDyn_release": "v0.2.0", - "validation_date": "2026-10-20", + "CableDyn_release": "v0.3.0", + "validation_date": "2026-10-23", "executable_name": "cabledyn.exe", - "executable_sha256": "55fe625602eaef9c3bdb7bbee889858d3a50fa4baa66437801d9c102dc2424a3", + "executable_sha256": "5f1d3b15b414ce7b7eb884db76e50901da6a0094c5548489f58cbfed7ac0546a", "compiler": "GNU Fortran 16.2.0", "build_type": "Release", "platform": "Windows 11 x86-64", diff --git a/validation/experiments/holcombe/provenance.json b/validation/experiments/holcombe/provenance.json index 5d3221b..9836b3d 100644 --- a/validation/experiments/holcombe/provenance.json +++ b/validation/experiments/holcombe/provenance.json @@ -1,8 +1,8 @@ { - "CableDyn_release": "v0.2.0", - "validation_date": "2026-10-20", + "CableDyn_release": "v0.3.0", + "validation_date": "2026-10-23", "executable_name": "cabledyn.exe", - "executable_sha256": "55fe625602eaef9c3bdb7bbee889858d3a50fa4baa66437801d9c102dc2424a3", + "executable_sha256": "5f1d3b15b414ce7b7eb884db76e50901da6a0094c5548489f58cbfed7ac0546a", "compiler": "GNU Fortran 16.2.0", "build_type": "Release", "platform": "Windows 11 x86-64", diff --git a/validation/scripts/openfast_supercycle_ab.py b/validation/scripts/openfast_supercycle_ab.py new file mode 100644 index 0000000..fed2b80 --- /dev/null +++ b/validation/scripts/openfast_supercycle_ab.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Supercycling A/B: coupled CableDyn runs at a coarser dtM against the same model at dtM = DT. + +Twin OpenFAST runs of the SAME model and environment differ only in the CableDyn time step (and, +with dtM larger than DT, in how the coupled loads are served between CableDyn solves: deck +OPTIONS predictLoads True extrapolates them, False holds them). The reference is the run at +dtM = DT. Over the window t >= t_start, on the common grid of the coarsest committed CableDyn +samples (--grid, default 0.1 s), the script reports per channel: + + * platform DOFs (glue .out): mean, std, max and the RMS of the time-series difference + relative to the reference std; + * fairlead and anchor tensions (CableDyn .CD.out, committed samples): the same, plus the + damage-equivalent load DEL = (sum n_i S_i^m / T)^(1/m) from a rainflow count (ASTM E1049, + four-point method, residue counted as half cycles; a plateau of equal samples at the + output precision counts as one turning point), slope m (--slope, default 3 for steel chain), + 1 Hz equivalent count, and its change against the reference DEL over the same samples. + +Every statistic of a run is taken over the samples it shares with the reference; a run that +covers fewer of them (a shorter run, missing rows) is flagged in the report. + +It also reports the reference DEL at its native sampling against the common grid, the part of +a DEL difference that is sampling alone. It reports; it does not gate. + +Usage: + python validation/scripts/openfast_supercycle_ab.py REF_ROOT LABEL=ROOT [LABEL=ROOT ...] + [--t-start 200] [--grid 0.1] [--slope 3] +""" + +from __future__ import annotations + +import argparse +import math +from pathlib import Path + +PLATFORM = ["PtfmSurge", "PtfmSway", "PtfmHeave", "PtfmRoll", "PtfmPitch", "PtfmYaw"] +TENSION_PREFIXES = ("FairTen", "AnchTen") + + +def read_table(path: Path) -> tuple[list[str], list[list[float]]]: + """An OpenFAST or CableDyn text table: the header row starting with Time, the units row, + then numeric rows (rows that do not parse to the header width are skipped).""" + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + hi = next((i for i, ln in enumerate(lines) if ln.split()[:1] == ["Time"]), -1) + if hi < 0: + raise ValueError(f"{path}: no Time header row") + names = lines[hi].split() + rows = [] + for ln in lines[hi + 2:]: + parts = ln.split() + if len(parts) != len(names): + continue + try: + rows.append([float(x) for x in parts]) + except ValueError: + continue + return names, rows + + +def on_grid(rows: list[list[float]], step: float, t_start: float) -> dict[int, list[float]]: + """The rows at whole multiples of step from t_start on, keyed by the multiple.""" + out = {} + for row in rows: + k = round(row[0] / step) + if abs(row[0] - step * k) < 1e-6 and row[0] >= t_start - 1e-9: + out[k] = row + return out + + +def turning_points(x: list[float]) -> list[float]: + """Peaks and valleys of x; a plateau of equal neighbours counts once.""" + y = [v for i, v in enumerate(x) if i == 0 or v != x[i - 1]] + if len(y) < 3: + return y + out = [y[0]] + for i in range(1, len(y) - 1): + if (y[i] - y[i - 1]) * (y[i + 1] - y[i]) < 0.0: + out.append(y[i]) + out.append(y[-1]) + return out + + +def rainflow(x: list[float]) -> list[tuple[float, float]]: + """(range, count) pairs: full cycles by the four-point method, the residue as half cycles.""" + stack: list[float] = [] + out: list[tuple[float, float]] = [] + for v in turning_points(x): + stack.append(v) + while len(stack) >= 4: + s0, s1, s2, s3 = stack[-4:] + inner = abs(s2 - s1) + if inner <= abs(s1 - s0) and inner <= abs(s3 - s2): + out.append((inner, 1.0)) + del stack[-3:-1] + else: + break + out.extend((abs(b - a), 0.5) for a, b in zip(stack[:-1], stack[1:])) + return out + + +def damage_equivalent_load(x: list[float], duration: float, slope: float) -> float: + """DEL at a 1 Hz equivalent count over duration seconds; zero for a history without cycles + or without duration.""" + total = sum(n * r ** slope for r, n in rainflow(x)) + if total <= 0.0 or duration <= 0.0: + return 0.0 + return (total / duration) ** (1.0 / slope) + + +def relative_change(value: float, reference: float) -> float: + """100 (value/reference - 1); 0 when both are zero, NaN (undefined) when only the reference is.""" + if reference == 0.0: + return 0.0 if value == 0.0 else math.nan + return 100.0 * (value / reference - 1.0) + + +def mean_std(x: list[float]) -> tuple[float, float]: + m = sum(x) / len(x) + return m, math.sqrt(sum((v - m) ** 2 for v in x) / len(x)) + + +def compare_channel(grid: dict[int, list[float]], j: int, ref_grid: dict[int, list[float]], jr: int, + step: float, slope: float | None) -> dict[str, float] | None: + """One channel of a run against the reference over the samples BOTH have: mean, std and max of + the run, the RMS of the difference over the reference std, and (slope given) the run's DEL and + its change against the reference DEL over the same samples and duration (relative_change). + covered is the fraction of the reference samples the run has; None when the two share fewer + than two samples.""" + keys = sorted(set(grid) & set(ref_grid)) + if len(keys) < 2: + return None + x = [grid[k][j] for k in keys] + xr = [ref_grid[k][jr] for k in keys] + m, s = mean_std(x) + _, sr = mean_std(xr) + out = {"mean": m, "std": s, "max": max(x), "covered": len(keys) / len(ref_grid), + "rel": math.sqrt(sum((p - q) ** 2 for p, q in zip(x, xr)) / len(x)) / max(sr, 1e-30)} + if slope is not None: + duration = (keys[-1] - keys[0]) * step + out["del"] = damage_equivalent_load(x, duration, slope) + out["ddel"] = relative_change(out["del"], damage_equivalent_load(xr, duration, slope)) + return out + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("ref", help="root of the dtM = DT run (.out and .CD.out)") + ap.add_argument("runs", nargs="+", help="LABEL=ROOT of a run to compare (split at the last =)") + ap.add_argument("--t-start", type=float, default=200.0) + ap.add_argument("--grid", type=float, default=0.1) + ap.add_argument("--slope", type=float, default=3.0) + a = ap.parse_args() + + runs = [("dtM = DT (reference)", a.ref)] + [tuple(r.rsplit("=", 1)) for r in a.runs] + tables = {} + for label, root in runs: + of_names, of_rows = read_table(Path(root + ".out")) + cd_names, cd_rows = read_table(Path(root + ".CD.out")) + tables[label] = (of_names, on_grid(of_rows, a.grid, a.t_start), cd_names, + on_grid(cd_rows, a.grid, a.t_start), cd_rows) + ref_label = runs[0][0] + ref = tables[ref_label] + tension = [n for n in ref[2] if n.startswith(TENSION_PREFIXES)] + print(f"window t >= {a.t_start:g} s on the common {a.grid:g} s grid; DEL slope m = {a.slope:g}, " + "1 Hz equivalent count") + print(f"{'channel':<11} {'run':<28} {'mean':>12} {'std':>11} {'max':>12} {'RMSdiff/std':>11} " + f"{'DEL':>11} {'dDEL':>8}") + for ch, kind in [(c, 0) for c in PLATFORM] + [(c, 2) for c in tension]: + rnames, rgrid = ref[kind], ref[kind + 1] + if ch not in rnames: + continue + jr = rnames.index(ch) + for label, tab in tables.items(): + names, grid = tab[kind], tab[kind + 1] + if ch not in names: + continue + c = compare_channel(grid, names.index(ch), rgrid, jr, a.grid, a.slope if kind == 2 else None) + if c is None: + print(f"{ch:<11} {label:<28} no common samples with the reference") + continue + line = f"{ch:<11} {label:<28} {c['mean']:12.5e} {c['std']:11.4e} {c['max']:12.5e} {c['rel']:11.4f}" + if kind == 2: + change = " n/a" if math.isnan(c["ddel"]) else f"{c['ddel']:7.2f}%" + line += f" {c['del']:11.4e} {change}" + if c["covered"] < 1.0: + line += f" (covers {100.0 * c['covered']:.1f} % of the reference samples; compared over those)" + print(line) + print() + print("reference DEL at its native sampling against the common grid (sampling alone):") + names, grid, rows = ref[2], ref[3], ref[4] + window = [r for r in rows if r[0] >= a.t_start - 1e-9] + if len(window) < 2 or len(grid) < 2: + print(" (fewer than two reference samples in the window)") + return 0 + for ch in tension: + j = names.index(ch) + native = damage_equivalent_load([r[j] for r in window], window[-1][0] - window[0][0], a.slope) + keys = sorted(grid) + coarse = damage_equivalent_load([grid[k][j] for k in keys], (keys[-1] - keys[0]) * a.grid, a.slope) + print(f" {ch:<9} native {native:.4e} grid {coarse:.4e}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())