From 841b4c71fa9363bd72a13933d881cf7676475685 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Wed, 21 Oct 2026 08:54:14 +0900 Subject: [PATCH 01/17] docs(changelog): open the Unreleased section after 0.2.0 (#33) RELEASE.md closes a release by adding an empty [Unreleased] section to the changelog; it compares against the v0.2.0 tag. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ebfa1..22e2a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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.2.0] - 2026-10-20 ### Added @@ -534,6 +536,7 @@ 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.2.0...HEAD [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 From 4c42daac8000b64f81b7e29acccaf4f2f61a23dc Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Wed, 21 Oct 2026 12:43:09 +0900 Subject: [PATCH 02/17] perf(profile): split the line and cable steps into their kernels (#34) The run-time profile (CABLEDYN_PROFILE=1) counted the steps and Newton iterations of EI = 0 lines but did not say where their time went, and it reported the factorisation and back-substitution of finite-EI cables as one "linear solve". Deciding whether a reused, scheduled factorisation is worth building needs both. The step of an EI = 0 line now times its residual-only evaluations, its residual and tangent evaluations, its LU factorisations and its back-substitutions (CableDyn_Profile kernel counters, updated atomically because lines step concurrently; their times are summed over the lines). The finite-EI cable profile times its factorisations apart from its back-substitutions, which now also include the force-blend correction that ends each step. The standalone driver and the OpenFAST module report both; the OpenFAST module arms and disarms the line counters with its own profiler. Unprofiled, every new entry point returns after one test: three decks run in the same time as with 0.2.0 (ratios 0.96-1.00) and write byte-identical outputs, and the driver tests check that every output file is identical with and without the profile. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 8 ++++ CMakeLists.txt | 4 +- app/cabledyn.f90 | 33 +++++++++----- doc/cli.rst | 36 +++++++++------ doc/faq.rst | 6 ++- src/CableDyn_Dynamic.f90 | 37 ++++++++++++++- src/CableDyn_HermiteCableDynamic.f90 | 34 ++++++++++---- src/CableDyn_Profile.f90 | 68 +++++++++++++++++++++++++--- src/openfast/CableDyn_OF.f90 | 35 ++++++++++++-- tests/test_profile.f90 | 53 ++++++++++++++++++++++ 10 files changed, 267 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e2a6f..1388738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ All notable changes to CableDyn are recorded in this file. The format follows ## [Unreleased] +### Added + +- The run-time profile (`CABLEDYN_PROFILE=1`, standalone driver and OpenFAST) splits the steps + of `EI = 0` lines into residual-only evaluations, residual and tangent evaluations, LU + factorisations and back-substitutions, with their counts and times, and separates the + factorisations of finite-EI cables from their back-substitutions (which now include the + force-blend correction that ends each step). Results do not change. + ## [0.2.0] - 2026-10-20 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index fa0bb6d..c891590 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2047,7 +2047,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 +2055,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.]+\\." -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_driver_profile.cmake) add_test( NAME driver_profile_failure diff --git a/app/cabledyn.f90 b/app/cabledyn.f90 index 1f4bf25..ad8dede 100644 --- a/app/cabledyn.f90 +++ b/app/cabledyn.f90 @@ -37,7 +37,9 @@ 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 USE, INTRINSIC :: ISO_FORTRAN_ENV, ONLY: error_unit, output_unit, int64 @@ -857,9 +859,10 @@ 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) + 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 +890,24 @@ 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), '.' 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/cli.rst b/doc/cli.rst index d9f0ec0..27a5db3 100644 --- a/doc/cli.rst +++ b/doc/cli.rst @@ -157,20 +157,21 @@ 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.237 s 8.8 % + dynamic march 2.444 s 91.1 % + fluid sampled by the driver 0.050 s 1.9 % + line, cable and object steps 2.379 s 88.6 % + checks and line failures 0.002 s 0.1 % + outputs 0.012 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.684 s 100.0 % + March: 600 committed step(s), 4.0735 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.039 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.412, tangent 0.883, effective matrix 0.039, factorisation 0.211, back-substitution 0.075, added mass 0.049, other 0.085. 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 +194,18 @@ 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. An unset or empty variable, or ``0``, leaves the profile off. The driver reads the variable once, when it starts. diff --git a/doc/faq.rst b/doc/faq.rst index 226b305..33ad71c 100644 --- a/doc/faq.rst +++ b/doc/faq.rst @@ -104,8 +104,10 @@ 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 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/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..d92621a 100644 --- a/src/CableDyn_HermiteCableDynamic.f90 +++ b/src/CableDyn_HermiteCableDynamic.f90 @@ -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,7 +179,8 @@ 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) ! Number of coupling intervals completed by internal generalised-alpha substeps after the nominal @@ -499,9 +502,9 @@ 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 END SUBROUTINE CD_HermiteCable_Dyn_Reset_Profile @@ -513,23 +516,31 @@ SUBROUTINE CD_HermiteCable_Dyn_Disable_Profile() END SUBROUTINE CD_HermiteCable_Dyn_Disable_Profile 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, & @@ -3502,7 +3513,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 +3745,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) 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/openfast/CableDyn_OF.f90 b/src/openfast/CableDyn_OF.f90 index 694d40c..e56a93f 100644 --- a/src/openfast/CableDyn_OF.f90 +++ b/src/openfast/CableDyn_OF.f90 @@ -97,6 +97,8 @@ MODULE CableDyn CD_HermiteCable_Dyn_Get_Profile, & CD_HermiteCable_Dyn_Recovery_Count, & CD_HermiteCable_Dyn_Recovery_Reset + 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, & @@ -494,6 +496,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 ! ------------------------------------------------------------------------------ @@ -2433,8 +2436,12 @@ 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) + 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 @@ -2479,20 +2486,39 @@ SUBROUTINE CD_End(u, p, x, xd, z, other, y, m, ErrStat , ErrMsg) ' 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='// & @@ -4235,8 +4261,11 @@ SUBROUTINE reset_instance_profile( id, enabled, init_seconds, reset_global ) 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 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 From eb28b201b3d1111139a93e6484bd0c2c24e51e1e Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Wed, 21 Oct 2026 13:47:47 +0900 Subject: [PATCH 03/17] perf(profile): split the cable assembly, CalcOutput and wave sampling (#35) The first coupled profile showed that, on a mooring with a finite-EI power cable, CalcOutput and the SeaState sampling cost as much as the mooring step itself, and that the cable step is dominated by its residual and tangent assemblies. Choosing what to optimise first needs the inside of each. The residual and tangent assembly of a finite-EI cable now times its sections (band zeroing, tangent element kernels, tangent scatter, structural residual, weight, attachments, dry buoyancy, seabed contact, axial damping, drag, end connections, wave and held Froude-Krylov loads, torsion). The OpenFAST module splits CalcOutput into the overlay of the current kinematics, the output channels and the restore of the committed state, and the SeaState sampling into the node queries, the grid checks and the hand-over of the fields. The standalone driver and the OpenFAST module report them. Unprofiled, each new timer is skipped after one test; outputs are byte-identical with and without the profile. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 4 ++ CMakeLists.txt | 2 +- app/cabledyn.f90 | 13 ++++- doc/cli.rst | 26 ++++++---- doc/faq.rst | 6 ++- src/CableDyn_HermiteCableDynamic.f90 | 64 +++++++++++++++++++++++- src/openfast/CableDyn_OF.f90 | 74 +++++++++++++++++++++++++++- 7 files changed, 174 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1388738..8aed813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ All notable changes to CableDyn are recorded in this file. The format follows factorisations and back-substitutions, with their counts and times, and separates the factorisations of finite-EI cables from their back-substitutions (which now include the force-blend correction that ends each step). Results do not change. +- The run-time profile also splits the residual and tangent assemblies of finite-EI cables into + their sections and, in OpenFAST, `CalcOutput` into the kinematics overlay, the output channels + and the restore, and the SeaState sampling into the node queries, the grid checks and the + hand-over of the fields. Results do not change. ## [0.2.0] - 2026-10-20 diff --git a/CMakeLists.txt b/CMakeLists.txt index c891590..ac225bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2055,7 +2055,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][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.]+\\." + "-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 diff --git a/app/cabledyn.f90 b/app/cabledyn.f90 index ad8dede..bcb9978 100644 --- a/app/cabledyn.f90 +++ b/app/cabledyn.f90 @@ -41,7 +41,8 @@ PROGRAM cabledyn 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 IMPLICIT NONE TYPE :: StandaloneProgress @@ -860,7 +861,8 @@ SUBROUTINE print_profile() !! 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, t_factor - REAL(wp) :: kernel_seconds(CD_PROF_NKERNEL) + 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 @@ -908,6 +910,13 @@ SUBROUTINE print_profile() ', 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/cli.rst b/doc/cli.rst index 27a5db3..affb914 100644 --- a/doc/cli.rst +++ b/doc/cli.rst @@ -158,20 +158,21 @@ breakdown such as: Run-time profile (CABLEDYN_PROFILE), wall-clock time: input parsing 0.002 s 0.1 % - initialisation and statics 0.237 s 8.8 % - dynamic march 2.444 s 91.1 % - fluid sampled by the driver 0.050 s 1.9 % - line, cable and object steps 2.379 s 88.6 % + 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.012 s 0.5 % + outputs 0.013 s 0.5 % progress and bookkeeping 0.000 s 0.0 % finalisation 0.000 s 0.0 % - total 2.684 s 100.0 % - March: 600 committed step(s), 4.0735 ms per step. + 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.039 s), residual and tangent 3322 (0.064 s), factorisation 3322 (0.013 s), back-substitution 5126 (0.009 s). + 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.412, tangent 0.883, effective matrix 0.039, factorisation 0.211, back-substitution 0.075, added mass 0.049, other 0.085. + 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 @@ -206,6 +207,13 @@ The counts follow the march: 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/faq.rst b/doc/faq.rst index 33ad71c..e8f035f 100644 --- a/doc/faq.rst +++ b/doc/faq.rst @@ -106,7 +106,11 @@ OpenFAST coupling ``CABLEDYN_PROFILE=1`` before running ``openfast.exe`` to separate CableDyn solve, SeaState 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 standalone driver reads + ``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 diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index d92621a..4b7cf7b 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 @@ -182,6 +182,29 @@ MODULE CableDyn_HermiteCableDynamic 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 @@ -506,6 +529,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Reset_Profile() 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_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() @@ -515,6 +539,26 @@ 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, & n_factor, t_factor, n_correct) @@ -6439,6 +6483,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta REAL(wp), INTENT(IN), OPTIONAL :: endconn_direction_eval(3, 2) REAL(wp), POINTER :: R(:), Kb(:, :), Kvb(:, :) INTEGER :: ne, e, a, gi, es, omp_threads + 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) @@ -6467,10 +6512,13 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta Kvb => model%ws_Kvb 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 @@ -6488,12 +6536,14 @@ 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 qe(1:3) = q_cfg(6*(e - 1) + 1:6*(e - 1) + 3) @@ -6521,6 +6571,7 @@ 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 @@ -6541,12 +6592,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) 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 @@ -6582,6 +6635,7 @@ 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. @@ -6621,6 +6675,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 @@ -6640,6 +6695,7 @@ 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 @@ -6682,6 +6738,7 @@ 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 @@ -6765,6 +6822,7 @@ 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 @@ -6787,6 +6845,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta 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). @@ -6840,6 +6899,7 @@ 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. @@ -6883,6 +6943,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 @@ -6891,6 +6952,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) diff --git a/src/openfast/CableDyn_OF.f90 b/src/openfast/CableDyn_OF.f90 index e56a93f..ccbac0e 100644 --- a/src/openfast/CableDyn_OF.f90 +++ b/src/openfast/CableDyn_OF.f90 @@ -95,6 +95,7 @@ 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_Profile, ONLY: CD_Profile_Arm, CD_Profile_Disarm, CD_Profile_Kernels, CD_PROF_K_RESIDUAL, & @@ -216,6 +217,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(:) @@ -1744,7 +1752,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) @@ -1763,6 +1772,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 ) @@ -1794,6 +1806,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 @@ -1889,6 +1902,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. @@ -1897,8 +1915,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 @@ -2228,6 +2255,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 @@ -2277,6 +2308,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 @@ -2290,6 +2322,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 @@ -2299,7 +2336,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 @@ -2441,6 +2484,8 @@ SUBROUTINE CD_End(u, p, x, xd, z, other, y, m, ErrStat , ErrMsg) 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) @@ -2480,6 +2525,13 @@ 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))// & @@ -2523,6 +2575,14 @@ SUBROUTINE CD_End(u, p, x, xd, z, other, y, m, ErrStat , ErrMsg) ' 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 @@ -4257,6 +4317,12 @@ 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 @@ -4286,6 +4352,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 From 8c1f5256eee28d367da1bbcdf9cfc9e6604d7982 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Wed, 21 Oct 2026 15:15:06 +0900 Subject: [PATCH 04/17] perf(openfast): skip the discarded cable reactions in CalcOutput (#36) At the glue rate (nsuper = 1) CalcOutput saves the committed coupled kinematics, transfers the current ones, evaluates the loads and channels and restores the committed state. The save read the kinematics with CD_AGG_GetMovingPointMesh, which also evaluated the reaction of every finite-EI cable (a full residual and added-mass assembly) only for the caller to overwrite it with the loads evaluated after the transfer. The profile put this at 17 of the 41 s of CalcOutput in a 600 s run of the IEA-15 VolturnUS-S with a 248-element power cable. CD_AGG_GetMovingPointMesh takes an optional cable_loads = .FALSE. that skips the cable reactions and returns their load columns as zero; the save uses it. The reactions write only the cable's scratch, which the loads evaluation that follows overwrites, so the outputs are unchanged: the coupled chain-only and mixed runs write byte-identical OpenFAST and CableDyn outputs, and the mixed run takes 212 s instead of 227 s. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 7 +++++++ src/CableDyn_OpenFAST_Aggregate.f90 | 11 ++++++++++- src/openfast/CableDyn_OF.f90 | 7 +++++-- tests/test_openfast_aggregate.f90 | 10 ++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aed813..a65c5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ All notable changes to CableDyn are recorded in this file. The format follows and the restore, and the SeaState sampling into the node queries, the grid checks and the hand-over of the fields. Results do not change. +### Changed + +- OpenFAST: `CalcOutput` no longer evaluates the reactions of the finite-EI cables when it saves + the committed kinematics before transferring the current ones; it discarded them. With a + 248-element power cable in the IEA-15 VolturnUS-S mooring this cuts `CalcOutput` from 41 s to + 25 s and the 600 s run from 227 s to 212 s. Results do not change. + ## [0.2.0] - 2026-10-20 ### Added diff --git a/src/CableDyn_OpenFAST_Aggregate.f90 b/src/CableDyn_OpenFAST_Aggregate.f90 index e60e5ab..783ee0c 100644 --- a/src/CableDyn_OpenFAST_Aggregate.f90 +++ b/src/CableDyn_OpenFAST_Aggregate.f90 @@ -561,7 +561,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 +581,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 +672,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 diff --git a/src/openfast/CableDyn_OF.f90 b/src/openfast/CableDyn_OF.f90 index ccbac0e..241a691 100644 --- a/src/openfast/CableDyn_OF.f90 +++ b/src/openfast/CableDyn_OF.f90 @@ -2224,10 +2224,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 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 From da5dfad8d4e4589c6367056830782e5c6a7e0a8c Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Wed, 21 Oct 2026 16:41:32 +0900 Subject: [PATCH 05/17] perf(hermite): assemble a cable reaction from its node's elements (#37) The reaction of a finite-EI cable at a node (the fairlead load the OpenFAST module returns on every CalcOutput) assembled the residual and the added mass of the whole line and multiplied the whole mass by the acceleration, to read three rows. With a 248-element power cable this was 20 s of a 212 s coupled run. assemble_residual takes an optional node_only with a caller residual R_out: it assembles only the elements that reach the node and the node's own terms (attachments, contact, end connection), each in the order of the full assembly, so the node's rows are bit-identical and the model's scratch is not touched. The contact and attachment assemblies take node bounds, and contact writes through the residual it is given. The reaction builds the node's rows of the added mass from the same one or two element matrices and forms the inertia column by column as band_matvec does. A line with torsion, which couples every element, keeps the full assembly, as does full_assembly = .TRUE. test_hermite_node_reaction compares the two at every node, bit for bit, on hand-built cables with every load section (contact with friction on a controlled seabed, attachments, axial damping, drag, added mass, Airy waves or a held field, finite, rigid or no end connections) at scattered states, and along a coupled trajectory of the IEA-15 MW mixed mooring example, also after a fairlead transfer; it fails when the inertia sum is reordered. All 51 examples write byte-identical outputs, and the coupled chain-only and mixed OpenFAST runs write byte-identical outputs, the mixed one in 194 s instead of 212 s. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 4 + CMakeLists.txt | 11 ++ src/CableDyn_HermiteCableDynamic.f90 | 210 ++++++++++++++++----- tests/test_hermite_node_reaction.f90 | 264 +++++++++++++++++++++++++++ 4 files changed, 446 insertions(+), 43 deletions(-) create mode 100644 tests/test_hermite_node_reaction.f90 diff --git a/CHANGELOG.md b/CHANGELOG.md index a65c5ca..cb6f2df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ All notable changes to CableDyn are recorded in this file. The format follows the committed kinematics before transferring the current ones; it discarded them. With a 248-element power cable in the IEA-15 VolturnUS-S mooring this cuts `CalcOutput` from 41 s to 25 s and the 600 s run from 227 s to 212 s. Results do not change. +- The reaction of a finite-EI cable at a node (the fairlead load the OpenFAST module returns) + is assembled from the one or two elements that reach the node instead of the whole line, + bit-identical to the full assembly (lines with torsion keep the full assembly). In the same + run this cuts `CalcOutput` to 9 s and the run to 194 s. Results do not change. ## [0.2.0] - 2026-10-20 diff --git a/CMakeLists.txt b/CMakeLists.txt index ac225bc..88408f7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1084,6 +1084,17 @@ 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 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) diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index 4b7cf7b..d239da1 100644 --- a/src/CableDyn_HermiteCableDynamic.f90 +++ b/src/CableDyn_HermiteCableDynamic.f90 @@ -1110,7 +1110,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. @@ -1118,6 +1119,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 @@ -1125,6 +1127,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 @@ -5118,7 +5121,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. !! @@ -5139,15 +5142,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) @@ -5168,6 +5186,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 @@ -6456,7 +6533,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 @@ -6481,8 +6558,16 @@ 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 @@ -6510,6 +6595,25 @@ 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 @@ -6545,7 +6649,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta 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) @@ -6576,7 +6680,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta 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) @@ -6594,7 +6698,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta 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 @@ -6608,7 +6712,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 @@ -6640,12 +6744,12 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! 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 @@ -6699,7 +6803,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! 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) @@ -6745,7 +6849,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! 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) @@ -6829,19 +6933,23 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! 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 @@ -6850,7 +6958,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! 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 @@ -6904,7 +7012,7 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! (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 @@ -7043,12 +7151,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 @@ -7070,7 +7181,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 @@ -7116,7 +7227,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 @@ -7126,7 +7237,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 @@ -7152,7 +7263,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) + & @@ -8142,22 +8253,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 @@ -8172,6 +8274,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/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 From 99fbaa8e731c80c9517b61938382a4f5433976f7 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Wed, 21 Oct 2026 17:54:32 +0900 Subject: [PATCH 06/17] feat(init): note a dtM that does not resolve a line's axial period (#38) A coarser mooring step is the main way to make coupled runs cheaper, but nothing told a user when dtM was too coarse for a line. With the IEA-15 MW mixed example in a turbulent 600 s OpenFAST run, the power cable (axial period 0.351 s) matches the dtM = DT fairlead tension within 0.3 % of its standard deviation at dtM = 0.05 s (7 steps per period), but at 0.1 s (3.5 steps) 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 0.1 s. At initialisation each line's axial period is estimated as that of a bar fixed at both ends, twice the sum over its sections of the length over sqrt(EA/m) (unstretched lengths, dry mass, the dynamic EA of a constant-stiffness viscoelastic type). 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. Load-dependent types (ElasticMod 3, Syrope) are not assessed. Nothing else changes. The FAQ and the OpenFAST tutorial still gave 0.1 s as the OpenFAST default dtM; since 0.2.0 it is the glue DT. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 9 ++++ CMakeLists.txt | 8 +++ doc/faq.rst | 18 ++++--- doc/modeling.rst | 15 ++++++ doc/tutorial_openfast.rst | 4 +- src/CableDyn_DeckDriver.f90 | 62 ++++++++++++++++++++++-- src/CableDyn_OpenFAST_Aggregate.f90 | 3 +- tests/check_driver_advisories.cmake | 14 +++++- tests/test_dtm_advisor.f90 | 75 +++++++++++++++++++++++++++++ 9 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 tests/test_dtm_advisor.f90 diff --git a/CHANGELOG.md b/CHANGELOG.md index cb6f2df..7a8f128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ All notable changes to CableDyn are recorded in this file. The format follows their sections and, in OpenFAST, `CalcOutput` into the kinematics overlay, the output channels and the restore, and the SeaState sampling into the node queries, the grid checks and the hand-over of the fields. Results do not change. +- dtM advisory: at initialisation, a line whose axial period (twice its length over + `sqrt(EA/m)`) the time step `dtM` resolves in fewer than six steps gets a note with the + period and a `dtM` that resolves it, on the standalone, mixed and OpenFAST routes. Results do + not change. ### Changed @@ -31,6 +35,11 @@ All notable changes to CableDyn are recorded in this file. The format follows bit-identical to the full assembly (lines with torsion keep the full assembly). In the same run this cuts `CalcOutput` to 9 s and the run to 194 s. Results do not change. +### Fixed + +- The FAQ and the OpenFAST tutorial still gave 0.1 s as the default `dtM` in OpenFAST; since + 0.2.0 it is the glue `DT`. + ## [0.2.0] - 2026-10-20 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index 88408f7..e52727c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1095,6 +1095,14 @@ 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 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) diff --git a/doc/faq.rst b/doc/faq.rst index e8f035f..8a5af7e 100644 --- a/doc/faq.rst +++ b/doc/faq.rst @@ -83,18 +83,20 @@ 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 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. 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 diff --git a/doc/modeling.rst b/doc/modeling.rst index 38231b0..267b99d 100644 --- a/doc/modeling.rst +++ b/doc/modeling.rst @@ -208,6 +208,21 @@ bandwidth, the shortest element transit time, contact switching, and constitutiv 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/tutorial_openfast.rst b/doc/tutorial_openfast.rst index 7614aac..8a8d832 100644 --- a/doc/tutorial_openfast.rst +++ b/doc/tutorial_openfast.rst @@ -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/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index 926b7a5..d4efa54 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 @@ -991,9 +993,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 @@ -7086,8 +7088,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) @@ -10113,6 +10116,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 @@ -32618,6 +32666,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_OpenFAST_Aggregate.f90 b/src/CableDyn_OpenFAST_Aggregate.f90 index 783ee0c..7b4e7dc 100644 --- a/src/CableDyn_OpenFAST_Aggregate.f90 +++ b/src/CableDyn_OpenFAST_Aggregate.f90 @@ -2049,7 +2049,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/tests/check_driver_advisories.cmake b/tests/check_driver_advisories.cmake index aab9b32..d34e8a4 100644 --- a/tests/check_driver_advisories.cmake +++ b/tests/check_driver_advisories.cmake @@ -3,7 +3,9 @@ # * 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). 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,6 +95,16 @@ 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") 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 From a6439d0fb9d706c324271543f0c5247e71cbecbe Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Wed, 21 Oct 2026 19:20:33 +0900 Subject: [PATCH 07/17] feat(openfast): extrapolate the coupled loads between dtM solves (#39) With dtM larger than the glue DT, CableDyn solves once per nsuper glue steps and held its loads between solves, so the platform received them on average dtM/2 late: a first-order lag that acts as negative damping of about the mooring stiffness times dtM/2. This is why 0.2.0 defaults to dtM = DT, at four times the mooring cost of dtM = 0.1 s. Between solves the loads are now extrapolated from the last two committed solves, F = F_c + tau (F_c - F_p) with tau the fraction of the mooring step elapsed, clamped to [0, 1]; the error is second order in dtM. The extrapolation is used only between consecutive commits, so the first step keeps the held loads. CD_UpdateStates shifts the served loads into the previous ones when a mooring step starts (not on a correction re-entry over the same step), keeping CalcOutput state-const, and mirrors them into the OtherState (pred_t, pred_loads), so a restart continues the extrapolation. The output channels and the .CD.out keep the committed samples. The deck OPTION predictLoads (default True) switches it; False keeps the hold. The arithmetic lives in CableDyn_LoadPredictor and is unit-tested. IEA-15 MW VolturnUS-S, turbulent 600 s DLC-1.6, dtM = 0.1 s against dtM = DT = 0.025 s: the platform surge, sway, roll and pitch differ by at most 0.12 % of their standard deviation (0.42 % held), on the chain and on the chain and power-cable mooring; the chain-only run takes a fifth less time (116 s against 141 s). Runs at dtM = DT are byte-identical, and with predictLoads False the outputs equal the held runs byte for byte. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 7 ++ CMakeLists.txt | 8 +++ doc/faq.rst | 3 +- doc/modeling.rst | 5 +- doc/openfast.rst | 38 +++++++--- doc/options.rst | 9 +++ src/CableDyn_DeckDriver.f90 | 9 +++ src/CableDyn_LoadPredictor.f90 | 45 ++++++++++++ src/CableDyn_OpenFAST_Aggregate.f90 | 4 ++ src/openfast/CableDyn_OF.f90 | 105 +++++++++++++++++++++++++--- src/openfast/CableDyn_Registry.txt | 2 + src/openfast/CableDyn_Types.f90 | 27 +++++++ tests/test_load_predictor.f90 | 77 ++++++++++++++++++++ 13 files changed, 315 insertions(+), 24 deletions(-) create mode 100644 src/CableDyn_LoadPredictor.f90 create mode 100644 tests/test_load_predictor.f90 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a8f128..83346b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ All notable changes to CableDyn are recorded in this file. The format follows `sqrt(EA/m)`) the time step `dtM` resolves in fewer than six steps gets a note with the period and a `dtM` that resolves it, on the standalone, mixed and OpenFAST routes. Results do not change. +- 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`; `False` keeps the hold). The error drops from first to second order in `dtM`: 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; the previous solve's loads are checkpointed, so a restart + continues the extrapolation. ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index e52727c..d82c742 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -333,6 +333,7 @@ add_library(cabledyn_obj OBJECT 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 @@ -1103,6 +1104,13 @@ 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) diff --git a/doc/faq.rst b/doc/faq.rst index 8a5af7e..a554942 100644 --- a/doc/faq.rst +++ b/doc/faq.rst @@ -84,7 +84,8 @@ OpenFAST coupling :class: note CableDyn solves on ``dtM`` (the glue ``DT`` unless the deck sets a larger ``dtM``) and, with a - larger ``dtM``, holds its committed loads/channels between solves on the faster glue clock of + 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 diff --git a/doc/modeling.rst b/doc/modeling.rst index 267b99d..25eb2eb 100644 --- a/doc/modeling.rst +++ b/doc/modeling.rst @@ -200,8 +200,9 @@ 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 diff --git a/doc/openfast.rst b/doc/openfast.rst index 69e0ee9..1ebeed5 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,29 @@ 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). 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 ~~~~~~~~~~~~ diff --git a/doc/options.rst b/doc/options.rst index cfa2934..fd1f4a3 100644 --- a/doc/options.rst +++ b/doc/options.rst @@ -527,6 +527,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/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index d4efa54..ce6604e 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -634,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 @@ -1018,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(:) @@ -6540,6 +6545,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 @@ -7636,6 +7642,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) @@ -14244,6 +14251,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') 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 7b4e7dc..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 diff --git a/src/openfast/CableDyn_OF.f90 b/src/openfast/CableDyn_OF.f90 index 241a691..0a29426 100644 --- a/src/openfast/CableDyn_OF.f90 +++ b/src/openfast/CableDyn_OF.f90 @@ -98,6 +98,7 @@ MODULE CableDyn 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, & @@ -136,9 +137,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. @@ -157,6 +159,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. @@ -640,13 +648,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 @@ -793,6 +794,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 @@ -1118,6 +1133,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 @@ -1529,6 +1545,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, '// & @@ -1645,6 +1675,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. @@ -2170,6 +2204,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 @@ -2403,6 +2438,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 @@ -2416,6 +2476,31 @@ SUBROUTINE CD_CalcOutput( t, u, p, x, xd, z, other, y, m, ErrStat, ErrMsg ) END SUBROUTINE CD_CalcOutput !---------------------------------------------------------------------------------------------------------------------------------- + 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. diff --git a/src/openfast/CableDyn_Registry.txt b/src/openfast/CableDyn_Registry.txt index 45180fc..78e960e 100644 --- a/src/openfast/CableDyn_Registry.txt +++ b/src/openfast/CableDyn_Registry.txt @@ -359,6 +359,8 @@ 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" - ## ============================== 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..c88607f 100644 --- a/src/openfast/CableDyn_Types.f90 +++ b/src/openfast/CableDyn_Types.f90 @@ -393,6 +393,8 @@ 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 [-] END TYPE CD_OtherStateType ! ======================= ! ========= CD_ParameterType ======= @@ -2969,11 +2971,26 @@ 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 end subroutine subroutine CD_DestroyOtherState(OtherStateData, ErrStat, ErrMsg) @@ -2983,6 +3000,9 @@ 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 end subroutine subroutine CD_PackOtherState(RF, Indata) @@ -2992,6 +3012,8 @@ 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) if (RegCheckErr(RF, RoutineName)) return end subroutine @@ -2999,9 +3021,14 @@ 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 end subroutine subroutine CD_CopyParam(SrcParamData, DstParamData, CtrlCode, ErrStat, ErrMsg) 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 From ec3c6d9b78fdedf04a2791d283db7db1fe9422f9 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Wed, 21 Oct 2026 23:54:43 +0900 Subject: [PATCH 08/17] fix(openfast): restore the held SeaState fields on a supercycled restart (#40) A committed mooring step holds the SeaState fields sampled at its interval end at the node positions of its start. On openfast -restart the module re-sampled them at the restored, end-of-step positions. At dtM = DT this was harmless, because the next solve re-samples before any load is served; with dtM larger than DT the loads served until the next solve came from the re-sampled fields, and the restarted run drifted from the uninterrupted one (up to 2e-3 of a tension's standard deviation in the CableDyn record, on held and extrapolated loads alike). A full-precision trace placed the first difference at the first load served after the restart, with identical host inputs. The OtherState carries the held fields (fluid_held: velocity, acceleration, free-surface elevation and dynamic pressure at the line nodes), saved at Init and at every committed step, and the restart hands them back to the aggregate instead of re-sampling. OpenFAST checkpoints are not versioned, so a checkpoint written by an earlier CableDyn (without these or the load predictor's fields) cannot be restarted with this one; the CHANGELOG says so. openfast -restart at 50 s now reproduces every later OpenFAST and .CD.out row of the IEA-15 VolturnUS-S chain mooring at dtM = 0.1 s (extrapolated and held loads) and at dtM = DT, and of the chain and power-cable mooring at dtM = 0.05 s. Uninterrupted runs do not change. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 8 ++++ VALIDATION.md | 2 +- doc/openfast.rst | 4 +- src/openfast/CableDyn_OF.f90 | 70 +++++++++++++++++++++++++++--- src/openfast/CableDyn_Registry.txt | 1 + src/openfast/CableDyn_Types.f90 | 18 ++++++++ 6 files changed, 95 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83346b6..d357569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,14 @@ All notable changes to CableDyn are recorded in this file. The format follows ### Fixed +- OpenFAST with `dtM` larger than `DT`: an `openfast -restart` re-sampled the SeaState fields of the + committed step at its end positions instead of restoring the fields the step held (sampled at + its start positions), so the loads served until the next solve, and the run after them, + differed slightly from the uninterrupted run (up to 2e-3 of a tension's standard deviation). + The checkpoint now carries the held fields, and a restart continues the run bit for bit at any + `dtM`. The checkpoint layout changes with this and with the load predictor's fields: a + checkpoint written by an earlier CableDyn cannot be restarted with this one (OpenFAST + checkpoints are not versioned; restart with the executable that wrote them). - The FAQ and the OpenFAST tutorial still gave 0.1 s as the default `dtM` in OpenFAST; since 0.2.0 it is the glue `DT`. diff --git a/VALIDATION.md b/VALIDATION.md index bd2f11f..e572b2f 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -746,7 +746,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/doc/openfast.rst b/doc/openfast.rst index 1ebeed5..93828d6 100644 --- a/doc/openfast.rst +++ b/doc/openfast.rst @@ -343,7 +343,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/src/openfast/CableDyn_OF.f90 b/src/openfast/CableDyn_OF.f90 index 0a29426..439402f 100644 --- a/src/openfast/CableDyn_OF.f90 +++ b/src/openfast/CableDyn_OF.f90 @@ -848,6 +848,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 @@ -1315,6 +1316,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 @@ -1493,14 +1495,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 ) @@ -1700,6 +1708,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 @@ -2476,6 +2485,55 @@ 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 diff --git a/src/openfast/CableDyn_Registry.txt b/src/openfast/CableDyn_Registry.txt index 78e960e..a4c32cf 100644 --- a/src/openfast/CableDyn_Registry.txt +++ b/src/openfast/CableDyn_Registry.txt @@ -361,6 +361,7 @@ typedef ^ OtherStateType IntKi restart_serial - 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 c88607f..e654f30 100644 --- a/src/openfast/CableDyn_Types.f90 +++ b/src/openfast/CableDyn_Types.f90 @@ -395,6 +395,7 @@ MODULE CableDyn_Types 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 ======= @@ -2991,6 +2992,18 @@ subroutine CD_CopyOtherState(SrcOtherStateData, DstOtherStateData, CtrlCode, Err 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) @@ -3003,6 +3016,9 @@ subroutine CD_DestroyOtherState(OtherStateData, ErrStat, 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) @@ -3014,6 +3030,7 @@ subroutine CD_PackOtherState(RF, Indata) 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 @@ -3029,6 +3046,7 @@ subroutine CD_UnPackOtherState(RF, OutData) 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) From f0358aeb3c0739d9095ca0f673fc993a53239338 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Thu, 22 Oct 2026 12:58:27 +0900 Subject: [PATCH 09/17] docs(validation): record the supercycling verification (#41) The coupled time-step work (#38 to #40) lets CableDyn solve on a dtM larger than the OpenFAST DT. This records the verification of that change on the IEA-15 MW VolturnUS-S chain mooring in a DLC-1.6 environment (NTM 10.6 m/s turbulent wind, JONSWAP Hs 8 m / Tp 12 s, 3800 s), at dtM = 4 DT = 0.1 s against the same model at dtM = DT and against stock MoorDyn. With the coupled loads extrapolated between CableDyn solves, every platform DOF's time series differs from the dtM = DT run by at most 0.14 % of its standard deviation (0.41 % held), the tension statistics stay within 0.16 %, and the rainflow damage-equivalent loads change by at most 0.21 % at the fairleads and 1.5 % at the anchors, of the order of the reference's own sampling effect (0.94 %). Against stock MoorDyn the in-plane platform standard deviations agree within 0.03 %. validation/scripts/openfast_supercycle_ab.py computes these figures from the twin runs; its rainflow count (ASTM E1049, four-point method, half-cycle residue, a plateau of equal samples counted once) is tested against the standard's worked example. Every statistic of a run, its DEL change included, is taken over the samples it shares with the reference, and a run covering fewer of them is flagged. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 4 + VALIDATION.md | 17 ++ doc/openfast.rst | 4 +- tests/test_supercycle_ab.py | 85 ++++++++ validation/scripts/openfast_supercycle_ab.py | 205 +++++++++++++++++++ 5 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 tests/test_supercycle_ab.py create mode 100644 validation/scripts/openfast_supercycle_ab.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d357569..8dccd01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ All notable changes to CableDyn are recorded in this file. The format follows `dtM = DT` (the default) do not change; the previous solve's loads are checkpointed, so a restart continues the extrapolation. +- `validation/scripts/openfast_supercycle_ab.py`: compares coupled runs at a coarser `dtM` with + the same model at `dtM = DT` (platform time series, tension statistics, rainflow + damage-equivalent loads); `VALIDATION.md` records the supercycling verification it scores. + ### Changed - OpenFAST: `CalcOutput` no longer evaluates the reactions of the finite-EI cables when it saves diff --git a/VALIDATION.md b/VALIDATION.md index e572b2f..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 diff --git a/doc/openfast.rst b/doc/openfast.rst index 93828d6..5f579a3 100644 --- a/doc/openfast.rst +++ b/doc/openfast.rst @@ -107,7 +107,9 @@ peaks, and damage. If CableDyn needs a smaller step than the glue step, reduce ` 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). In the three-seed DLC-1.1 comparison + 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. 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/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()) From 312179fd50c1dd88f2ad8dc42d8f41581213aa16 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Thu, 22 Oct 2026 15:25:08 +0900 Subject: [PATCH 10/17] build(python): build platform wheels that carry the native library (#42) The cabledyn package was published only as a pure-Python wheel: it loads the CableDyn shared library and runs the standalone driver, but the user had to build or download both separately. This prepares publication on PyPI. A root pyproject.toml (scikit-build-core) builds an sdist of the sources and a platform wheel. CMake builds only the cabledyn_shared and cabledyn targets, serially, and under scikit-build installs them through the `python` component: the library beside the package modules, where cabledyn._discovery already looks first, and the driver in cabledyn/bin. The wheel is tagged py3-none-, since the package reaches the library through ctypes. The sdist leaves out the validation records, the manual, the OpenFAST integration and the release tooling, and builds a wheel on its own. The driver search takes the bundled driver after CABLEDYN_DRIVER and before PATH, under either toolchain name (CableDyn_driver, cabledyn). 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, and the release checks cover the new file. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 6 ++ CMakeLists.txt | 10 +++ DEVELOPMENT.md | 31 +++++++++ pyproject.toml | 101 ++++++++++++++++++++++++++++++ python/cabledyn/driver.py | 21 ++++++- python/tests/test_cli.py | 51 ++++++++++++++- release/audit_release_tree.py | 1 + release/check_release_metadata.py | 4 ++ tests/test_packaging_metadata.py | 65 +++++++++++++++++++ 9 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 pyproject.toml create mode 100644 tests/test_packaging_metadata.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dccd01..870b945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,12 @@ All notable changes to CableDyn are recorded in this file. The format follows the same model at `dtM = DT` (platform time series, tension statistics, rainflow damage-equivalent loads); `VALIDATION.md` records the supercycling verification it scores. +- Packaging for PyPI: a root `pyproject.toml` (scikit-build-core) builds an sdist of the sources + and a `py3-none-` wheel that carries the compiled library and the standalone driver + inside the `cabledyn` package, so the package needs no separate library; the driver search + takes the bundled driver after `CABLEDYN_DRIVER` and before `PATH`. `python/pyproject.toml` + remains the pure package for development against a build tree. + ### Changed - OpenFAST: `CalcOutput` no longer evaluates the reactions of the finite-EI cables when it saves diff --git a/CMakeLists.txt b/CMakeLists.txt index d82c742..551c910 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1800,6 +1800,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. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7e3c14f..0653a8f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -93,6 +93,37 @@ 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). + 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b09e93d --- /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.2.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/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/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/check_release_metadata.py b/release/check_release_metadata.py index f6b5a95..7a6f1a1 100644 --- a/release/check_release_metadata.py +++ b/release/check_release_metadata.py @@ -115,6 +115,7 @@ def main() -> 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/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py new file mode 100644 index 0000000..049ec46 --- /dev/null +++ b/tests/test_packaging_metadata.py @@ -0,0 +1,65 @@ +# 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 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", + }, + ) + + +if __name__ == "__main__": + unittest.main() From 825d016909d57bd10d5ba63f811641b511e038e1 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Thu, 22 Oct 2026 17:03:12 +0900 Subject: [PATCH 11/17] perf(build): compile the release for x86-64-v3 (AVX2) (#43) The wave kinematics and the element kernels spend most of their time in vectorised cos/sin/exp, which the release build ran two doubles wide (SSE2). CABLEDYN_TARGET_AVX2 compiles the Fortran core for x86-64-v3 (/arch:CORE-AVX2 with IFX, -march=x86-64-v3 with gfortran 11+), and the static Windows release sets it: the IFX driver runs the benchmark decks 13-33% faster (lazy_wave_vessel_rao 11.9 s to 8.8 s, als_volturnus_line_break_time 35.5 s to 23.9 s). Contraction into fused multiply-adds stays off (/Qfma-, -ffp-contract=off): with it the compiler fuses the same expression differently where it is inlined, and a restored instance no longer continued a run bit for bit (test_line_attachments). src/cabledyn_cpu_guard.c, compiled for baseline x86-64, checks the processor before any Fortran code runs and stops a program on an older one with a message naming the missing instructions (exit 3). It writes with the operating system's calls only: in the Windows GNU build the C library names can resolve to openblas.dll's exports and import it at load time. CABLEDYN_CPU_GUARD_SIMULATE exercises the stop on any processor; the release script checks it for both executables, and the driver's --version and the OpenFAST module's start-up lines report the x86-64-v3 build. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 8 ++ CMakeLists.txt | 54 ++++++++++ DEVELOPMENT.md | 12 +++ README.md | 2 +- app/cabledyn.f90 | 14 +++ doc/installation.rst | 8 ++ release/build_static_windows.ps1 | 30 +++++- src/cabledyn_cpu_guard.c | 165 +++++++++++++++++++++++++++++++ src/openfast/CableDyn_OF.f90 | 10 ++ tests/test_cpu_guard.f90 | 65 ++++++++++++ 10 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 src/cabledyn_cpu_guard.c create mode 100644 tests/test_cpu_guard.f90 diff --git a/CHANGELOG.md b/CHANGELOG.md index 870b945..4e38899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,14 @@ All notable changes to CableDyn are recorded in this file. The format follows ### Changed +- The Windows release executables (`CableDyn_driver.exe`, `openfast.exe`) are compiled for + processors with AVX2 (x86-64-v3: Intel Haswell and AMD Excavator or newer; CMake option + `CABLEDYN_TARGET_AVX2`), which makes the driver 13-33% faster on the benchmark decks + (`lazy_wave_vessel_rao` 11.9 s to 8.8 s, `als_volturnus_line_break_time` 35.5 s to 23.9 s). + On an older processor they stop at start with a message naming the missing instructions. + Results change at round-off: in the last printed digit on the lazy-wave and buoy decks; in the + line-break deck the platform's slow drift after the break amplifies the round-off past 900 s, + with the same peaks and standard deviations to four digits. - OpenFAST: `CalcOutput` no longer evaluates the reactions of the finite-EI cables when it saves the committed kinematics before transferring the current ones; it discarded them. With a 248-element power cable in the IEA-15 VolturnUS-S mooring this cuts `CalcOutput` from 41 s to diff --git a/CMakeLists.txt b/CMakeLists.txt index 551c910..27f4816 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,9 @@ option(CABLEDYN_ENABLE_COVERAGE "Instrument the GNU Fortran core for gcov/gcovr 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; @@ -63,6 +68,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,6 +366,7 @@ 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 @@ -1996,6 +2035,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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0653a8f..287bb9a 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 diff --git a/README.md b/README.md index b5b9f44..edc103a 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ 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 | diff --git a/app/cabledyn.f90 b/app/cabledyn.f90 index bcb9978..3e2b9fb 100644 --- a/app/cabledyn.f90 +++ b/app/cabledyn.f90 @@ -44,6 +44,7 @@ PROGRAM cabledyn 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 @@ -56,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. @@ -80,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() diff --git a/doc/installation.rst b/doc/installation.rst index b7d78cb..6b6eea9 100644 --- a/doc/installation.rst +++ b/doc/installation.rst @@ -52,6 +52,14 @@ 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 ~~~~~~~~~~~~~~~~~~~ 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/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 439402f..ba710ac 100644 --- a/src/openfast/CableDyn_OF.f90 +++ b/src/openfast/CableDyn_OF.f90 @@ -122,6 +122,15 @@ MODULE CableDyn PRIVATE TYPE(ProgDesc), PARAMETER :: CableDyn_ProgDesc = ProgDesc( 'CableDyn', 'v0.2.0', '2026-10-20' ) + 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 @@ -528,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) 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 From fc9fdb29e10610dcecce4f91a82502fbf2dea8e3 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Thu, 22 Oct 2026 19:48:32 +0900 Subject: [PATCH 12/17] build(ci): build and smoke-test the release wheels (#44) .github/workflows/wheels.yml builds the published distribution as workflow artifacts (manual runs, published releases, and pull requests that touch the packaging); publishing stays a separate step: - sdist, checked with twine; - Linux x86-64 (cibuildwheel, manylinux_2_28, image GCC toolset): OpenBLAS and the Fortran runtime bundled by auditwheel, built for x86-64-v3 (CABLEDYN_TARGET_AVX2), as the Windows release; - macOS arm64 (cibuildwheel, Homebrew gfortran and OpenBLAS, bundled by delocate, deployment target 14.0); - Windows x86-64 (release/build_windows_wheel.ps1): the static release configuration with IFX and MSVC and the release's pinned reference LAPACK; dumpbin checks that the library and the driver import only Windows system DLLs. release/smoke_test_wheel.py installs nothing itself: run in a clean environment with the wheel installed, it requires the package to load the library it carries and select the driver it carries, the driver to report the expected build (--expect-build), and a standalone deck to run. tests/test_packaging_metadata.py keeps the reference LAPACK version, checksum and flags of the wheel and release scripts equal. The Windows wheel is inspected with System.IO.Compression.ZipFile and an explicit extension filter (Windows PowerShell 5.1's Expand-Archive refuses .whl, and its Get-ChildItem -LiteralPath ignores -Include), and the static release's shared library links with /Qipo like the executables. Signed-off-by: Jae Hoon Seo --- .github/workflows/wheels.yml | 191 +++++++++++++++++++++++++++++++ CHANGELOG.md | 4 + CMakeLists.txt | 5 +- DEVELOPMENT.md | 20 ++++ release/build_windows_wheel.ps1 | 162 ++++++++++++++++++++++++++ release/smoke_test_wheel.py | 73 ++++++++++++ tests/test_packaging_metadata.py | 28 +++++ 7 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/wheels.yml create mode 100644 release/build_windows_wheel.ps1 create mode 100644 release/smoke_test_wheel.py diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 0000000..02f125f --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,191 @@ +# 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; publishing them is a separate, explicit step. +name: wheels + +on: + workflow_dispatch: + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e38899..1a26e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ All notable changes to CableDyn are recorded in this file. The format follows inside the `cabledyn` package, so the package needs no separate library; the driver search takes the bundled driver after `CABLEDYN_DRIVER` and before `PATH`. `python/pyproject.toml` remains the pure package for development against a build tree. +- Release wheels built in CI (`.github/workflows/wheels.yml`): Linux x86-64 (`manylinux_2_28`, + x86-64-v3), macOS arm64 and Windows x86-64 (IFX, static, the release's reference LAPACK), plus + the sdist. Each wheel is installed into a clean environment and smoke-tested + (`release/smoke_test_wheel.py`); publishing is a separate step. ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index 27f4816..28b84a8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 287bb9a..8a908be 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -136,6 +136,26 @@ python -m pip wheel . --no-deps --no-build-isolation -w dist ` 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; publishing +them is a separate step. 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/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/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/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index 049ec46..44cbf9f 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -6,6 +6,7 @@ from __future__ import annotations +import re import unittest from pathlib import Path @@ -61,5 +62,32 @@ def test_the_wheel_classifiers_differ_only_in_the_platforms(self): ) +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() From 419007e3f43e8ce7fb951bfef36d7c24b860c6c2 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Thu, 22 Oct 2026 21:20:36 +0900 Subject: [PATCH 13/17] build(ci): publish the wheels through PyPI Trusted Publishing (#45) wheels.yml gains two publish jobs that upload the sdist and the three release wheels its build jobs smoke-tested, with no stored token (id-token: write only; the publishers are registered on pypi.org and test.pypi.org for this repository, wheels.yml and the environments pypi and testpypi): - publish-testpypi: a manual run with upload = testpypi; a version already on TestPyPI is skipped; - publish-pypi: a published GitHub release, behind the pypi environment, after checking that the distribution set is one sdist and three wheels of the release tag's version. pypa/gh-action-pypi-publish is pinned to v1.14.2 by commit. RELEASE.md adds the TestPyPI preflight with a clean-environment install and smoke test, the approval step, and the publisher setup of a new repository. DEVELOPMENT.md links RELEASE.md by its repository URL (the Sphinx manual includes DEVELOPMENT.md but not RELEASE.md). Signed-off-by: Jae Hoon Seo --- .github/workflows/wheels.yml | 76 +++++++++++++++++++++++++++++++++++- CHANGELOG.md | 4 +- DEVELOPMENT.md | 7 +++- RELEASE.md | 28 ++++++++++++- 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 02f125f..b0eb5a5 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -6,11 +6,19 @@ # 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; publishing them is a separate, explicit step. +# 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: @@ -189,3 +197,69 @@ jobs: 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 1a26e3a..3d2fb7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,9 @@ All notable changes to CableDyn are recorded in this file. The format follows - Release wheels built in CI (`.github/workflows/wheels.yml`): Linux x86-64 (`manylinux_2_28`, x86-64-v3), macOS arm64 and Windows x86-64 (IFX, static, the release's reference LAPACK), plus the sdist. Each wheel is installed into a clean environment and smoke-tested - (`release/smoke_test_wheel.py`); publishing is a separate step. + (`release/smoke_test_wheel.py`). The same workflow publishes them through PyPI Trusted + Publishing: to TestPyPI on request from a manual run, and to PyPI when a GitHub release is + published (after checking the release tag's version and approval of the `pypi` environment). ### Changed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8a908be..2f7bfa8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -137,8 +137,11 @@ A wheel built this way still needs the toolchain's runtime and LAPACK libraries 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; publishing -them is a separate step. It builds the sdist (checked with `twine`) and three wheels: +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 | |---|---|---|---| 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 From ab7cdfcdb94d627e96d9b9d952bcc85879ab9f1f Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 23 Oct 2026 12:41:26 +0900 Subject: [PATCH 14/17] fix(hermite): keep the cached committed force across snapshot and restore (#46) A restore followed by a step must be bit-identical to taking that step from the saved state (CableDyn_RestoreState, OpenFAST correction iterations). Restore discarded the committed force cached from the previous step and evaluated it afresh. On a finite-EI cable with seabed friction the two differ in the last bits: the commit's return mapping moves a sliding node's anchor to q - c*dxy, and the spring force from q minus that anchor reproduces the capped force only to rounding. Whether it showed depended on the trajectory (the controlled-cable snapshot test failed on the Linux Debug runner only). Snapshot now keeps the cache with the state, and Restore brings it back unless a setter invalidated the cache since the snapshot (fc_clears counts the invalidations, including the OpenFAST checkpoint reload). The control test asserts the restored cache again instead of clearing the reference's, and the cache comment states what holds. Runs without restores do not change. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 7 ++++ src/CableDyn_HermiteCableDynamic.f90 | 60 ++++++++++++++++++++++++++-- src/CableDyn_OpenFAST_HermiteFMF.f90 | 1 + tests/test_hermite_cable_control.f90 | 13 +++--- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d2fb7e..8ea3bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,13 @@ All notable changes to CableDyn are recorded in this file. The format follows ### Fixed +- Finite-EI cables with seabed friction: a restored state (the C API `CableDyn_RestoreState`, + OpenFAST correction iterations) could continue differently in the last bits from the step taken + before the restore, breaking the documented bit-identity. A sliding node's moved friction + anchor reproduces the capped friction force only to rounding, so the committed force cached + from the step and a fresh evaluation differ slightly; the restore discarded the cache and took + the fresh path. The snapshot now keeps the cached force with the state, and the restore brings + it back unless a load setter invalidated it since. Runs without restores do not change. - OpenFAST with `dtM` larger than `DT`: an `openfast -restart` re-sampled the SeaState fields of the committed step at its end positions instead of restoring the fields the step held (sampled at its start positions), so the loads served until the next solve, and the run after them, diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index d239da1..3e5b24b 100644 --- a/src/CableDyn_HermiteCableDynamic.f90 +++ b/src/CableDyn_HermiteCableDynamic.f90 @@ -281,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 @@ -713,6 +723,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 @@ -778,6 +789,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 @@ -926,6 +938,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 @@ -971,6 +984,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 @@ -1049,6 +1063,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 @@ -1701,6 +1716,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 @@ -1924,6 +1940,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 @@ -2294,6 +2311,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 @@ -2393,6 +2411,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 @@ -2464,6 +2483,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 @@ -2560,6 +2580,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 @@ -2680,6 +2701,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 @@ -2819,6 +2841,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 @@ -3086,6 +3109,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 @@ -6141,6 +6165,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 @@ -6332,6 +6357,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 @@ -6411,7 +6446,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 @@ -6493,7 +6542,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 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/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) From 6399796389575613bbd84f4e12ebe673b3e56462 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 23 Oct 2026 14:34:42 +0900 Subject: [PATCH 15/17] fix(output): recover node tension through touchdown on coarse meshes (#47) The equilibrium recovery of finite-EI node values spread each node's floor reaction over the elements beside it by tributary length, including the touchdown element, which lies on the floor at one end only. That understated the tension of the last suspended node by the floor reaction of half an element: 5.3 % on a 10 m lazy-wave mesh against a mesh four times finer. A node's reaction is now shared by the grounded elements beside it (both nodes on the floor); a node with none keeps the tributary split. The coarse mesh follows the fine one within 3 % through touchdown (tests/check_touchdown_recovery.py fails on the old recovery). The node-value cache keys on the neighbouring nodes' positions as well, since their contact now decides an element's share. Line end forces and the solution do not change. The larger coarse-mesh wiggle comes from seabed friction, which acts at the nodes: as the line lands and lifts, a grounded element locks or releases up to mu w L, and the laid tension swings by that much (std 491, 136, 42, 33 N with 10, 5, 2.5, 1 m elements on the same cable). At initialisation a finite-EI line whose grounded elements lock more than 15 % of the touchdown tension now gets a note with the grounded element length that resolves it (flat seabed; standalone and OpenFAST). The manual explains this mesh resolution, the mesh dependence of the negative BA (damping ratio) form, whose ratio holds for each element's own axial mode while the line's modes get zeta n pi / (2N), and the measured trade-off of modified_newton, which stays off by default. A static solve in a current that fails because seabed friction holds the grounded run while the current pushes the suspended span into the touchdown ran the mesh-sequenced route to its iteration budget after the catenary-continuation route had diagnosed it; the frictionless grounded-run diagnosis already skipped that route. Both diagnoses now skip it (the symmetry-breaking pass still runs): the robustness study's 1000 m lazy wave in 440 m of water fails closed in 17.7 s instead of 22 s with the same message, and its 15 converging friction-and-current cases are unchanged. An anchor held exactly on the floor has no contact force of its own, so the grounded-element rule of the node recovery counted its laid end element as suspended and moved the next node's whole floor reaction to the following element; the held-end floor support restored only the anchor's side, leaving a weight moment at the anchor and its neighbour (521 N.m, 31 % of w l0^2/12, on a 100 m line laid on the penalty floor of CD_HermiteCable_Dyn_Init). A held end whose end element the floor supports (held_end_floor_support) now counts as on the floor; test_hermite_cable_static checks the laid line (4.6 N.m left, the bending of the line settling into the floor). Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 22 +++++ CMakeLists.txt | 9 ++ doc/conventions.rst | 4 +- doc/options.rst | 12 ++- doc/theory.rst | 28 +++++++ src/CableDyn_DeckDriver.f90 | 81 ++++++++++++++++-- src/CableDyn_HermiteCableDynamic.f90 | 110 +++++++++++++++++++++--- tests/check_driver_advisories.cmake | 58 ++++++++++++- tests/check_touchdown_recovery.py | 121 +++++++++++++++++++++++++++ tests/test_hermite_cable_static.f90 | 53 +++++++++++- 10 files changed, 476 insertions(+), 22 deletions(-) create mode 100644 tests/check_touchdown_recovery.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea3bb5..ee1ed8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ All notable changes to CableDyn are recorded in this file. The format follows ### Added +- Documentation of the negative `BA` (damping ratio) form: it damps each element's own axial + mode at the given ratio, so the line's axial modes are damped at `zeta n pi / (2N)` and less on + a finer mesh (`BA = -1`: 1.6 % for the first mode on 100 elements, 0.8 % on 200); the manual + gives the `BA` in N·s for a mesh-independent material damping ratio. +- Seabed-friction resolution note: friction acts at the nodes, so on a coarse grounded mesh the + tension of the laid part and near touchdown steps by up to mu w L per node as the line lands + and lifts (a 10 m lazy-wave mesh swung a grounded segment tension by 1600 N where 1 m elements + give 120 N). At initialisation a finite-EI line whose grounded elements lock more than 15 % of + the touchdown tension is noted with the grounded element length that resolves it (flat + seabed; standalone and OpenFAST). Results do not change. - The run-time profile (`CABLEDYN_PROFILE=1`, standalone driver and OpenFAST) splits the steps of `EI = 0` lines into residual-only evaluations, residual and tangent evaluations, LU factorisations and back-substitutions, with their counts and times, and separates the @@ -49,6 +59,11 @@ All notable changes to CableDyn are recorded in this file. The format follows ### Changed +- A finite-EI static solve in a current that fails because seabed friction holds the grounded + run while the current pushes the suspended span into the touchdown now stops after the + catenary-continuation route has diagnosed it, as the frictionless grounded-run case already + did, instead of also running the mesh-sequenced route to its iteration budget (a 1000 m lazy + wave in 440 m of water: 22 s to 17.7 s, same message). Converging decks do not change. - The Windows release executables (`CableDyn_driver.exe`, `openfast.exe`) are compiled for processors with AVX2 (x86-64-v3: Intel Haswell and AMD Excavator or newer; CMake option `CABLEDYN_TARGET_AVX2`), which makes the driver 13-33% faster on the benchmark decks @@ -75,6 +90,13 @@ All notable changes to CableDyn are recorded in this file. The format follows from the step and a fresh evaluation differ slightly; the restore discarded the cache and took the fresh path. The snapshot now keeps the cached force with the state, and the restore brings it back unless a load setter invalidated it since. Runs without restores do not change. +- Finite-EI node tension, curvature and bend moment at touchdown (node channels, range graphs): + the recovery spread a grounded node's floor reaction over the touchdown element, which is on + the floor at one end only, and understated the tension of the last suspended node by the + floor reaction of half an element (5.3 % on a 10 m lazy-wave mesh). A node's floor reaction is + now shared by the grounded elements beside it; the touchdown element takes none. The same + coarse mesh now follows a four times finer one within 3 % through touchdown. Line end forces + (`FairTen`, `AnchTen`) and the solution do not change. - OpenFAST with `dtM` larger than `DT`: an `openfast -restart` re-sampled the SeaState fields of the committed step at its end positions instead of restoring the fields the step held (sampled at its start positions), so the loads served until the next solve, and the run after them, diff --git a/CMakeLists.txt b/CMakeLists.txt index 28b84a8..f1b2ba5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2284,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/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/options.rst b/doc/options.rst index fd1f4a3..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`` 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/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index ce6604e..c43aef6 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -2235,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 @@ -3056,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 @@ -4370,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) diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index 3e5b24b..edf6b65 100644 --- a/src/CableDyn_HermiteCableDynamic.f90 +++ b/src/CableDyn_HermiteCableDynamic.f90 @@ -494,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 @@ -737,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 @@ -5509,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 @@ -5528,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 @@ -5881,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 @@ -5937,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) @@ -6509,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) diff --git a/tests/check_driver_advisories.cmake b/tests/check_driver_advisories.cmake index d34e8a4..4b653d4 100644 --- a/tests/check_driver_advisories.cmake +++ b/tests/check_driver_advisories.cmake @@ -5,7 +5,9 @@ # * 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; # * 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). +# 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}") @@ -111,3 +113,57 @@ require_text("no seabed" "${_no_seabed_log}" "below its Fixed anchor and the dec 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_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 From 7fa9e03a63b34a49993388e2cb09edc76daa33be Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 23 Oct 2026 12:32:50 +0900 Subject: [PATCH 16/17] chore(release): prepare CableDyn 0.3.0 Set the version to 0.3.0 (released 2026-10-23) in every file the release metadata check names (C API 0.3.0, ABI 1), and replace the unreleased notes with a short [0.3.0] section, one entry per user-visible change. The README, the Python package README (the PyPI project page) and the installation chapter install from PyPI with pip install cabledyn, name the platforms whose wheels carry the solver, and keep the pure wheel of the release page with its driver search. Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 130 +++++++++--------------------- CITATION.cff | 4 +- CMakeLists.txt | 2 +- README.md | 29 ++++--- doc/capi.rst | 8 +- doc/citing.rst | 6 +- doc/coupling_boundary.md | 2 +- doc/index.rst | 11 +-- doc/installation.rst | 41 +++++----- doc/openfast.rst | 2 +- doc/quickstart.rst | 4 +- doc/tutorial_openfast.rst | 2 +- examples/openfast/README.md | 2 +- integration/openfast/README.md | 2 +- pyproject.toml | 2 +- python/README.md | 26 +++--- python/cabledyn/__init__.py | 2 +- python/pyproject.toml | 2 +- release/check_release_metadata.py | 4 +- src/CableDyn_Banner.f90 | 2 +- src/CableDyn_CAPI.f90 | 4 +- src/CableDyn_CAPI.h | 2 +- src/openfast/CableDyn_OF.f90 | 2 +- tests/test_c_api.f90 | 2 +- 24 files changed, 129 insertions(+), 164 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1ed8d..37ef50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,105 +8,54 @@ All notable changes to CableDyn are recorded in this file. The format follows ## [Unreleased] +## [0.3.0] - 2026-10-23 + ### Added -- Documentation of the negative `BA` (damping ratio) form: it damps each element's own axial - mode at the given ratio, so the line's axial modes are damped at `zeta n pi / (2N)` and less on - a finer mesh (`BA = -1`: 1.6 % for the first mode on 100 elements, 0.8 % on 200); the manual - gives the `BA` in N·s for a mesh-independent material damping ratio. -- Seabed-friction resolution note: friction acts at the nodes, so on a coarse grounded mesh the - tension of the laid part and near touchdown steps by up to mu w L per node as the line lands - and lifts (a 10 m lazy-wave mesh swung a grounded segment tension by 1600 N where 1 m elements - give 120 N). At initialisation a finite-EI line whose grounded elements lock more than 15 % of - the touchdown tension is noted with the grounded element length that resolves it (flat - seabed; standalone and OpenFAST). Results do not change. -- The run-time profile (`CABLEDYN_PROFILE=1`, standalone driver and OpenFAST) splits the steps - of `EI = 0` lines into residual-only evaluations, residual and tangent evaluations, LU - factorisations and back-substitutions, with their counts and times, and separates the - factorisations of finite-EI cables from their back-substitutions (which now include the - force-blend correction that ends each step). Results do not change. -- The run-time profile also splits the residual and tangent assemblies of finite-EI cables into - their sections and, in OpenFAST, `CalcOutput` into the kinematics overlay, the output channels - and the restore, and the SeaState sampling into the node queries, the grid checks and the - hand-over of the fields. Results do not change. -- dtM advisory: at initialisation, a line whose axial period (twice its length over - `sqrt(EA/m)`) the time step `dtM` resolves in fewer than six steps gets a note with the - period and a `dtM` that resolves it, on the standalone, mixed and OpenFAST routes. Results do - not change. -- 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`; `False` keeps the hold). The error drops from first to second order in `dtM`: on the +- `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; the previous solve's loads are checkpointed, so a restart - continues the extrapolation. - -- `validation/scripts/openfast_supercycle_ab.py`: compares coupled runs at a coarser `dtM` with - the same model at `dtM = DT` (platform time series, tension statistics, rainflow - damage-equivalent loads); `VALIDATION.md` records the supercycling verification it scores. - -- Packaging for PyPI: a root `pyproject.toml` (scikit-build-core) builds an sdist of the sources - and a `py3-none-` wheel that carries the compiled library and the standalone driver - inside the `cabledyn` package, so the package needs no separate library; the driver search - takes the bundled driver after `CABLEDYN_DRIVER` and before `PATH`. `python/pyproject.toml` - remains the pure package for development against a build tree. -- Release wheels built in CI (`.github/workflows/wheels.yml`): Linux x86-64 (`manylinux_2_28`, - x86-64-v3), macOS arm64 and Windows x86-64 (IFX, static, the release's reference LAPACK), plus - the sdist. Each wheel is installed into a clean environment and smoke-tested - (`release/smoke_test_wheel.py`). The same workflow publishes them through PyPI Trusted - Publishing: to TestPyPI on request from a manual run, and to PyPI when a GitHub release is - published (after checking the release tag's version and approval of the `pypi` environment). + `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 -- A finite-EI static solve in a current that fails because seabed friction holds the grounded - run while the current pushes the suspended span into the touchdown now stops after the - catenary-continuation route has diagnosed it, as the frictionless grounded-run case already - did, instead of also running the mesh-sequenced route to its iteration budget (a 1000 m lazy - wave in 440 m of water: 22 s to 17.7 s, same message). Converging decks do not change. -- The Windows release executables (`CableDyn_driver.exe`, `openfast.exe`) are compiled for - processors with AVX2 (x86-64-v3: Intel Haswell and AMD Excavator or newer; CMake option - `CABLEDYN_TARGET_AVX2`), which makes the driver 13-33% faster on the benchmark decks - (`lazy_wave_vessel_rao` 11.9 s to 8.8 s, `als_volturnus_line_break_time` 35.5 s to 23.9 s). - On an older processor they stop at start with a message naming the missing instructions. - Results change at round-off: in the last printed digit on the lazy-wave and buoy decks; in the - line-break deck the platform's slow drift after the break amplifies the round-off past 900 s, - with the same peaks and standard deviations to four digits. -- OpenFAST: `CalcOutput` no longer evaluates the reactions of the finite-EI cables when it saves - the committed kinematics before transferring the current ones; it discarded them. With a - 248-element power cable in the IEA-15 VolturnUS-S mooring this cuts `CalcOutput` from 41 s to - 25 s and the 600 s run from 227 s to 212 s. Results do not change. -- The reaction of a finite-EI cable at a node (the fairlead load the OpenFAST module returns) - is assembled from the one or two elements that reach the node instead of the whole line, - bit-identical to the full assembly (lines with torsion keep the full assembly). In the same - run this cuts `CalcOutput` to 9 s and the run to 194 s. Results do not change. +- 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 cables with seabed friction: a restored state (the C API `CableDyn_RestoreState`, - OpenFAST correction iterations) could continue differently in the last bits from the step taken - before the restore, breaking the documented bit-identity. A sliding node's moved friction - anchor reproduces the capped friction force only to rounding, so the committed force cached - from the step and a fresh evaluation differ slightly; the restore discarded the cache and took - the fresh path. The snapshot now keeps the cached force with the state, and the restore brings - it back unless a load setter invalidated it since. Runs without restores do not change. -- Finite-EI node tension, curvature and bend moment at touchdown (node channels, range graphs): - the recovery spread a grounded node's floor reaction over the touchdown element, which is on - the floor at one end only, and understated the tension of the last suspended node by the - floor reaction of half an element (5.3 % on a 10 m lazy-wave mesh). A node's floor reaction is - now shared by the grounded elements beside it; the touchdown element takes none. The same - coarse mesh now follows a four times finer one within 3 % through touchdown. Line end forces - (`FairTen`, `AnchTen`) and the solution do not change. -- OpenFAST with `dtM` larger than `DT`: an `openfast -restart` re-sampled the SeaState fields of the - committed step at its end positions instead of restoring the fields the step held (sampled at - its start positions), so the loads served until the next solve, and the run after them, - differed slightly from the uninterrupted run (up to 2e-3 of a tension's standard deviation). - The checkpoint now carries the held fields, and a restart continues the run bit for bit at any - `dtM`. The checkpoint layout changes with this and with the load predictor's fields: a - checkpoint written by an earlier CableDyn cannot be restarted with this one (OpenFAST - checkpoints are not versioned; restart with the executable that wrote them). -- The FAQ and the OpenFAST tutorial still gave 0.1 s as the default `dtM` in OpenFAST; since - 0.2.0 it is the glue `DT`. +- 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 @@ -636,7 +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.2.0...HEAD +[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 f1b2ba5..27126a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,7 +8,7 @@ # cmake_minimum_required(VERSION 3.20) # VERSION is a bare numeric triple matching the release version. -project(CableDynCore VERSION 0.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) diff --git a/README.md b/README.md index edc103a..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 @@ -48,8 +48,8 @@ a named error rather than running approximately. |---|---| | `CableDyn_driver.exe` | Standalone solver for Windows x64 (AVX2 processor) | | `openfast.exe` | OpenFAST with CableDyn available as `CompMooring = 5` | - | `cabledyn-0.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/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/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/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 6b6eea9..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 @@ -64,7 +64,7 @@ Download and verify ~~~~~~~~~~~~~~~~~~~ 1. Create a folder, for example ``C:\CableDyn``, and download into it ``CableDyn_driver.exe``, - ``openfast.exe`` (only if you will couple with OpenFAST), ``cabledyn-0.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: @@ -84,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. @@ -94,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\ @@ -120,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) ------------------------------------------------------------------- @@ -161,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 @@ -196,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`. @@ -267,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/openfast.rst b/doc/openfast.rst index 5f579a3..e21b582 100644 --- a/doc/openfast.rst +++ b/doc/openfast.rst @@ -205,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 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/tutorial_openfast.rst b/doc/tutorial_openfast.rst index 8a8d832..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) 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 index b09e93d..af814b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ build-backend = "scikit_build_core.build" [project] name = "cabledyn" -version = "0.2.0" +version = "0.3.0" description = "Python APIs for the CableDyn standalone driver and native coupling library" readme = "python/README.md" requires-python = ">=3.10" 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/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/release/check_release_metadata.py b/release/check_release_metadata.py index 7a6f1a1..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\.)?|(? 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/openfast/CableDyn_OF.f90 b/src/openfast/CableDyn_OF.f90 index ba710ac..c963a00 100644 --- a/src/openfast/CableDyn_OF.f90 +++ b/src/openfast/CableDyn_OF.f90 @@ -121,7 +121,7 @@ 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 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, & From c887917029959410a55770bbe49a1cdd34eafb95 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 23 Oct 2026 14:11:23 +0900 Subject: [PATCH 17/17] chore(release): record the builds and checks of 0.3.0 validation/RELEASE_0_3_0.md records the source, pins, toolchains, the GNU and CI gates, the Windows executable gates of the release workflow (run 39898803772), the 26-check clean-machine test of the assets and every shipped example with the release driver, the hashes of the release assets and of the preflight PyPI distributions (run 39898806566). The Holcombe and Bergdahl records were re-run with the 0.3.0 GNU build and reproduce byte for byte; their provenance names v0.3.0. Signed-off-by: Jae Hoon Seo --- validation/RELEASE_0_3_0.md | 96 +++++++++++++++++++ .../experiments/bergdahl/provenance.json | 6 +- .../experiments/holcombe/provenance.json | 6 +- 3 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 validation/RELEASE_0_3_0.md 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",