From aac8105b3e91563cdcabd505298e59b214a0ea79 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Sun, 8 Nov 2026 16:17:10 +0900 Subject: [PATCH 01/40] docs(release): a version placeholder in the tagging example (#107) Signed-off-by: Jae Hoon Seo --- RELEASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index 60bd59c..a4fc134 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -140,7 +140,7 @@ python release/smoke_test_wheel.py examples/dynamic_chain_current.dat --expect-b Tag only the commit that passed every check: ```powershell -$ReleaseVersion = "0.4.0" +$ReleaseVersion = "MAJOR.MINOR.PATCH" git tag -s "v$ReleaseVersion" -m "CableDyn v$ReleaseVersion" git push origin "v$ReleaseVersion" ``` From b7c883b79041e45c083adc0cfdb28bf2853fe9b1 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Sun, 8 Nov 2026 17:03:54 +0900 Subject: [PATCH 02/40] test(statics): gate the low-EI sweep cases on the deck route (#108) Signed-off-by: Jae Hoon Seo --- VALIDATION.md | 23 +++-- tests/data/cable_statics_matrix.json | 132 +++++++++++++++++++++++++++ tests/test_static_init_sweep.f90 | 27 +++--- 3 files changed, 164 insertions(+), 18 deletions(-) diff --git a/VALIDATION.md b/VALIDATION.md index 722cc8d..e556c0a 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -743,17 +743,28 @@ cannot restart the buoyancy ramp. AutoMesh warm-start refinement is tested by buoyant fraction. Each is solved by the production mesh-sequenced solver from a library-built trivial seed (`CD_HermiteCable_Trivial_Seed`: endpoints, rest lengths, and seabed in; isometric seed out; no catenary, seed files, or per-case -tuning). Result: **117 pass, 3 known failures, 2 cases annotated as known failures -that now pass, 0 unexpected failures, and 0 silent wrong answers**. Known-failure -annotations are checked both ways: an annotated case must either fail cleanly or be -reported when it passes. +tuning). Result: **118 pass, 4 known failures, 0 unexpected failures, and 0 silent +wrong answers**, the same on every OpenBLAS kernel. Known-failure annotations are checked +both ways: an annotated case must either fail cleanly or be reported when it passes. + +The deck route solves all 122 cases but the one beyond the L-shape bound that it refuses by +name: a deck seeds a line with a buoyant section as a lazy wave, starts it from the +`EI = 0` catenary and refines a kinked state locally, never this kernel from the trivial seed. +Run through the standalone driver on both `cable_statics` routes, the 121 equilibria agree +between the routes to 1.3e-7 in tension and 1e-5 m in position, with `max(h*kappa)` at most +0.56. The four known failures of the kernel and the former fifth are cases of the +`cable_statics_matrix` test (entries `SIS_*`, both routes): where the arch stays submerged +their fairlead tension is within 2.4e-4 of an independent elastic multi-segment `EI = 0` +catenary; two float their arch at the surface. - On a frictionless bed, a smooth planar equilibrium exists only while the rest length is below the L-shape bound `L < h_fairlead + x_anchor`; beyond it the surplus must fold on the bed. The slack axis runs to 0.97× the bound. A separate family crosses it and must return either a clean error or a finite - bounded-curvature solution, never NaN or a silent cusp. The three known failures - are hard contact cases at the lowest EI of the sweep (5e2 N·m²). + bounded-curvature solution, never NaN or a silent cusp. The four known failures + are lazy waves at the lowest EI of the sweep (5e2 N·m²): from the trivial seed the + kernel reaches a residual-converged kink of a few elements in mid-water, which the + check below rejects. - A residual-converged state is not sufficient: the solver rejects an element-localised branch when `max(h*kappa) > 0.7`, so a folded equilibrium cannot enter dynamics. diff --git a/tests/data/cable_statics_matrix.json b/tests/data/cable_statics_matrix.json index 9e5e0d4..c1b3541 100644 --- a/tests/data/cable_statics_matrix.json +++ b/tests/data/cable_statics_matrix.json @@ -1612,5 +1612,137 @@ } }, "mesh_of": "ROUGH_1003" + }, + { + "id": "SIS_B800_EA5E7_EI5E2", + "expect": "admissible", + "why": "static_init_sweep case 87 (family b, depth 800 m, slackness 1.25, buoyancy ratio 1.5, EA 5e+07 N, EI 500 N m2, kBot 100000, span ratio 1, buoyant fraction 0.29) through the deck route; oracle: elastic multi-segment EI = 0 catenary", + "deck": "--------------------- CableDyn Input File ------------------------------------\nSW_B800_EA5E7_EI5E2\n--------------------- LINE TYPES ---------------------------------------------\nTypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t\n(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-)\nbare 0.160 36.720364 5e+07 0.0 500 1.2 0.1 1.0 0.0\nbuoy 0.290 0.0 5e+07 0.0 500 1.2 0.1 1.0 0.0\n--------------------- POINTS ------------------------------------------------\nID Type X Y Z\n(-) (-) (m) (m) (m)\n1 Coupled 0.0 0.0 -16\n2 Fixed 800 0.0 -800\n--------------------- LINES -------------------------------------------------\nID NodeA NodeB Outputs\n(-) (-) (-) (-)\n1 1 2 -\n--------------------- SECTIONS ----------------------------------------------\nLineID LineType Length NumSegs\n(-) (-) (m) (-)\n1 bare 393.790176522 72\n1 buoy 404.728792536 74\n1 bare 601.623880797 110\n--------------------- EQUIVALENT BUOYANCY -----------------------------------\nLineType Diam SubmergedWeightNpm\n(-) (m) (N/m)\nbuoy 0.290 -237\n--------------------- OPTIONS -----------------------------------------------\n800 WtrDpth\n100000 kBot\n0.0 TMax\nnone frictionMu\n--------------------- OUTPUTS -----------------------------------------------\n\"FairTen1\"\n\"AnchTen1\"\n--------------------- need this line ----------------------------------------\n", + "lines": { + "1": { + "oracle_tfair": [ + 45921.372599184884 + ], + "lambda_over_length": 0.0010485728908409383 + } + } + }, + { + "id": "SIS_B800_EA5E7_EI5E2_SEQ", + "expect": "admissible", + "why": "static_init_sweep case 87 (family b, depth 800 m, slackness 1.25, buoyancy ratio 1.5, EA 5e+07 N, EI 500 N m2, kBot 100000, span ratio 1, buoyant fraction 0.29) through the deck route, mesh-sequenced route first; oracle: elastic multi-segment EI = 0 catenary", + "deck": "--------------------- CableDyn Input File ------------------------------------\nSW_B800_EA5E7_EI5E2_SEQ\n--------------------- LINE TYPES ---------------------------------------------\nTypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t\n(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-)\nbare 0.160 36.720364 5e+07 0.0 500 1.2 0.1 1.0 0.0\nbuoy 0.290 0.0 5e+07 0.0 500 1.2 0.1 1.0 0.0\n--------------------- POINTS ------------------------------------------------\nID Type X Y Z\n(-) (-) (m) (m) (m)\n1 Coupled 0.0 0.0 -16\n2 Fixed 800 0.0 -800\n--------------------- LINES -------------------------------------------------\nID NodeA NodeB Outputs\n(-) (-) (-) (-)\n1 1 2 -\n--------------------- SECTIONS ----------------------------------------------\nLineID LineType Length NumSegs\n(-) (-) (m) (-)\n1 bare 393.790176522 72\n1 buoy 404.728792536 74\n1 bare 601.623880797 110\n--------------------- EQUIVALENT BUOYANCY -----------------------------------\nLineType Diam SubmergedWeightNpm\n(-) (m) (N/m)\nbuoy 0.290 -237\n--------------------- OPTIONS -----------------------------------------------\n800 WtrDpth\n100000 kBot\n0.0 TMax\nnone frictionMu\nsequenced cable_statics\n--------------------- OUTPUTS -----------------------------------------------\n\"FairTen1\"\n\"AnchTen1\"\n--------------------- need this line ----------------------------------------\n", + "lines": { + "1": { + "oracle_tfair": [ + 45921.372599184884 + ], + "lambda_over_length": 0.0010485728908409383 + } + } + }, + { + "id": "SIS_B800_EA2E9_EI5E2", + "expect": "admissible", + "why": "static_init_sweep case 89 (family b, depth 800 m, slackness 1.25, buoyancy ratio 1.5, EA 2e+09 N, EI 500 N m2, kBot 100000, span ratio 1, buoyant fraction 0.29) through the deck route; oracle: elastic multi-segment EI = 0 catenary", + "deck": "--------------------- CableDyn Input File ------------------------------------\nSW_B800_EA2E9_EI5E2\n--------------------- LINE TYPES ---------------------------------------------\nTypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t\n(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-)\nbare 0.160 36.720364 2e+09 0.0 500 1.2 0.1 1.0 0.0\nbuoy 0.290 0.0 2e+09 0.0 500 1.2 0.1 1.0 0.0\n--------------------- POINTS ------------------------------------------------\nID Type X Y Z\n(-) (-) (m) (m) (m)\n1 Coupled 0.0 0.0 -16\n2 Fixed 800 0.0 -800\n--------------------- LINES -------------------------------------------------\nID NodeA NodeB Outputs\n(-) (-) (-) (-)\n1 1 2 -\n--------------------- SECTIONS ----------------------------------------------\nLineID LineType Length NumSegs\n(-) (-) (m) (-)\n1 bare 393.790176522 72\n1 buoy 404.728792536 74\n1 bare 601.623880797 110\n--------------------- EQUIVALENT BUOYANCY -----------------------------------\nLineType Diam SubmergedWeightNpm\n(-) (m) (N/m)\nbuoy 0.290 -237\n--------------------- OPTIONS -----------------------------------------------\n800 WtrDpth\n100000 kBot\n0.0 TMax\nnone frictionMu\n--------------------- OUTPUTS -----------------------------------------------\n\"FairTen1\"\n\"AnchTen1\"\n--------------------- need this line ----------------------------------------\n", + "lines": { + "1": { + "oracle_tfair": [ + 45968.597408873895 + ], + "lambda_over_length": 0.0010485728908409383 + } + } + }, + { + "id": "SIS_B800_EA2E9_EI5E2_SEQ", + "expect": "admissible", + "why": "static_init_sweep case 89 (family b, depth 800 m, slackness 1.25, buoyancy ratio 1.5, EA 2e+09 N, EI 500 N m2, kBot 100000, span ratio 1, buoyant fraction 0.29) through the deck route, mesh-sequenced route first; oracle: elastic multi-segment EI = 0 catenary", + "deck": "--------------------- CableDyn Input File ------------------------------------\nSW_B800_EA2E9_EI5E2_SEQ\n--------------------- LINE TYPES ---------------------------------------------\nTypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t\n(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-)\nbare 0.160 36.720364 2e+09 0.0 500 1.2 0.1 1.0 0.0\nbuoy 0.290 0.0 2e+09 0.0 500 1.2 0.1 1.0 0.0\n--------------------- POINTS ------------------------------------------------\nID Type X Y Z\n(-) (-) (m) (m) (m)\n1 Coupled 0.0 0.0 -16\n2 Fixed 800 0.0 -800\n--------------------- LINES -------------------------------------------------\nID NodeA NodeB Outputs\n(-) (-) (-) (-)\n1 1 2 -\n--------------------- SECTIONS ----------------------------------------------\nLineID LineType Length NumSegs\n(-) (-) (m) (-)\n1 bare 393.790176522 72\n1 buoy 404.728792536 74\n1 bare 601.623880797 110\n--------------------- EQUIVALENT BUOYANCY -----------------------------------\nLineType Diam SubmergedWeightNpm\n(-) (m) (N/m)\nbuoy 0.290 -237\n--------------------- OPTIONS -----------------------------------------------\n800 WtrDpth\n100000 kBot\n0.0 TMax\nnone frictionMu\nsequenced cable_statics\n--------------------- OUTPUTS -----------------------------------------------\n\"FairTen1\"\n\"AnchTen1\"\n--------------------- need this line ----------------------------------------\n", + "lines": { + "1": { + "oracle_tfair": [ + 45968.597408873895 + ], + "lambda_over_length": 0.0010485728908409383 + } + } + }, + { + "id": "SIS_B800_EI5E2", + "expect": "admissible", + "why": "static_init_sweep case 91 (family b, depth 800 m, slackness 1.25, buoyancy ratio 1.5, EA 4.69e+08 N, EI 500 N m2, kBot 100000, span ratio 1, buoyant fraction 0.29) through the deck route; oracle: elastic multi-segment EI = 0 catenary", + "deck": "--------------------- CableDyn Input File ------------------------------------\nSW_B800_EI5E2\n--------------------- LINE TYPES ---------------------------------------------\nTypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t\n(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-)\nbare 0.160 36.720364 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\nbuoy 0.290 0.0 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\n--------------------- POINTS ------------------------------------------------\nID Type X Y Z\n(-) (-) (m) (m) (m)\n1 Coupled 0.0 0.0 -16\n2 Fixed 800 0.0 -800\n--------------------- LINES -------------------------------------------------\nID NodeA NodeB Outputs\n(-) (-) (-) (-)\n1 1 2 -\n--------------------- SECTIONS ----------------------------------------------\nLineID LineType Length NumSegs\n(-) (-) (m) (-)\n1 bare 393.790176522 72\n1 buoy 404.728792536 74\n1 bare 601.623880797 110\n--------------------- EQUIVALENT BUOYANCY -----------------------------------\nLineType Diam SubmergedWeightNpm\n(-) (m) (N/m)\nbuoy 0.290 -237\n--------------------- OPTIONS -----------------------------------------------\n800 WtrDpth\n100000 kBot\n0.0 TMax\nnone frictionMu\n--------------------- OUTPUTS -----------------------------------------------\n\"FairTen1\"\n\"AnchTen1\"\n--------------------- need this line ----------------------------------------\n", + "lines": { + "1": { + "oracle_tfair": [ + 45964.63285892178 + ], + "lambda_over_length": 0.0010485728908409383 + } + } + }, + { + "id": "SIS_B800_EI5E2_SEQ", + "expect": "admissible", + "why": "static_init_sweep case 91 (family b, depth 800 m, slackness 1.25, buoyancy ratio 1.5, EA 4.69e+08 N, EI 500 N m2, kBot 100000, span ratio 1, buoyant fraction 0.29) through the deck route, mesh-sequenced route first; oracle: elastic multi-segment EI = 0 catenary", + "deck": "--------------------- CableDyn Input File ------------------------------------\nSW_B800_EI5E2_SEQ\n--------------------- LINE TYPES ---------------------------------------------\nTypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t\n(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-)\nbare 0.160 36.720364 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\nbuoy 0.290 0.0 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\n--------------------- POINTS ------------------------------------------------\nID Type X Y Z\n(-) (-) (m) (m) (m)\n1 Coupled 0.0 0.0 -16\n2 Fixed 800 0.0 -800\n--------------------- LINES -------------------------------------------------\nID NodeA NodeB Outputs\n(-) (-) (-) (-)\n1 1 2 -\n--------------------- SECTIONS ----------------------------------------------\nLineID LineType Length NumSegs\n(-) (-) (m) (-)\n1 bare 393.790176522 72\n1 buoy 404.728792536 74\n1 bare 601.623880797 110\n--------------------- EQUIVALENT BUOYANCY -----------------------------------\nLineType Diam SubmergedWeightNpm\n(-) (m) (N/m)\nbuoy 0.290 -237\n--------------------- OPTIONS -----------------------------------------------\n800 WtrDpth\n100000 kBot\n0.0 TMax\nnone frictionMu\nsequenced cable_statics\n--------------------- OUTPUTS -----------------------------------------------\n\"FairTen1\"\n\"AnchTen1\"\n--------------------- need this line ----------------------------------------\n", + "lines": { + "1": { + "oracle_tfair": [ + 45964.63285892178 + ], + "lambda_over_length": 0.0010485728908409383 + } + } + }, + { + "id": "SIS_F800_KN3E6_EI5E2", + "expect": "admissible", + "why": "static_init_sweep case 115 (family f, depth 800 m, slackness 1.372, buoyancy ratio 2.5, EA 4.69e+08 N, EI 500 N m2, kBot 3e+06, span ratio 1, buoyant fraction 0.29) through the deck route; the buoyant arch floats at the surface, so no submerged EI = 0 root applies", + "deck": "--------------------- CableDyn Input File ------------------------------------\nSW_F800_KN3E6_EI5E2\n--------------------- LINE TYPES ---------------------------------------------\nTypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t\n(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-)\nbare 0.160 36.720364 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\nbuoy 0.290 0.0 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\n--------------------- POINTS ------------------------------------------------\nID Type X Y Z\n(-) (-) (m) (m) (m)\n1 Coupled 0.0 0.0 -16\n2 Fixed 800 0.0 -800\n--------------------- LINES -------------------------------------------------\nID NodeA NodeB Outputs\n(-) (-) (-) (-)\n1 1 2 -\n--------------------- SECTIONS ----------------------------------------------\nLineID LineType Length NumSegs\n(-) (-) (m) (-)\n1 bare 432.135000000 72\n1 buoy 444.138750000 74\n1 bare 660.206250000 110\n--------------------- EQUIVALENT BUOYANCY -----------------------------------\nLineType Diam SubmergedWeightNpm\n(-) (m) (N/m)\nbuoy 0.290 -395\n--------------------- OPTIONS -----------------------------------------------\n800 WtrDpth\n3e+06 kBot\n0.0 TMax\nnone frictionMu\n--------------------- OUTPUTS -----------------------------------------------\n\"FairTen1\"\n\"AnchTen1\"\n--------------------- need this line ----------------------------------------\n", + "lines": { + "1": { + "oracle_tfair": [], + "lambda_over_length": 0.0009555294150673793 + } + } + }, + { + "id": "SIS_F800_KN3E6_EI5E2_SEQ", + "expect": "admissible", + "why": "static_init_sweep case 115 (family f, depth 800 m, slackness 1.372, buoyancy ratio 2.5, EA 4.69e+08 N, EI 500 N m2, kBot 3e+06, span ratio 1, buoyant fraction 0.29) through the deck route, mesh-sequenced route first; the buoyant arch floats at the surface, so no submerged EI = 0 root applies", + "deck": "--------------------- CableDyn Input File ------------------------------------\nSW_F800_KN3E6_EI5E2_SEQ\n--------------------- LINE TYPES ---------------------------------------------\nTypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t\n(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-)\nbare 0.160 36.720364 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\nbuoy 0.290 0.0 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\n--------------------- POINTS ------------------------------------------------\nID Type X Y Z\n(-) (-) (m) (m) (m)\n1 Coupled 0.0 0.0 -16\n2 Fixed 800 0.0 -800\n--------------------- LINES -------------------------------------------------\nID NodeA NodeB Outputs\n(-) (-) (-) (-)\n1 1 2 -\n--------------------- SECTIONS ----------------------------------------------\nLineID LineType Length NumSegs\n(-) (-) (m) (-)\n1 bare 432.135000000 72\n1 buoy 444.138750000 74\n1 bare 660.206250000 110\n--------------------- EQUIVALENT BUOYANCY -----------------------------------\nLineType Diam SubmergedWeightNpm\n(-) (m) (N/m)\nbuoy 0.290 -395\n--------------------- OPTIONS -----------------------------------------------\n800 WtrDpth\n3e+06 kBot\n0.0 TMax\nnone frictionMu\nsequenced cable_statics\n--------------------- OUTPUTS -----------------------------------------------\n\"FairTen1\"\n\"AnchTen1\"\n--------------------- need this line ----------------------------------------\n", + "lines": { + "1": { + "oracle_tfair": [], + "lambda_over_length": 0.0009555294150673793 + } + } + }, + { + "id": "SIS_F200_FB040_EI5E2", + "expect": "admissible", + "why": "static_init_sweep case 118 (family f, depth 200 m, slackness 1.25, buoyancy ratio 2.5, EA 4.69e+08 N, EI 500 N m2, kBot 100000, span ratio 1, buoyant fraction 0.4) through the deck route; the buoyant arch floats at the surface, so no submerged EI = 0 root applies", + "deck": "--------------------- CableDyn Input File ------------------------------------\nSW_F200_FB040_EI5E2\n--------------------- LINE TYPES ---------------------------------------------\nTypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t\n(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-)\nbare 0.160 36.720364 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\nbuoy 0.290 0.0 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\n--------------------- POINTS ------------------------------------------------\nID Type X Y Z\n(-) (-) (m) (m) (m)\n1 Coupled 0.0 0.0 -4\n2 Fixed 200 0.0 -200\n--------------------- LINES -------------------------------------------------\nID NodeA NodeB Outputs\n(-) (-) (-) (-)\n1 1 2 -\n--------------------- SECTIONS ----------------------------------------------\nLineID LineType Length NumSegs\n(-) (-) (m) (-)\n1 bare 97.232142351 20\n1 buoy 140.986606409 29\n1 bare 111.816963704 23\n--------------------- EQUIVALENT BUOYANCY -----------------------------------\nLineType Diam SubmergedWeightNpm\n(-) (m) (N/m)\nbuoy 0.290 -395\n--------------------- OPTIONS -----------------------------------------------\n200 WtrDpth\n100000 kBot\n0.0 TMax\nnone frictionMu\n--------------------- OUTPUTS -----------------------------------------------\n\"FairTen1\"\n\"AnchTen1\"\n--------------------- need this line ----------------------------------------\n", + "lines": { + "1": { + "oracle_tfair": [], + "lambda_over_length": 0.0041942915633607575 + } + } + }, + { + "id": "SIS_F200_FB040_EI5E2_SEQ", + "expect": "admissible", + "why": "static_init_sweep case 118 (family f, depth 200 m, slackness 1.25, buoyancy ratio 2.5, EA 4.69e+08 N, EI 500 N m2, kBot 100000, span ratio 1, buoyant fraction 0.4) through the deck route, mesh-sequenced route first; the buoyant arch floats at the surface, so no submerged EI = 0 root applies", + "deck": "--------------------- CableDyn Input File ------------------------------------\nSW_F200_FB040_EI5E2_SEQ\n--------------------- LINE TYPES ---------------------------------------------\nTypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t\n(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-)\nbare 0.160 36.720364 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\nbuoy 0.290 0.0 4.69e+08 0.0 500 1.2 0.1 1.0 0.0\n--------------------- POINTS ------------------------------------------------\nID Type X Y Z\n(-) (-) (m) (m) (m)\n1 Coupled 0.0 0.0 -4\n2 Fixed 200 0.0 -200\n--------------------- LINES -------------------------------------------------\nID NodeA NodeB Outputs\n(-) (-) (-) (-)\n1 1 2 -\n--------------------- SECTIONS ----------------------------------------------\nLineID LineType Length NumSegs\n(-) (-) (m) (-)\n1 bare 97.232142351 20\n1 buoy 140.986606409 29\n1 bare 111.816963704 23\n--------------------- EQUIVALENT BUOYANCY -----------------------------------\nLineType Diam SubmergedWeightNpm\n(-) (m) (N/m)\nbuoy 0.290 -395\n--------------------- OPTIONS -----------------------------------------------\n200 WtrDpth\n100000 kBot\n0.0 TMax\nnone frictionMu\nsequenced cable_statics\n--------------------- OUTPUTS -----------------------------------------------\n\"FairTen1\"\n\"AnchTen1\"\n--------------------- need this line ----------------------------------------\n", + "lines": { + "1": { + "oracle_tfair": [], + "lambda_over_length": 0.0041942915633607575 + } + } } ] diff --git a/tests/test_static_init_sweep.f90 b/tests/test_static_init_sweep.f90 index 5f87ce9..1a31ad3 100644 --- a/tests/test_static_init_sweep.f90 +++ b/tests/test_static_init_sweep.f90 @@ -53,9 +53,10 @@ PROGRAM test_static_init_sweep !! concentrates an O(pi) turn at one node, curv*l0 ~ 2-3. This is the measurable !! version of "reached the smooth branch, not a kinked local equilibrium". !! (4) penetration consistent with the penalty: min z >= -d - (10 |w|_max / kn + 1 mm). - !! An expected-fail list records the known limitations (five 800 m low-EI lazy waves the - !! production initialisation does not solve); an XFAIL case that PASSES also fails the - !! gate, so a fix or a regression is seen. + !! An expected-fail list records the known limitations of this kernel from the trivial + !! seed (four low-EI lazy waves; the deck route solves them, see the list below); an + !! XFAIL case that PASSES is counted and reported, so a fix is seen, and a case off the + !! list that fails fails the gate. USE, INTRINSIC :: IEEE_ARITHMETIC, ONLY: IEEE_IS_FINITE USE CableDyn_Precision, ONLY: wp USE CableDyn_HermiteCableStatic, ONLY: CD_HermiteCable_Static_Solve_Sequenced, & @@ -167,15 +168,17 @@ PROGRAM test_static_init_sweep CALL add_case('g', MERGE(50.0_wp, 400.0_wp, i == 1), -2.0_wp, 1.5_wp, EA0, EI0, KN0, CHI0, FB0) END DO - ! ---- KNOWN-FAIL SET (measured on the full matrix sweep; parameter-matched - ! fail-closed: a matrix edit that orphans an entry is rejected. A solver improvement that - ! flips one to PASS is reported without failing the suite. The remaining cases either - ! exhaust the bounded hierarchy/recovery paths or reach a residual-converged one-element - ! hairpin that the production h*kappa safety gate rejects. Neither class can silently - ! enter dynamics. - ! Continuous whole-element curvature inspection exposes the unresolved hairpin in this - ! low-EI, 800 m fixed mesh. The former nodal metric incorrectly admitted it as smooth. - CALL mark_xfail('b', 800.0_wp, 1.25_wp, 1.5_wp, 5.0e7_wp, 5.0e2_wp, KN0, CHI0, FB0) ! [unresolved] + ! ---- KNOWN-FAIL SET (measured on the full matrix sweep with every OpenBLAS kernel; + ! parameter-matched fail-closed: a matrix edit that orphans an entry is rejected. A solver + ! improvement that flips one to PASS is reported without failing the suite. These four + ! low-EI lazy waves reach, from the trivial seed on this kernel alone, a residual-converged + ! kinked state (a Z of a few elements in mid-water) that the production h*kappa safety gate + ! rejects, so none can silently enter dynamics. A deck never takes this combination for a + ! line with a buoyant section: the deck route seeds it as a lazy wave, starts from the + ! EI = 0 catenary, and refines a kinked state locally. That route solves all four, and the + ! formerly listed 800 m EA = 5e7 N case, on both cable_statics routes, to within 2.4e-4 of + ! an independent EI = 0 catenary where the arch stays submerged: cable_statics_matrix gates + ! them (entries SIS_*). CALL mark_xfail('b', 800.0_wp, 1.25_wp, 1.5_wp, 2.0e9_wp, 5.0e2_wp, KN0, CHI0, FB0) ! [wad] CALL mark_xfail('b', 800.0_wp, 1.25_wp, 1.5_wp, EA0, 5.0e2_wp, KN0, CHI0, FB0) ! [unresolved] CALL mark_xfail('f', 800.0_wp, -1.0_wp, 2.5_wp, EA0, 5.0e2_wp, 3.0e6_wp, CHI0, FB0) ! [wad] From 0b8eee3732d9dfa68e8f47bb0e18f80af659c7a5 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Sun, 8 Nov 2026 18:03:38 +0900 Subject: [PATCH 03/40] fix(recovery): spread the touchdown node's floor reaction over its grounded stretch (#109) Signed-off-by: Jae Hoon Seo --- CHANGELOG.md | 11 ++ CMakeLists.txt | 13 ++- doc/outputs.rst | 13 +++ doc/theory.rst | 21 +++- src/CableDyn_HermiteCableDynamic.f90 | 113 +++++++++++++------- tests/check_touchdown_convergence.py | 149 +++++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 42 deletions(-) create mode 100644 tests/check_touchdown_convergence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b6df6ab..e354955 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ All notable changes to CableDyn are recorded in this file. The format follows ## [Unreleased] +### Fixed + +- Finite-EI node tension, curvature and bend moment next to the touchdown point: the floor + reaction of the first grounded node was spread over its grounded half-element alone, wherever + the touchdown point lay in the touchdown element. The local tension error there fell only at + the first order with the element length (4.3, 1.9, 1.0 N at 1.6, 0.8, 0.4 m elements on a + lazy-wave cable at 270 N) and the node outputs stepped as each node grounded or lifted off. + The touchdown element now takes the reaction of its grounded stretch, from the grounded node + to the touchdown point located from the two nodes' clearances: the error falls at the second + order (2.1, 0.7, 0.2 N) and the outputs vary continuously. + ## [0.4.1] - 2026-11-08 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index aebdc15..b2b4b1f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2779,13 +2779,24 @@ if(Python3_Interpreter_FOUND) 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. + # the floor reaction of its grounded stretch alone, not half an element's. 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}") + # Node tension at touchdown converges at second order wherever the touchdown point falls in + # its element: the local error over a sweep of meshes at 1.6, 0.8 and 0.4 m elements falls + # by at least 2.5 from 0.8 to 0.4 m and stays below 0.35 N there (the touchdown element takes + # the floor reaction of its grounded stretch). + add_test(NAME driver_touchdown_convergence + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_touchdown_convergence.py + $) + set_tests_properties(driver_touchdown_convergence PROPERTIES + LABELS "python;driver;integration;static" + TIMEOUT 600 + ENVIRONMENT "${_cabledyn_python_test_environment}") # A hang-off through a tapered bend stiffener follows the planar elastica (scipy solve_bvp) # within 2 % of the root moment and 1 cm, and node by node on a sweep of element sizes within # 0.5 % of the local curvature on 0.1 m elements; skipped where scipy is not installed. diff --git a/doc/outputs.rst b/doc/outputs.rst index 0cb33a4..f2b132e 100644 --- a/doc/outputs.rst +++ b/doc/outputs.rst @@ -841,6 +841,19 @@ the channels report that end node (and ``TDPGrd`` reads 0); when the whole li seabed, the suspended end node. ``TDPGrd`` is the grounded length of the `Anchor uplift`_ check. +The seabed carries the line at its nodes, so the mesh at touchdown sets how finely the touchdown +point and the tension around it are resolved: a node lands on the floor at once, and in a +dynamic run its landing shakes the line. The node tension near touchdown converges at the second +order in the element length there. On the lazy-wave cable of +``lozon_gomex80_power_cable_motion.dat`` (bending length :math:`\sqrt{EI/T}` = 3.9 m at +touchdown, tension 1.3 to 1.9 kN) it departs from a 1024-element solution by at most 140, 55, +19 and 5.8 N with 3.3, 1.6, 0.8 and 0.4 m elements at touchdown over the heave cycle (time step +0.0125 s), and by 4.6, 1.0 and 0.25 N in the static state with 3.3, 1.6 and 0.8 m elements; on +the 3.3 m mesh ``TDPs`` moves at up to 4 m/s while a node lands (0.7 m/s converged) and its +lowest value differs from the converged one by 0.6 m. Elements no longer than about a fifth of +:math:`\sqrt{EI/T}` at touchdown keep the node tension there within about 1.5 % of the +converged value through the motion, and a tenth within 0.5 %. + Point channels ~~~~~~~~~~~~~~ diff --git a/doc/theory.rst b/doc/theory.rst index 7b45a63..6db8e69 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -314,11 +314,22 @@ end residual holds only the element's own distributed loads: consistent submerged weight, the buoyancy lost above the free surface, drag, Froude-Krylov force, structural and added-mass inertia, and the -seabed reaction of its end nodes, each nodal reaction spread uniformly over the node's tributary -length (the distributed floor reaction it stands for; without it a line resting on the floor -would report the fixed-end moment :math:`w L_0^2/12` of its own weight; at a held line end whose -end element lies on the floor, the reaction of the end half is the floor-support share of the -weight, the share that the static and dynamic residuals carry on the end tangent rows). +seabed reaction of its end nodes, each nodal reaction spread uniformly over the grounded part of +the node's tributary length (the distributed floor reaction it stands for; without it a line +resting on the floor would report the fixed-end moment :math:`w L_0^2/12` of its own weight; at a +held line end whose end element lies on the floor, the reaction of the end half is the +floor-support share of the weight, the share that the static and dynamic residuals carry on the +end tangent rows). A half-element whose far node is on the floor is grounded whole; the +touchdown element, on the floor at one end only, is grounded from that end to the touchdown +point, where the clearance interpolated linearly between its two nodes reaches the contact onset +height (at most its half). The grounded stretch reaches the half-element as the suspended node +reaches the floor, and vanishes with the reaction as the grounded node lifts off, so the node +outputs vary continuously as nodes ground and lift off. With the reaction of the first grounded +node spread over its grounded half-element alone, the tension near touchdown carried a local +error that depended on where the touchdown point fell in its element and fell only at the first +order (at most 4.3, 1.9 and 1.0 N at 1.6, 0.8 and 0.4 m elements on the lazy-wave cable of +``lazy_wave_buoyancy_smeared.dat``, line tension 270 N there; 2.1, 0.7 and 0.2 N with the +grounded stretch), and it stepped as each node grounded. :math:`\mathbf{g}_e` is the generalised force the two end nodes exert on the element: its position rows at the End-B side of the internal arc are the section force :math:`\mathbf{F}_j^-` just before node diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index c711a15..fb32e36 100644 --- a/src/CableDyn_HermiteCableDynamic.f90 +++ b/src/CableDyn_HermiteCableDynamic.f90 @@ -7257,15 +7257,18 @@ PURE FUNCTION unit_tangent(model, node) RESULT(t) END IF END FUNCTION unit_tangent - SUBROUTINE node_contact_normal(model, node, fc, ErrStat, ErrMsg) + SUBROUTINE node_contact_normal(model, node, fc, ErrStat, ErrMsg, clearance) !! Frictionless seabed contact force on a node at the committed state, exactly as !! assemble_contact applies it: the normal force with its damping and, in a rounded crease !! of the bathymetry, the elastic increment of the penalty potential (crease_contact_increment). + !! clearance is the node's normal distance above the height at which the contact law engages: + !! negative on the floor, positive above it. TYPE(CD_HermiteCableDynType), INTENT(IN) :: model INTEGER, INTENT(IN) :: node REAL(wp), INTENT(OUT) :: fc(3) INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg + REAL(wp), INTENT(OUT), OPTIONAL :: clearance REAL(wp) :: z_floor, gx, gy, xg, yg, gxg, gyg, sfac, nvec(3), gap, normal, normal_gap, vn, gate, gate_gap REAL(wp) :: curv_g(3), fcrease(3), kcrease(3, 3) INTEGER :: iz, es @@ -7273,6 +7276,7 @@ SUBROUTINE node_contact_normal(model, node, fc, ErrStat, ErrMsg) CHARACTER(200) :: em2 ErrStat = CD_HCDYN_OK; ErrMsg = '' fc = CD_ZERO + IF (PRESENT(clearance)) clearance = HUGE(CD_ONE) iz = 6*(node - 1) + 3 IF (model%has_contact) THEN z_floor = model%seabed_z @@ -7293,6 +7297,7 @@ SUBROUTINE node_contact_normal(model, node, fc, ErrStat, ErrMsg) sfac = SQRT(CD_ONE + gx*gx + gy*gy) nvec = [-gx, -gy, CD_ONE]/sfac gap = (z_floor - model%q(iz))/sfac + IF (PRESENT(clearance)) clearance = -gap - CD_SEABED_CONTACT_BLEND CALL CD_Seabed_Normal_Law(gap, model%contact_kn(node), normal, normal_gap) CALL CD_Seabed_Contact_Gate(gap, gate, gate_gap) IF (normal <= CD_ZERO .AND. gate <= CD_ZERO) RETURN @@ -7308,6 +7313,7 @@ SUBROUTINE node_contact_normal(model, node, fc, ErrStat, ErrMsg) END IF ELSE IF (model%kn > CD_ZERO) THEN gap = model%seabed_z - model%q(iz) + IF (PRESENT(clearance)) clearance = -gap IF (gap > CD_ZERO) fc(3) = model%kn*gap*model%trib(node) END IF END SUBROUTINE node_contact_normal @@ -7364,7 +7370,7 @@ SUBROUTINE element_end_residual(model, e, g, ErrStat, ErrMsg) INTEGER, PARAMETER :: ZS(4) = [3, 6, 9, 12] REAL(wp) :: qe(12), ve(12), ae(12), fe(12), fx(12), energy, fdry(4), kdry(4, 4) REAL(wp) :: dry_wl, dry_b, dry_r, hf_mid(3), l0, fca(3), fcb(3), c, a4(4), m4(4, 4) - REAL(wp) :: support(3), dsupport(3), nin(3) + REAL(wp) :: support(3), dsupport(3), nin(3), xa, xb, ca(4), cb(4) INTEGER :: es, k, inode LOGICAL :: dry CHARACTER(200) :: em2 @@ -7465,20 +7471,22 @@ SUBROUTINE element_end_residual(model, e, g, ErrStat, ErrMsg) ! 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. 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. + ! reaction is spread uniformly over the grounded part of its two half-elements and shared + ! between them by that length (grounded_share). The touchdown element, on the floor at one + ! end only, is grounded from that end to the touchdown point, located on it from the two + ! nodes' clearances; it takes the reaction of that stretch alone (spreading the reaction over + ! its half understated the tension of the last suspended node by the floor reaction of half + ! an element, 5 % on a 10 m lazy-wave mesh). The grounded stretch grows to the half-element + ! as the suspended node reaches the floor, so the spread, and the node outputs, change + ! continuously as nodes ground and lift off. 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 - CALL grounded_share(e, e, fca, es) + CALL grounded_share(e, e, fca, xa, es) IF (es /= CD_HCDYN_OK) GOTO 900 - CALL grounded_share(e + 1, e, fcb, es) + CALL grounded_share(e + 1, e, fcb, xb, 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 @@ -7486,18 +7494,23 @@ SUBROUTINE element_end_residual(model, e, g, ErrStat, ErrMsg) ! end half of that element is the support share of its weight, uniform with the reaction ! of the neighbouring node, so that a laid end element carries no fixed-end moment. IF (e == 1) THEN - IF (held_end_floor_support(model, model%q, 1, support, dsupport, inode, nin)) & - fca = (model%w(1)*l0)*support + IF (held_end_floor_support(model, model%q, 1, support, dsupport, inode, nin)) THEN + fca = (0.5_wp*model%w(1)*l0)*support; xa = 0.5_wp + END IF END IF IF (e == model%ne) THEN - IF (held_end_floor_support(model, model%q, 2, support, dsupport, inode, nin)) & - fcb = (model%w(e)*l0)*support + IF (held_end_floor_support(model, model%q, 2, support, dsupport, inode, nin)) THEN + fcb = (0.5_wp*model%w(e)*l0)*support; xb = 0.5_wp + END IF END IF - ! integrals of the four shapes over xi in [0, 1/2] (fca) and [1/2, 1] (fcb) - g(1:3) = g(1:3) - (0.40625_wp*fca + 0.09375_wp*fcb) - g(4:6) = g(4:6) - l0*(0.0572916666666666667_wp*fca + 0.0260416666666666667_wp*fcb) - g(7:9) = g(7:9) - (0.09375_wp*fca + 0.40625_wp*fcb) - g(10:12) = g(10:12) + l0*(0.0260416666666666667_wp*fca + 0.0572916666666666667_wp*fcb) + ! the loads fca and fcb spread uniformly over xi in [0, xa] and [1 - xb, 1]: the integrals + ! of the four shapes over those stretches per unit length of xi (spread_shapes) + ca = spread_shapes(xa) + cb = spread_shapes(xb) + g(1:3) = g(1:3) - (ca(1)*fca + cb(3)*fcb) + g(4:6) = g(4:6) - l0*(ca(2)*fca - cb(4)*fcb) + g(7:9) = g(7:9) - (ca(3)*fca + cb(1)*fcb) + g(10:12) = g(10:12) - l0*(ca(4)*fca - cb(2)*fcb) END IF ae(1:6) = model%a(6*(e - 1) + 1:6*e); ae(7:12) = model%a(6*e + 1:6*(e + 1)) IF (ANY(ABS(ae) > CD_ZERO)) THEN @@ -7529,40 +7542,68 @@ SUBROUTINE element_end_residual(model, e, g, ErrStat, ErrMsg) 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. + SUBROUTINE grounded_share(node, el, fc, x, esx) + !! Element el's share of the floor reaction fc of node (an end of el), and the grounded + !! stretch x of el's half beside the node (a fraction of el, at most 1/2) that it is + !! spread over. Each half-element beside the node is grounded over x = 1/2 when its far + !! node is on the floor as well, and otherwise up to the touchdown point, where the + !! clearance interpolated linearly between the node (on the floor) and the far node (above + !! it) reaches the contact onset; the reaction is shared between the two halves by the + !! grounded length x l0. The touchdown point reaches the far node as that node reaches + !! the floor and the node itself as it lifts off (its reaction then vanishing), so the + !! share and the stretch are continuous in the configuration. A node off the floor with a + !! reaction (a held end the floor supports) keeps the tributary-length share over the + !! half-element. INTEGER, INTENT(IN) :: node, el REAL(wp), INTENT(INOUT) :: fc(3) + REAL(wp), INTENT(OUT) :: x INTEGER, INTENT(OUT) :: esx - REAL(wp) :: grounded_len - LOGICAL :: on_el, grounded - INTEGER :: k + REAL(wp) :: ext, ext_el, grounded_len, c_node, c_far, fdum(3) + LOGICAL :: grounded + INTEGER :: k, far esx = CD_HCDYN_OK + x = 0.5_wp IF (.NOT. ANY(ABS(fc) > CD_ZERO)) RETURN + CALL node_contact_normal(model, node, fdum, esx, em2, clearance=c_node) + IF (esx /= CD_HCDYN_OK) RETURN grounded_len = CD_ZERO - on_el = .FALSE. + ext_el = CD_ZERO 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. + ext = 0.5_wp + ELSE + far = MERGE(k, k + 1, k == node - 1) + CALL node_contact_normal(model, far, fdum, esx, em2, clearance=c_far) + IF (esx /= CD_HCDYN_OK) RETURN + ext = CD_ZERO + IF (c_node < CD_ZERO .AND. c_far > c_node) ext = MIN(0.5_wp, -c_node/(c_far - c_node)) END IF + grounded_len = grounded_len + ext*model%l0(k) + IF (k == el) ext_el = ext 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 + fc = fc*(ext_el*model%l0(el)/grounded_len) + x = ext_el ELSE IF (model%trib(node) > CD_ZERO) THEN - fc = fc*model%l0(el)/model%trib(node) + fc = fc*(0.5_wp*model%l0(el)/model%trib(node)) END IF END SUBROUTINE grounded_share + PURE FUNCTION spread_shapes(xs) RESULT(cs) + !! Integrals of the cubic Hermite shapes H1, H2/l0, H3, H4/l0 over xi in [0, xs], divided + !! by xs: the consistent loads of a unit load spread uniformly over that stretch (a point + !! load at xi = 0 as xs goes to zero). A stretch [1 - xs, 1] at the other end takes + !! [cs(3), -cs(4), cs(1), -cs(2)]. + REAL(wp), INTENT(IN) :: xs + REAL(wp) :: cs(4) + cs(1) = CD_ONE - xs*xs + 0.5_wp*xs**3 + cs(2) = 0.5_wp*xs - 2.0_wp*xs*xs/3.0_wp + 0.25_wp*xs**3 + cs(3) = xs*xs - 0.5_wp*xs**3 + cs(4) = -xs*xs/3.0_wp + 0.25_wp*xs**3 + END FUNCTION spread_shapes + 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 diff --git a/tests/check_touchdown_convergence.py b/tests/check_touchdown_convergence.py new file mode 100644 index 0000000..1ef2f4d --- /dev/null +++ b/tests/check_touchdown_convergence.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Node tension at touchdown converges at the element's order (driver_touchdown_convergence). + +A lazy-wave cable in a current is solved statically on three element sizes at touchdown +(about 1.6, 0.8 and 0.4 m), each on nine meshes that differ by a few elements in the section +that holds the touchdown point, so that the touchdown point falls at every position within its +element. A mesh 32 times finer than the 64-element base is the reference; nodes are compared at +the same unstretched arc length. The local error at touchdown is the largest departure of the +node tension error at the last suspended node, the first grounded node and the next node from +the error three nodes either side (the smooth discretisation error of the profile). Its largest +value over the nine meshes must fall at least by RATE when the elements are halved from 0.8 to +0.4 m, and stay below LIMIT on the 0.4 m meshes. + +The floor reaction of the first grounded node used to be spread over its grounded half-element +alone, whatever the position of the touchdown point in the touchdown element: the local error +then fell at the first order (4.3, 1.9, 1.0 N on the three sizes, against a line tension of +about 270 N there). + +Usage: check_touchdown_convergence.py +""" + +import bisect +import pathlib +import subprocess +import sys +import tempfile + +FLOOR_Z = -80.0 +LENGTHS = (68.114, 50.0, 52.101) +BASE = (28, 20, 16) # elements per section of the 64-element base mesh +LEVELS = (2, 4, 8) # multipliers of the base mesh +SHIFTS = range(-4, 5) # elements added to the touchdown section of each level +REFERENCE = 32 +RATE = 2.5 +LIMIT = 0.35 # N, on the 0.4 m meshes + +DECK = """--------------------- CableDyn Input File ------------------------------------ +Touchdown convergence check: lazy-wave cable in a current, static +--------------------- LINE TYPES --------------------------------------------- +TypeName Diam MassDenInAir EA BA EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (-) (N-m2) (-) (-) (-) (-) +bare 0.160 36.70 4.69e8 0.0 1.99e4 1.2 0.1 1.0 0.0 +buoy 0.290 0.0 4.69e8 0.0 1.99e4 1.2 0.1 1.0 0.0 +--------------------- POINTS ------------------------------------------------ +ID Type X Y Z +(-) (-) (m) (m) (m) +1 Coupled 5.0 0.0 -14.0 +2 Fixed 125.0 0.0 -80.0 +--------------------- LINES ------------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +1 1 2 - +--------------------- SECTIONS ---------------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +1 bare 68.114 {n1} +1 buoy 50.000 {n2} +1 bare 52.101 {n3} +--------------------- EQUIVALENT BUOYANCY ----------------------------------- +LineType Diam SubmergedWeightNpm +(-) (m) (N/m) +buoy 0.290 -80.15 +--------------------- OPTIONS ----------------------------------------------- +9.80665 g +1025.0 rhoW +80.0 WtrDpth +1.0e5 kBot +1.0e4 cBot +0.05 dtM +0.0 TMax +uniform 0.5 0.0 0.0 current +--------------------- OUTPUTS ----------------------------------------------- +"FairTen1" +--------------------- need this line ---------------------------------------- +""" + + +def static_profile(exe, work, counts): + """Unstretched arc length, height and node tension of every node of the static state.""" + name = "m_" + "_".join(str(n) for n in counts) + deck = work / f"{name}.dat" + deck.write_text(DECK.format(n1=counts[0], n2=counts[1], n3=counts[2]), 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}") + lines = (work / f"{name}.static.out").read_text(encoding="utf-8").splitlines() + header = lines[1].split() + if "DeckNode" in header: + raise SystemExit(f"FAIL: the static solve refined the {name} mesh") + iz, it = header.index("Z"), header.index("Tension") + rows = [line.split() for line in lines[3:] if line.strip()] + arc = [0.0] + start = 0.0 + for length, n in zip(LENGTHS, counts): + arc += [start + length * k / n for k in range(1, n + 1)] + start += length + if len(rows) != len(arc): + raise SystemExit(f"FAIL: {len(rows)} rows in {name}.static.out, expected {len(arc)}") + return arc, [float(r[iz]) for r in rows], [float(r[it]) for r in rows] + + +def interpolate(xs, ys, x): + k = min(max(bisect.bisect_left(xs, x), 1), len(xs) - 1) + f = (x - xs[k - 1]) / (xs[k] - xs[k - 1]) + return ys[k - 1] + f * (ys[k] - ys[k - 1]) + + +def local_error(exe, work, counts, reference): + """Largest local tension error at touchdown, and the element size there.""" + arc, z, t = static_profile(exe, work, counts) + first = next(j for j in range(1, len(z)) if z[j] <= FLOOR_Z + 1e-6 and z[j - 1] > FLOOR_Z + 1e-6) + err = [t[j] - interpolate(reference[0], reference[1], arc[j]) for j in range(len(arc))] + background = 0.5 * (err[first - 3] + err[first + 3]) + return max(abs(err[j] - background) for j in range(first - 1, first + 2)), arc[first] - arc[first - 1] + + +def main(): + if len(sys.argv) != 2: + raise SystemExit(__doc__) + exe = sys.argv[1] + worst = [] + with tempfile.TemporaryDirectory() as tmp: + work = pathlib.Path(tmp) + r_arc, _, r_t = static_profile(exe, work, tuple(REFERENCE * n for n in BASE)) + for m in LEVELS: + values = [] + for shift in SHIFTS: + counts = (BASE[0] * m, BASE[1] * m, BASE[2] * m + shift) + e, h = local_error(exe, work, counts, (r_arc, r_t)) + values.append(e) + worst.append(max(values)) + listed = " ".join(f"{v:.3f}" for v in values) + print(f" {BASE[2] * m + SHIFTS[0]}-{BASE[2] * m + SHIFTS[-1]} elements in the touchdown section " + f"(about {h:.2f} m): local error {listed} N; largest {worst[-1]:.3f} N") + rate = worst[-2] / max(worst[-1], 1e-12) + print(f" largest local error {worst[-2]:.3f} N -> {worst[-1]:.3f} N when the elements are halved: " + f"ratio {rate:.2f} (at least {RATE}; second order 4)") + failures = [] + if rate < RATE: + failures.append(f"the local error fell by {rate:.2f} per halving of the elements (at least {RATE})") + if worst[-1] > LIMIT: + failures.append(f"local error {worst[-1]:.3f} N on the 0.4 m meshes (limit {LIMIT} N)") + if failures: + raise SystemExit("FAIL: node tension at touchdown does not converge at second order: " + "; ".join(failures)) + print("PASS: node tension at touchdown converges at second order wherever the touchdown point falls") + + +if __name__ == "__main__": + main() From fbcebdc76160f808b6f356eb83ab749e17527f51 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Sun, 8 Nov 2026 22:10:50 +0900 Subject: [PATCH 04/40] perf(hermite): remove overhead from the finite-EI dynamic step (bit-identical) (#115) Signed-off-by: Jae Hoon Seo --- src/CableDyn_HermiteCable.f90 | 2 + src/CableDyn_HermiteCableDynamic.f90 | 239 ++++++++++++++++++--------- src/CableDyn_Hydro.f90 | 24 ++- src/CableDyn_Precision.f90 | 30 +++- 4 files changed, 203 insertions(+), 92 deletions(-) diff --git a/src/CableDyn_HermiteCable.f90 b/src/CableDyn_HermiteCable.f90 index ea7cef9..dc62e17 100644 --- a/src/CableDyn_HermiteCable.f90 +++ b/src/CableDyn_HermiteCable.f90 @@ -327,6 +327,8 @@ SUBROUTINE hermite_element_integrated(qr, L, EA, EI, quadrature_order, Eout, fin ! symmetry without redundant flops. DO m = 1, 4 DO k = 1, 4 + ! a node block below the diagonal (k > m) holds only rows ic > jc: none in the upper triangle + IF (half_kt .AND. k > m) CYCLE w11 = b1(k)*b1(m) w12 = b1(k)*b2(m) w21 = b2(k)*b1(m) diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index fb32e36..9889a04 100644 --- a/src/CableDyn_HermiteCableDynamic.f90 +++ b/src/CableDyn_HermiteCableDynamic.f90 @@ -179,6 +179,11 @@ MODULE CableDyn_HermiteCableDynamic ! Half-bandwidth of the nodal-twist tangent in 7-DOF-per-node order ([r, m, phi] per node). INTEGER, PARAMETER :: KB7 = 13 INTEGER, PARAMETER :: LDAB_D = 2*KL_D + KU_D + 1 + ! First row of the stored band. Rows 1..KL_D are the factorization's fill-in space: every + ! band array is allocated with them zero and only an LU factor (ws_Keffb) ever writes them, + ! so the sweeps over the operators (zeroing, sums, the effective-tangent combination, the + ! added-mass check) skip them. + INTEGER, PARAMETER :: BAND_TOP_D = KL_D + 1 ! Profiling is OFF by default: SYSTEM_CLOCK in the step/residual hot path is measurable ! overhead in a production run. CD_HermiteCable_Dyn_Reset_Profile ARMS it (a benchmark @@ -832,6 +837,9 @@ SUBROUTINE CD_HermiteCable_Dyn_Init(model, l0, EA, EI, rho_a, w, seed, fixed_dof ALLOCATE (model%ws_Kb(LDAB_D, ndof), model%ws_Kvb(LDAB_D, ndof), & model%ws_Keffb(LDAB_D, ndof), model%ws_Msumb(LDAB_D, ndof), & model%ws_Mamb(LDAB_D, ndof)) + ! zero fill-in rows (BAND_TOP_D), which the band sweeps then leave untouched + model%ws_Kb = CD_ZERO; model%ws_Kvb = CD_ZERO; model%ws_Keffb = CD_ZERO + model%ws_Msumb = CD_ZERO; model%ws_Mamb = CD_ZERO ALLOCATE (model%ws_ipiv(ndof)) ALLOCATE (model%ws_elem_f(12, ne), model%ws_elem_K(12, 12, ne), model%ws_elem_Kdrag(12, 12, ne), & model%ws_elem_Kv(12, 12, ne), model%ws_elem_Kwave(12, 12, ne), model%ws_elem_Kheld(12, 12, ne), & @@ -3879,8 +3887,7 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r ! Total mass for this step: structural + added mass FROZEN at the beginning-of-step ! configuration q_n (constant within the Newton -> no dM/dq term; O(dt) freeze error consistent ! with gen-alpha accuracy, exact at any at-rest fixed point). All band arrays share the - ! DGBSV layout, so the sum is a plain array operation. - CALL band_copy(model%Mgb, model%ws_Msumb) + ! DGBSV layout, so the sum is a plain array operation (one sweep, after the added mass). IF (model%has_am) THEN IF (prof_enabled) CALL SYSTEM_CLOCK(pc0) CALL assemble_added_mass(model, model%ws_qn, es, em2) @@ -3892,7 +3899,9 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r IF (es /= CD_HCDYN_OK) THEN ErrStat = es; ErrMsg = 'CD_HermiteCable_Dyn_Step: '//TRIM(em2); RETURN END IF - CALL band_add(model%ws_Mamb, model%ws_Msumb) + CALL band_sum(model%Mgb, model%ws_Mamb, model%ws_Msumb) + ELSE + CALL band_copy(model%Mgb, model%ws_Msumb) END IF ! A fixed DOF that is NOT prescribed this step is a boundary HELD AT REST: zero its velocity and ! acceleration so the predictor keeps it at q_n and the commit leaves v = a = 0. Without this a @@ -5038,8 +5047,12 @@ SUBROUTINE add_rigid_transport_tangent() !! contains the c_a*d and gamma/(beta*dt)*d contributions. Add the terms !! from d_dot and d_ddot as a rank-one update whose right vector is d; !! after the rigid basis transform this affects only the free axial column. - INTEGER :: iend, base, row, j - REAL(wp) :: rate_coefficient, extra_acceleration(3) + !! The left vector (1 - am) M a_x + (1 - af) Kv d_dot has non-zeros only in the band rows + !! of the three tangent columns, so it is formed on those rows alone: the products of the + !! three column entries, summed in column order as the band matvec would sum them. + INTEGER :: iend, base, row, j, lo, hi + REAL(wp) :: rate_coefficient, extra_acceleration(3), mass_part, damping_part + REAL(wp) :: transport(KL_D + KU_D + 3) rate_coefficient = gm/(bt*dt) DO iend = 1, 2 IF (model%endconn_mode(iend) /= CD_ENDCONN_RIGID) CYCLE @@ -5050,19 +5063,23 @@ SUBROUTINE add_rigid_transport_tangent() END IF extra_acceleration = 2.0_wp*rate_coefficient*step_endconn_rate(:, iend) + & step_endconn_acceleration(:, iend) - model%ws_dq = CD_ZERO - model%ws_dq(base + 1:base + 3) = extra_acceleration - CALL band_matvec(model%ws_Msumb, model%ws_dq, model%ws_inert) - model%ws_inert = (CD_ONE - am)*model%ws_inert - model%ws_dq = CD_ZERO - model%ws_dq(base + 1:base + 3) = step_endconn_rate(:, iend) - CALL band_matvec(model%ws_Kvb, model%ws_dq, model%ws_vk) - model%ws_inert = model%ws_inert + (CD_ONE - af)*model%ws_vk + lo = MAX(1, base + 1 - KU_D) + hi = MIN(model%ndof, base + 3 + KL_D) + DO row = lo, hi + mass_part = CD_ZERO + damping_part = CD_ZERO + DO j = 1, 3 + mass_part = mass_part + dynamic_band_entry(model, model%ws_Msumb, row, base + j)*extra_acceleration(j) + damping_part = damping_part + & + dynamic_band_entry(model, model%ws_Kvb, row, base + j)*step_endconn_rate(j, iend) + END DO + transport(row - lo + 1) = (CD_ONE - am)*mass_part + (CD_ONE - af)*damping_part + END DO DO j = 1, 3 DO row = MAX(1, base + j - KU_D), MIN(model%ndof, base + j + KL_D) CALL set_dynamic_band_entry(model, model%ws_Keffb, row, base + j, & dynamic_band_entry(model, model%ws_Keffb, row, base + j) + & - model%ws_inert(row)*step_endconn_target(j, iend)) + transport(row - lo + 1)*step_endconn_target(j, iend)) END DO END DO END DO @@ -6334,19 +6351,21 @@ SUBROUTINE CD_HermiteCable_Dyn_Reaction(model, node, freac, ErrStat, ErrMsg, ful 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) + SUBROUTINE node_reaction(model, node, freac, ErrStat, ErrMsg, tangent_rows) !! 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. + !! torsion, which couples every element. tangent_rows = .TRUE. returns the node's three + !! material-tangent rows instead of its translational ones (a rigid end connection). TYPE(CD_HermiteCableDynType), INTENT(INOUT) :: model INTEGER, INTENT(IN) :: node REAL(wp), INTENT(OUT) :: freac(3) INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg + LOGICAL, INTENT(IN), OPTIONAL :: tangent_rows 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 @@ -6357,6 +6376,9 @@ SUBROUTINE node_reaction(model, node, freac, ErrStat, ErrMsg) ErrStat = es; ErrMsg = TRIM(em); RETURN END IF gi = 6*(node - 1) + IF (PRESENT(tangent_rows)) THEN + IF (tangent_rows) gi = gi + 3 + END IF 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 @@ -7763,25 +7785,36 @@ SUBROUTINE CD_HermiteCable_Dyn_EndConnection_Moment(model, node, moment, ErrStat ! G0 is the unconstrained dynamic residual. The constraint contribution ! required in the residual is -G0 in the transverse tangent plane; under ! virtual work, m cross that contribution is the line's moment on the - ! supporting body, matching the finite-spring convention. - CALL assemble_residual(model, model%q, model%v, model%t, .TRUE., .FALSE., es, em) - IF (es /= CD_HCDYN_OK) THEN - ErrStat = es - ErrMsg = 'CD_HermiteCable_Dyn_EndConnection_Moment: '//TRIM(em) - RETURN - END IF - model%ws_Msumb = model%Mgb - IF (model%has_am) THEN - CALL assemble_added_mass(model, model%q, es, em) + ! supporting body, matching the finite-spring convention. Only the end tangent's three + ! rows are needed: without torsion (which couples every element) they come from the + ! elements at the node, bit-identical to the full assembly (node_reaction). + IF (model%torsion%active) THEN + CALL assemble_residual(model, model%q, model%v, model%t, .TRUE., .FALSE., es, em) + IF (es /= CD_HCDYN_OK) THEN + ErrStat = es + ErrMsg = 'CD_HermiteCable_Dyn_EndConnection_Moment: '//TRIM(em) + RETURN + END IF + model%ws_Msumb = model%Mgb + IF (model%has_am) THEN + CALL assemble_added_mass(model, model%q, es, em) + IF (es /= CD_HCDYN_OK) THEN + ErrStat = es + ErrMsg = 'CD_HermiteCable_Dyn_EndConnection_Moment: '//TRIM(em) + RETURN + END IF + model%ws_Msumb = model%ws_Msumb + model%ws_Mamb + END IF + CALL band_matvec(model%ws_Msumb, model%a, model%ws_inert) + f_ec = -(model%ws_inert(base + 1:base + 3) + model%ws_R(base + 1:base + 3)) + ELSE + CALL node_reaction(model, node, f_ec, es, em, tangent_rows=.TRUE.) IF (es /= CD_HCDYN_OK) THEN ErrStat = es ErrMsg = 'CD_HermiteCable_Dyn_EndConnection_Moment: '//TRIM(em) RETURN END IF - model%ws_Msumb = model%ws_Msumb + model%ws_Mamb END IF - CALL band_matvec(model%ws_Msumb, model%a, model%ws_inert) - f_ec = -(model%ws_inert(base + 1:base + 3) + model%ws_R(base + 1:base + 3)) direction = model%endconn_d0(:, iend) f_ec = f_ec - DOT_PRODUCT(f_ec, direction)*direction END IF @@ -8493,7 +8526,8 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta IF (prof_enabled) CALL SYSTEM_CLOCK(asm_t) IF (want_residual) R = CD_ZERO IF (want_tangent) THEN - Kb = CD_ZERO; Kvb = CD_ZERO + ! the stored band only: the fill-in rows above it are never written (BAND_TOP_D) + Kb(BAND_TOP_D:, :) = CD_ZERO; Kvb(BAND_TOP_D:, :) = CD_ZERO END IF CALL asm_mark(HC_ASM_ZERO, asm_t) @@ -10231,7 +10265,10 @@ SUBROUTINE evaluate_tangent_element(model, e, q_cfg, v_cfg, t_eval, Kstruct, Kdr qe(7:12) = q_cfg(6*e + 1:6*(e + 1)) ve(1:6) = v_cfg(6*(e - 1) + 1:6*e) ve(7:12) = v_cfg(6*e + 1:6*(e + 1)) - Kdrag = CD_ZERO; Kvel = CD_ZERO; Kwave = CD_ZERO; Kheld = CD_ZERO + ! Kdrag and Kvel accumulate their contributors from zero. Kwave and Kheld are each set + ! whole by their one contributor below and read (scattered) only when it is configured, + ! so they are not cleared. + Kdrag = CD_ZERO; Kvel = CD_ZERO CALL CD_HermiteCable_Element(qe, model%l0(e), model%EA(e), model%EI(e), energy, force, Kstruct, es, em, & symmetric_half=.TRUE., tangent_only=.TRUE., & @@ -10421,8 +10458,8 @@ SUBROUTINE CD_HermiteCable_Axial_Damping_Element(qe, ve, l0, BA, fdamp, Kq, Kv, REAL(wp), INTENT(IN), OPTIONAL :: l0_dot REAL(wp) :: gp(6), gw(6), H(4), dH(4) - REAL(wp) :: r_xi(3), v_xi(3), tangent(3), pperp(3), projector(3, 3) - REAL(wp) :: speed, strain_rate, scale_q, scale_v, rate_ratio + REAL(wp) :: r_xi(3), v_xi(3), tangent(3), pperp(3), projector(3, 3), kq3(3, 3) + REAL(wp) :: speed, strain_rate, scale_q, scale_v, scale_r, rate_ratio INTEGER :: order, ig, s, sp, c, cp, ia, ib ErrStat = CD_HCDYN_OK @@ -10478,25 +10515,34 @@ SUBROUTINE CD_HermiteCable_Axial_Damping_Element(qe, ve, l0, BA, fdamp, Kq, Kv, END DO END DO pperp = MATMUL(projector, v_xi) + ! the 3 x 3 factor of the position Jacobian, formed once per Gauss point + DO cp = 1, 3 + DO c = 1, 3 + kq3(c, cp) = strain_rate*projector(c, cp) + tangent(c)*pperp(cp)/l0 + END DO + END DO DO s = 1, 4 DO c = 1, 3 ia = 3*(s - 1) + c fdamp(ia) = fdamp(ia) + gw(ig)*BA*strain_rate*dH(s)*tangent(c) - DO sp = 1, 4 - DO cp = 1, 3 - ib = 3*(sp - 1) + cp - IF (PRESENT(Kv)) THEN - scale_v = gw(ig)*BA*dH(s)*dH(sp)/l0 - Kv(ia, ib) = Kv(ia, ib) + scale_v*tangent(c)*tangent(cp) - END IF + END DO + END DO + DO sp = 1, 4 + DO s = 1, 4 + scale_v = gw(ig)*BA*dH(s)*dH(sp)/l0 + scale_q = gw(ig)*BA*dH(s)*dH(sp)/speed + scale_r = gw(ig)*BA*dH(s)*dH(sp)*(rate_ratio/l0) + DO cp = 1, 3 + ib = 3*(sp - 1) + cp + DO c = 1, 3 + ia = 3*(s - 1) + c + IF (PRESENT(Kv)) Kv(ia, ib) = Kv(ia, ib) + scale_v*tangent(c)*tangent(cp) IF (PRESENT(Kq)) THEN - scale_q = gw(ig)*BA*dH(s)*dH(sp)/speed - Kq(ia, ib) = Kq(ia, ib) + scale_q*(strain_rate*projector(c, cp) + & - tangent(c)*pperp(cp)/l0) + Kq(ia, ib) = Kq(ia, ib) + scale_q*kq3(c, cp) ! the length-rate term depends on the end positions (sp = 1, 3) only IF (PRESENT(l0_dot) .AND. MOD(sp, 2) == 1) & - Kq(ia, ib) = Kq(ia, ib) - gw(ig)*BA*dH(s)*dH(sp)*(rate_ratio/l0)*tangent(c)*tangent(cp) + Kq(ia, ib) = Kq(ia, ib) - scale_r*tangent(c)*tangent(cp) END IF END DO END DO @@ -10575,7 +10621,7 @@ SUBROUTINE CD_HermiteCable_Drag_Element(qe, ve, l0, fluid, waterline_z, rho, dia REAL(wp), PARAMETER :: G3 = 0.7745966692414834_wp ! sqrt(3/5) REAL(wp) :: gp(NGP), gw(NGP), H(4), dH(4) REAL(wp) :: rpos(3), vvel(3), rp(3), speed, tangent(3), urel(3), fpl(3), Jrel(3, 3), Jtan(3, 3) - REAL(wp) :: scale, eta_w, uw(3), aw(3) + REAL(wp) :: jq3(3, 3), scale, eta_w, uw(3), aw(3) LOGICAL :: has_wv, has_shared, has_trio, has_cmp, any_wv_arg, want_jac INTEGER :: g, s, c, sp, cp, a, b, es ! the message of a per-Gauss-point CableDyn_Hydro kinematics or Morison call (at most 154 @@ -10680,12 +10726,15 @@ SUBROUTINE CD_HermiteCable_Drag_Element(qe, ve, l0, fluid, waterline_z, rho, dia IF (has_wv) urel = urel + uw ! Pass the raw dr/dxi as the tangent vector: the primitive normalises it and returns Jtan = ! d f_perlen / d(dr/dxi), so d f_perlen/d qe folds in through d(dr/dxi)/d qe = dH directly. + ! rho, diam, cdn and cdt were validated above, before the Gauss loop. IF (want_jac) THEN - CALL CD_Morison_Drag_Per_Length_Jac(urel, rp, rho, diam, cdn, cdt, fpl, Jrel, Jtan, es, em2) + CALL CD_Morison_Drag_Per_Length_Jac(urel, rp, rho, diam, cdn, cdt, fpl, Jrel, Jtan, es, em2, & + scalars_validated=.TRUE.) ELSE ! same primitive without the Jacobian pair: the force statements are identical, so ! the residual-only trial force is bit-for-bit the with-Jacobian force. - CALL CD_Morison_Drag_Per_Length_Jac(urel, rp, rho, diam, cdn, cdt, fpl, ErrStat=es, ErrMsg=em2) + CALL CD_Morison_Drag_Per_Length_Jac(urel, rp, rho, diam, cdn, cdt, fpl, ErrStat=es, ErrMsg=em2, & + scalars_validated=.TRUE.) END IF IF (es /= CD_HYDRO_OK) THEN ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = 'CD_HermiteCable_Drag_Element: '//TRIM(em2); RETURN @@ -10698,6 +10747,15 @@ SUBROUTINE CD_HermiteCable_Drag_Element(qe, ve, l0, fluid, waterline_z, rho, dia END DO END DO IF (.NOT. want_jac) CYCLE + ! position: d[speed * f_perlen_c]/d qe(b) with d(dr/dxi)/d qe = dH(sp) -- + ! speed * Jtan(c,cp) * dH(sp) (force direction/tangent) + ! + f_perlen_c * d(speed)/d qe(b), d(speed)/d qe(b) = tangent(cp) dH(sp); + ! its 3 x 3 factor jq3 is formed once per Gauss point. + DO cp = 1, 3 + DO c = 1, 3 + jq3(c, cp) = speed*Jtan(c, cp) + fpl(c)*tangent(cp) + END DO + END DO DO s = 1, 4 DO c = 1, 3 a = 3*(s - 1) + c @@ -10706,10 +10764,7 @@ SUBROUTINE CD_HermiteCable_Drag_Element(qe, ve, l0, fluid, waterline_z, rho, dia b = 3*(sp - 1) + cp ! velocity: d urel/d ve(b) = -H(sp), scaled by the deformed length measure djv(a, b) = djv(a, b) - scale*H(s)*H(sp)*Jrel(c, cp) - ! position: d[speed * f_perlen_c]/d qe(b) with d(dr/dxi)/d qe = dH(sp) -- - ! speed * Jtan(c,cp) * dH(sp) (force direction/tangent) - ! + f_perlen_c * d(speed)/d qe(b), d(speed)/d qe(b) = tangent(cp) dH(sp) - djq(a, b) = djq(a, b) + gw(g)*H(s)*dH(sp)*(speed*Jtan(c, cp) + fpl(c)*tangent(cp)) + djq(a, b) = djq(a, b) + gw(g)*H(s)*dH(sp)*jq3(c, cp) END DO END DO END DO @@ -10923,7 +10978,7 @@ SUBROUTINE CD_HermiteCable_FK_Element(qe, l0, waterline_z, rho, diam, can, cat, REAL(wp), PARAMETER :: PI_L = 3.14159265358979323846_wp REAL(wp) :: gp(NGP), gw(NGP), H(4), dH(4) REAL(wp) :: rpos(3), rp(3), speed, tangent(3), eta_w, uw(3), aw(3), area, alpha - REAL(wp) :: fpl(3), Jtan(3, 3), proj(3, 3), projk(3), dalpha + REAL(wp) :: fpl(3), Jtan(3, 3), proj(3, 3), projk(3), dalpha, jq3(3, 3) LOGICAL :: has_trio, has_cmp, has_held, want_jac INTEGER :: g, s, c, sp, cp, a, b, k, es ! the message of a per-Gauss-point CableDyn_Hydro kinematics call (at most 154 characters): a @@ -11066,14 +11121,20 @@ SUBROUTINE CD_HermiteCable_FK_Element(qe, l0, waterline_z, rho, diam, can, cat, END DO END DO IF (.NOT. want_jac) CYCLE + ! d[speed * fpl_c]/d qe(b): the tangent chain plus the length-measure derivative, with + ! its 3 x 3 factor jq3 formed once per Gauss point. + DO cp = 1, 3 + DO c = 1, 3 + jq3(c, cp) = speed*Jtan(c, cp) + fpl(c)*tangent(cp) + END DO + END DO DO s = 1, 4 DO c = 1, 3 a = 3*(s - 1) + c DO sp = 1, 4 DO cp = 1, 3 b = 3*(sp - 1) + cp - ! d[speed * fpl_c]/d qe(b): the tangent chain plus the length-measure derivative. - fjq(a, b) = fjq(a, b) + gw(g)*H(s)*dH(sp)*(speed*Jtan(c, cp) + fpl(c)*tangent(cp)) + fjq(a, b) = fjq(a, b) + gw(g)*H(s)*dH(sp)*jq3(c, cp) END DO END DO END DO @@ -11104,7 +11165,8 @@ SUBROUTINE assemble_added_mass(model, q_cfg, ErrStat, ErrMsg) REAL(wp) :: Me(12, 12) CHARACTER(1024) :: em2 ErrStat = CD_HCDYN_OK; ErrMsg = '' - model%ws_Mamb = CD_ZERO + ! the stored band only: the fill-in rows above it stay zero (BAND_TOP_D) + model%ws_Mamb(BAND_TOP_D:, :) = CD_ZERO IF (.NOT. model%has_am) RETURN DO e = 1, model%ne CALL added_mass_element(model, q_cfg, e, Me, es, em2) @@ -11116,7 +11178,7 @@ SUBROUTINE assemble_added_mass(model, q_cfg, ErrStat, ErrMsg) ! Every element matrix can be finite while two adjacent contributions ! overflow at their shared-node band entry. Validate the assembled global ! operator before it is added to the structural mass or factorised. - IF (.NOT. CD_All_Finite(model%ws_Mamb)) THEN + IF (.NOT. CD_All_Finite(model%ws_Mamb(BAND_TOP_D:, :))) THEN ErrStat = CD_HCDYN_NOCONVERGE ErrMsg = 'assemble_added_mass: non-finite global added-mass matrix' END IF @@ -11272,25 +11334,44 @@ PURE SUBROUTINE vector_sum3(x, b, y, c, z, out) out = x + b*y + c*z END SUBROUTINE vector_sum3 + ! The band sweeps below take CONTIGUOUS dummies, which lets the compiler vectorise them. + ! Every caller passes whole allocatable model arrays, so no copy is ever made; an actual + ! the compiler cannot prove contiguous (a pointer, an assumed-shape dummy) would be + ! packed into a temporary at each call. They sweep the stored band rows only: the fill-in + ! rows of their operands are zero (BAND_TOP_D). + PURE SUBROUTINE band_copy(x, y) !! y = x for band storage (distinct arrays). - REAL(wp), INTENT(IN) :: x(:, :) - REAL(wp), INTENT(INOUT) :: y(:, :) - y = x + REAL(wp), CONTIGUOUS, INTENT(IN) :: x(:, :) + REAL(wp), CONTIGUOUS, INTENT(INOUT) :: y(:, :) + INTEGER :: j + DO j = 1, SIZE(x, 2) + y(BAND_TOP_D:, j) = x(BAND_TOP_D:, j) + END DO END SUBROUTINE band_copy - PURE SUBROUTINE band_add(x, y) - !! y = y + x for band storage (distinct arrays). - REAL(wp), INTENT(IN) :: x(:, :) - REAL(wp), INTENT(INOUT) :: y(:, :) - y = y + x - END SUBROUTINE band_add + PURE SUBROUTINE band_sum(x, y, out) + !! out = x + y for band storage in one sweep (out distinct from x and y). + REAL(wp), CONTIGUOUS, INTENT(IN) :: x(:, :), y(:, :) + REAL(wp), CONTIGUOUS, INTENT(INOUT) :: out(:, :) + INTEGER :: j + DO j = 1, SIZE(x, 2) + out(BAND_TOP_D:, j) = x(BAND_TOP_D:, j) + y(BAND_TOP_D:, j) + END DO + END SUBROUTINE band_sum PURE SUBROUTINE band_combine(a, x, b, y, c, z, out) - !! out = a*x + b*y + c*z for band storage (out distinct from x, y, z). - REAL(wp), INTENT(IN) :: a, b, c, x(:, :), y(:, :), z(:, :) - REAL(wp), INTENT(INOUT) :: out(:, :) - out = a*x + b*y + c*z + !! out = a*x + b*y + c*z for band storage (out distinct from x, y, z). The fill-in rows of + !! out, which a previous factorization overwrote, are reset to the zero a*0 + b*0 + c*0 + !! of the operands' fill-in rows. + REAL(wp), INTENT(IN) :: a, b, c + REAL(wp), CONTIGUOUS, INTENT(IN) :: x(:, :), y(:, :), z(:, :) + REAL(wp), CONTIGUOUS, INTENT(INOUT) :: out(:, :) + INTEGER :: j + DO j = 1, SIZE(x, 2) + out(1:BAND_TOP_D - 1, j) = CD_ZERO + out(BAND_TOP_D:, j) = a*x(BAND_TOP_D:, j) + b*y(BAND_TOP_D:, j) + c*z(BAND_TOP_D:, j) + END DO END SUBROUTINE band_combine SUBROUTINE transform_rigid_band(model, bases, matrix) @@ -11485,23 +11566,25 @@ SUBROUTINE scatter12_band(abg, Ke, e) !! Scatter a 12x12 element matrix into LAPACK general-band storage for element e !! (entry (gi, gj) at abg(KL_D + KU_D + 1 + gi - gj, gj); the chain connectivity !! keeps |gi - gj| <= 11 = KU_D, so every entry is in band by construction). + !! The element's DOFs are the 12 consecutive global DOFs from 6(e - 1) + 1 (elem_gdof), so + !! column b of Ke lands in one contiguous run of band column 6(e - 1) + b. REAL(wp), INTENT(INOUT) :: abg(:, :) REAL(wp), INTENT(IN) :: Ke(12, 12) INTEGER, INTENT(IN) :: e - INTEGER :: a, b, gi, gj - DO a = 1, 12 - gi = elem_gdof(e, a) - DO b = 1, 12 - gj = elem_gdof(e, b) - abg(KL_D + KU_D + 1 + gi - gj, gj) = abg(KL_D + KU_D + 1 + gi - gj, gj) + Ke(a, b) + INTEGER :: a, b, gj, row0 + DO b = 1, 12 + gj = 6*(e - 1) + b + row0 = KL_D + KU_D + 1 - b + DO a = 1, 12 + abg(row0 + a, gj) = abg(row0 + a, gj) + Ke(a, b) END DO END DO END SUBROUTINE scatter12_band PURE SUBROUTINE band_matvec(abg, x, y) !! y = A x for A in the module's LAPACK general-band layout. - REAL(wp), INTENT(IN) :: abg(:, :), x(:) - REAL(wp), INTENT(OUT) :: y(:) + REAL(wp), CONTIGUOUS, INTENT(IN) :: abg(:, :), x(:) + REAL(wp), CONTIGUOUS, INTENT(OUT) :: y(:) INTEGER :: i, j, n n = SIZE(x) y = CD_ZERO @@ -11516,7 +11599,7 @@ PURE SUBROUTINE dirichlet_band(abg, freemask) !! Apply Dirichlet conditions in band form: zero each fixed DOF's row and column !! within the band and set a unit diagonal, so the full-size banded solve returns !! exactly zero update at every fixed DOF (with a zero RHS slot). - REAL(wp), INTENT(INOUT) :: abg(:, :) + REAL(wp), CONTIGUOUS, INTENT(INOUT) :: abg(:, :) LOGICAL, INTENT(IN) :: freemask(:) INTEGER :: j, gi, gj, n n = SIZE(freemask) diff --git a/src/CableDyn_Hydro.f90 b/src/CableDyn_Hydro.f90 index 5cde0ef..45514fc 100644 --- a/src/CableDyn_Hydro.f90 +++ b/src/CableDyn_Hydro.f90 @@ -1365,7 +1365,7 @@ SUBROUTINE CD_Morison_Drag_Per_Length(rel_velocity, tangent, rho, diameter, cdn, END SUBROUTINE CD_Morison_Drag_Per_Length SUBROUTINE CD_Morison_Drag_Per_Length_Jac(rel_velocity, tangent, rho, diameter, cdn, cdt, & - force, jac_rel, jac_tan, ErrStat, ErrMsg) + force, jac_rel, jac_tan, ErrStat, ErrMsg, scalars_validated) !! Morison drag per unit length [N/m] and its analytical Jacobians with respect to the relative !! velocity (jac_rel = d force / d rel_velocity) and the (possibly non-unit) tangent VECTOR !! (jac_tan = d force / d tangent), for consistent distributed-load assembly over an element's @@ -1378,14 +1378,20 @@ SUBROUTINE CD_Morison_Drag_Per_Length_Jac(rel_velocity, tangent, rho, diameter, !! jac_rel/jac_tan are OPTIONAL as a pair: a residual-only caller omits both and pays for !! the force alone -- computed by the IDENTICAL statements either way, so the force is !! bit-for-bit the same with or without the Jacobians. + !! + !! scalars_validated (optional, default .FALSE.): .TRUE. when the caller has already + !! established finite rho > 0, diameter > 0, cdn >= 0 and cdt >= 0 (an element kernel + !! that checks them once per element, before its quadrature loop), which skips the same + !! checks at every quadrature point. The vector inputs and the results are always checked. REAL(wp), INTENT(IN) :: rel_velocity(3), tangent(3), rho, diameter, cdn, cdt REAL(wp), INTENT(OUT) :: force(3) REAL(wp), INTENT(OUT), OPTIONAL :: jac_rel(3, 3), jac_tan(3, 3) INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg + LOGICAL, INTENT(IN), OPTIONAL :: scalars_validated REAL(wp) :: ek(3), col(3), tnorm, tu(3), munit(3, 3), proj(3, 3) INTEGER :: k - LOGICAL :: want_jac + LOGICAL :: want_jac, check_scalars want_jac = PRESENT(jac_rel) IF (want_jac .NEQV. PRESENT(jac_tan)) THEN @@ -1398,11 +1404,15 @@ SUBROUTINE CD_Morison_Drag_Per_Length_Jac(rel_velocity, tangent, rho, diameter, jac_rel = CD_ZERO; jac_tan = CD_ZERO END IF ErrStat = CD_HYDRO_OK; ErrMsg = '' - CALL validate_hydro_scalars(rho, diameter, ErrStat, ErrMsg) - IF (ErrStat /= CD_HYDRO_OK) RETURN - IF (.NOT. (CD_Is_Finite(cdn) .AND. CD_Is_Finite(cdt) .AND. cdn >= CD_ZERO .AND. cdt >= CD_ZERO)) THEN - CALL fail(ErrStat, ErrMsg, 'drag coefficients must be finite and non-negative') - RETURN + check_scalars = .TRUE. + IF (PRESENT(scalars_validated)) check_scalars = .NOT. scalars_validated + IF (check_scalars) THEN + CALL validate_hydro_scalars(rho, diameter, ErrStat, ErrMsg) + IF (ErrStat /= CD_HYDRO_OK) RETURN + IF (.NOT. (CD_Is_Finite(cdn) .AND. CD_Is_Finite(cdt) .AND. cdn >= CD_ZERO .AND. cdt >= CD_ZERO)) THEN + CALL fail(ErrStat, ErrMsg, 'drag coefficients must be finite and non-negative') + RETURN + END IF END IF IF (.NOT. CD_All_Finite(rel_velocity) .OR. .NOT. CD_All_Finite(tangent)) THEN CALL fail(ErrStat, ErrMsg, 'relative velocity and tangent must be finite') diff --git a/src/CableDyn_Precision.f90 b/src/CableDyn_Precision.f90 index b900499..96da792 100644 --- a/src/CableDyn_Precision.f90 +++ b/src/CableDyn_Precision.f90 @@ -54,12 +54,20 @@ PURE LOGICAL FUNCTION all_finite_1(x) RESULT(finite) END FUNCTION all_finite_1 PURE LOGICAL FUNCTION all_finite_2(x) RESULT(finite) + ! The column test is inlined rather than called per column: the arrays checked most + ! often are 12 x 12 element matrices, where a call per column costs more than the test. REAL(wp), INTENT(IN) :: x(:, :) - INTEGER :: j + INTEGER :: i, j + INTEGER(I8) :: nonfinite, exponent finite = .TRUE. DO j = 1, SIZE(x, 2) - IF (.NOT. all_finite_1(x(:, j))) THEN + nonfinite = 0_I8 + DO i = 1, SIZE(x, 1) + exponent = IAND(SHIFTR(TRANSFER(x(i, j), 0_I8), 52), EXPONENT_MASK) + nonfinite = IOR(nonfinite, MERGE(1_I8, 0_I8, exponent == EXPONENT_MASK)) + END DO + IF (nonfinite /= 0_I8) THEN finite = .FALSE. RETURN END IF @@ -68,14 +76,22 @@ END FUNCTION all_finite_2 PURE LOGICAL FUNCTION all_finite_3(x) RESULT(finite) REAL(wp), INTENT(IN) :: x(:, :, :) - INTEGER :: k + INTEGER :: i, j, k + INTEGER(I8) :: nonfinite, exponent finite = .TRUE. DO k = 1, SIZE(x, 3) - IF (.NOT. all_finite_2(x(:, :, k))) THEN - finite = .FALSE. - RETURN - END IF + DO j = 1, SIZE(x, 2) + nonfinite = 0_I8 + DO i = 1, SIZE(x, 1) + exponent = IAND(SHIFTR(TRANSFER(x(i, j, k), 0_I8), 52), EXPONENT_MASK) + nonfinite = IOR(nonfinite, MERGE(1_I8, 0_I8, exponent == EXPONENT_MASK)) + END DO + IF (nonfinite /= 0_I8) THEN + finite = .FALSE. + RETURN + END IF + END DO END DO END FUNCTION all_finite_3 From b8bb4b47259673f106131ae364bffc0ed5240d82 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Sun, 8 Nov 2026 22:13:47 +0900 Subject: [PATCH 05/40] fix(outputs): the tangent outputs of a finite-EI line take its tangent DOF (#114) Signed-off-by: Jae Hoon Seo --- CMakeLists.txt | 15 +++ doc/outputs.rst | 24 +++- doc/tutorial_openfast.rst | 2 +- python/tests/test_end_tangent.py | 69 ++++++++++++ src/CableDyn_DeckDriver.f90 | 68 ++++++++++-- tests/check_end_tangent_channels.py | 135 +++++++++++++++++++++++ tests/test_driver_channels.f90 | 35 +++++- tests/test_end_tangent_channels.f90 | 163 ++++++++++++++++++++++++++++ tests/test_openfast_aggregate.f90 | 10 +- 9 files changed, 500 insertions(+), 21 deletions(-) create mode 100644 python/tests/test_end_tangent.py create mode 100644 tests/check_end_tangent_channels.py create mode 100644 tests/test_end_tangent_channels.f90 diff --git a/CMakeLists.txt b/CMakeLists.txt index b2b4b1f..96dcf84 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1284,6 +1284,12 @@ add_executable(test_static_dry_weight tests/test_static_dry_weight.f90) target_link_libraries(test_static_dry_weight PRIVATE cabledyn_core) add_test(NAME static_dry_weight COMMAND test_static_dry_weight WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_tests_properties(static_dry_weight PROPERTIES LABELS "fortran;integration;static;openfast") +# The tangent channels of a finite-EI line read its tangent DOF: at a clamped end the clamp's +# angles, at a pinned end and every other node the solution's tangent. +add_executable(test_end_tangent_channels tests/test_end_tangent_channels.f90) +target_link_libraries(test_end_tangent_channels PRIVATE cabledyn_core) +add_test(NAME end_tangent_channels COMMAND test_end_tangent_channels WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +set_tests_properties(end_tangent_channels PROPERTIES LABELS "fortran;integration;static;outputs" TIMEOUT 600) add_executable(test_viv_kernels tests/test_viv_kernels.f90) target_link_libraries(test_viv_kernels PRIVATE cabledyn_core) @@ -2816,6 +2822,15 @@ if(Python3_Interpreter_FOUND) LABELS "python;driver;integration;static;validation" SKIP_RETURN_CODE 77 ENVIRONMENT "${_cabledyn_python_test_environment}") + # The end tangent outputs of a clamped finite-EI line (channels, static profile, range graph, + # initialisation reports on the finite-EI and mixed routes) are the clamp's angles. + add_test(NAME driver_end_tangent_channels + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_end_tangent_channels.py + $) + set_tests_properties(driver_end_tangent_channels PROPERTIES + LABELS "python;driver;integration;static;outputs" + TIMEOUT 600 + ENVIRONMENT "${_cabledyn_python_test_environment}") endif() # The scan of the round-off CI job: it must flag a fallback note, a failed run and a forced # BLAS kernel that did not take effect, or a clean scan would prove nothing. diff --git a/doc/outputs.rst b/doc/outputs.rst index f2b132e..b8e57da 100644 --- a/doc/outputs.rst +++ b/doc/outputs.rst @@ -218,7 +218,8 @@ finite-EI line of the run: stiffener); written as 0 by the ``EI = 0`` static route * - ``Declination`` - ``(deg)`` - - axial-tangent angle from +Z (0 = up, 90 = horizontal, 180 = down) + - axial-tangent angle from +Z (0 = up, 90 = horizontal, 180 = down); the + :ref:`node tangent ` * - ``Inclination`` - ``(deg)`` - signed inclination below horizontal, ``Declination − 90`` @@ -608,7 +609,8 @@ Line-end channels availability; for an anchor that holds one line it equals ``Point

FH`` - N * - ``FairIncl`` / ``AnchIncl`` - - signed inclination of the End-A / End-B tangent below horizontal + - signed inclination of the End-A / End-B tangent (:ref:`node tangent `) + below horizontal (``Decl − 90``: 0 = horizontal, +90 = pointing down, −90 = pointing up) - deg * - ``FairDecl`` / ``AnchDecl`` @@ -751,7 +753,8 @@ Line-node channels - node acceleration component (0 on a static run) - m/s² * - ``LNDec`` - - declination of the node's axial tangent from +Z (0 = up, 90 = horizontal, 180 = down) + - declination of the node's axial tangent from +Z (0 = up, 90 = horizontal, 180 = down); + the :ref:`node tangent ` - deg * - ``LNAzi`` - azimuth of the node's axial tangent from +X toward +Y, in the line's range @@ -944,6 +947,21 @@ The axial tangent of every orientation channel points from End A toward End B (O ``Ez`` axis). Curvature, declination and azimuth are evaluated from the solved geometry on every route (static, ``EI = 0`` dynamic, finite-EI dynamic, rod and Rigid6 decks). +.. _outputs-node-tangent: + +The tangent of a node is the line's own direction there. On a finite-EI line it is the node's +tangent degree of freedom :math:`\mathbf r_{,s}` of the cubic-Hermite centreline, normalised: at +an end clamped by an ``END CONNECTIONS`` ``Rigid`` row it is the direction the connection holds, +at an end on a rotational spring the direction the spring lets the line take, and at every other +node the tangent of the solution, continuous through the node. On an ``EI = 0`` line, whose segments are straight, it is the direction from +the previous to the next node (from the end node to its neighbour at an end). The +``LNDec``/``Azi`` channels, ``FairDecl``/``AnchDecl``, ``FairIncl``/``AnchIncl`` and +their aliases, the ``Declination``, ``Inclination`` and ``Azimuth`` columns of ``.static.out``, +the ``Declination`` envelope of the range graph and the ``line tangent`` of the initialisation +reports all take it, on every route (the standalone driver, the mixed and multibody routes, the +C API and Python, and OpenFAST). :func:`cabledyn.line_geometry` is a post-processing estimate from +the node positions alone. + Validation of channel names ~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/tutorial_openfast.rst b/doc/tutorial_openfast.rst index 684bd36..b89d9b1 100644 --- a/doc/tutorial_openfast.rst +++ b/doc/tutorial_openfast.rst @@ -339,7 +339,7 @@ seabed. Copy it into the case folder and point a copy of the ``.fst`` at it: ... Line 4 fairlead effective tension: 11922 N force [Fx, Fy, Fz]: [1230, 0, -11858] N, inclination=84.078 deg - line tangent: inclination=83.943 deg, declination=173.94 deg, azimuth=0 deg + line tangent: inclination=83.973 deg, declination=173.97 deg, azimuth=0 deg ... Curv4N38 = 9.01822E-02 (1/m) BendMom4N38 = 1794.6 (N.m) diff --git a/python/tests/test_end_tangent.py b/python/tests/test_end_tangent.py new file mode 100644 index 0000000..54dd89d --- /dev/null +++ b/python/tests/test_end_tangent.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The tangent of a finite-EI line in process: at an end clamped by END CONNECTIONS Rigid the +declination and azimuth the C API returns are the clamp's, and the line values match the +channels (aggregate route).""" + +from __future__ import annotations + +import math +from pathlib import Path + +import pytest + +try: + import cabledyn._lib # noqa: F401 (loads the shared library) +except (ImportError, OSError) as exc: # a missing library raises OSError + pytest.skip(f"CableDyn shared library not available: {exc}", allow_module_level=True) + +from cabledyn.model import CableDyn + +_BAR = "-" * 21 + + +def _deck(path: Path, declination: float, azimuth: float) -> Path: + d, a = math.radians(declination), math.radians(azimuth) + ez = (math.sin(d) * math.cos(a), math.sin(d) * math.sin(a), math.cos(d)) + path.write_text( + f"""{_BAR} CableDyn Input File {_BAR} +a cable clamped at End A +{_BAR} LINE TYPES {_BAR} +Name Diam Mass EA BA EI Cdn Cdt Can Cat +cable 0.2 60.0 1.0e8 -1.0 1.0e4 1.2 0.008 1.0 0.0 +{_BAR} POINTS {_BAR} +ID Type X Y Z Mass Vol CdA Ca +1 Coupled 0.0 0.0 -10.0 0 0 0 0 +2 Fixed 80.0 0.0 -80.0 0 0 0 0 +{_BAR} LINES {_BAR} +ID NodeA NodeB Outputs +1 1 2 - +{_BAR} SECTIONS {_BAR} +LineID LineType Length NumSegs +1 cable 125.0 100 +{_BAR} END CONNECTIONS {_BAR} +LineID End Stiffness EzX EzY EzZ +1 A Rigid {ez[0]:.17g} {ez[1]:.17g} {ez[2]:.17g} +{_BAR} OPTIONS {_BAR} +9.80665 g +1025.0 rhoW +0.05 dtM +1.0 TMax +{_BAR} OUTPUTS {_BAR} +FairDecl1 +{_BAR} need this line {_BAR} +""", + encoding="utf-8", + ) + return path + + +@pytest.mark.parametrize(("declination", "azimuth"), [(165.0, 0.0), (135.0, 20.0)]) +def test_the_clamped_end_reads_the_clamp(tmp_path, declination, azimuth): + with CableDyn(_deck(tmp_path / "clamp.dat", declination, azimuth)) as model: + line = model.line(1) + dec, azi = line.declination(), line.azimuth() + assert dec[0] == pytest.approx(declination, abs=1.0e-9) + assert azi[0] == pytest.approx(azimuth, abs=1.0e-9) + assert model.channel("FairDecl1") == pytest.approx(declination, abs=1.0e-9) + # the ends of the line's mesh (refined at the clamp) are its deck end nodes + assert dec[0] == model.channel("L1N1Dec") and azi[0] == model.channel("L1N1Azi") + assert dec[-1] == model.channel("AnchDecl1") diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index bb3317d..0e6abd5 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -36621,12 +36621,28 @@ END FUNCTION wrap_azimuth_deg PURE REAL(wp) FUNCTION line_geom_value(kind, deck_node, nnode, q_flat, stride, signed) RESULT(v) !! Shared evaluator for the GEOMETRIC output channels so every writer computes them - !! identically. `q_flat` holds the line node positions in INTERNAL order - !! (anchor->fairlead) with `stride` reals per node (3 for EI=0, 6 for finite-EI). + !! identically. `q_flat` holds the line node states in INTERNAL order + !! (anchor->fairlead) with `stride` reals per node (3 for EI=0: the position; 6 for + !! finite-EI: the position and the tangent DOF m = dr/ds). !! kind: 5 curvature, 7 declination, 8 azimuth. Bend moment is NOT geometric -- it is !! the MATERIAL moment EI*(kappa - kappa_0), computed per path (zero on EI=0 lines; !! eval_cable_channel and range_sample_cable on finite-EI lines), so it is not handled here and - !! kind = 6 returns 0. signed: the line's azimuth range (DeckLine%azi_signed). + !! kind = 6 returns 0. signed: the line's azimuth range (DeckLine%azi_signed). The + !! declination and azimuth of a finite-EI node are those of its tangent DOF + !! (cable_node_tangent), of an EI = 0 node those of the difference of node positions + !! (deck_node_tangent). + INTEGER, INTENT(IN) :: kind, deck_node, nnode, stride + REAL(wp), INTENT(IN) :: q_flat(:) + LOGICAL, INTENT(IN), OPTIONAL :: signed + IF (stride >= 6 .AND. (kind == 7 .OR. kind == 8)) THEN + v = cable_tangent_angle(kind, cable_node_tangent(q_flat, stride, nnode, deck_node), signed) + ELSE + v = position_geom_value(kind, deck_node, nnode, q_flat, stride, signed) + END IF + END FUNCTION line_geom_value + + PURE REAL(wp) FUNCTION position_geom_value(kind, deck_node, nnode, q_flat, stride, signed) RESULT(v) + !! line_geom_value from the node positions alone (line_geom_from_r), q_flat as there. INTEGER, INTENT(IN) :: kind, deck_node, nnode, stride REAL(wp), INTENT(IN) :: q_flat(:) LOGICAL, INTENT(IN), OPTIONAL :: signed @@ -36638,21 +36654,56 @@ PURE REAL(wp) FUNCTION line_geom_value(kind, deck_node, nnode, q_flat, stride, s r(:, j) = q_flat(base + 1:base + 3) END DO v = line_geom_from_r(kind, r, nnode, deck_node, signed) - END FUNCTION line_geom_value + END FUNCTION position_geom_value PURE SUBROUTINE line_geom_values(kind, nnode, stride, q, signed, values) !! line_geom_value at every deck node of a line. q(:, n) is internal node n's state, its first - !! three entries the position; the deck-ordered positions are read in place, not copied. + !! three entries the position (and on a finite-EI line, stride 6, the next three its tangent + !! DOF); the deck-ordered positions are read in place, not copied. INTEGER, INTENT(IN) :: kind, nnode, stride REAL(wp), INTENT(IN) :: q(stride, nnode) LOGICAL, INTENT(IN) :: signed REAL(wp), INTENT(OUT) :: values(nnode) INTEGER :: j + IF (stride >= 6 .AND. (kind == 7 .OR. kind == 8)) THEN + DO j = 1, nnode + ! deck node j is internal node nnode + 1 - j, whose tangent runs End B -> End A + values(j) = cable_tangent_angle(kind, -q(4:6, nnode + 1 - j), signed) + END DO + RETURN + END IF DO j = 1, nnode values(j) = line_geom_from_r(kind, q(1:3, nnode:1:-1), nnode, j, signed) END DO END SUBROUTINE line_geom_values + PURE FUNCTION cable_node_tangent(q_flat, stride, nnode, deck_node) RESULT(t) + !! The axial tangent of a finite-EI line at a deck node, oriented End A -> End B: its tangent + !! DOF m = dr/ds (q_flat in internal order, anchor -> fairlead, stride reals per node, the + !! position then m), reversed, as the internal arc length runs from End B. It is the line's + !! own direction at the node: the direction an END CONNECTIONS row holds at a restrained + !! end, continuous through the node, where a difference of node positions is the direction + !! of a chord. + REAL(wp), INTENT(IN) :: q_flat(:) + INTEGER, INTENT(IN) :: stride, nnode, deck_node + REAL(wp) :: t(3) + INTEGER :: base + base = stride*(nnode - MIN(MAX(deck_node, 1), nnode)) + t = -q_flat(base + 4:base + 6) + END FUNCTION cable_node_tangent + + PURE REAL(wp) FUNCTION cable_tangent_angle(kind, t, signed) RESULT(v) + !! kind 7: the declination [deg] of the tangent t; 8: its azimuth, in the line's range. + INTEGER, INTENT(IN) :: kind + REAL(wp), INTENT(IN) :: t(3) + LOGICAL, INTENT(IN), OPTIONAL :: signed + IF (kind == 7) THEN + v = tangent_declination_deg(t) + ELSE + v = tangent_azimuth_deg(t, signed) + END IF + END FUNCTION cable_tangent_angle + PURE REAL(wp) FUNCTION line_geom_from_r(kind, r, nnode, deck_node, signed) RESULT(v) !! Geometric channel value (kind: 5 curvature, 7 declination, 8 azimuth) from the !! deck-ordered node positions r(3, nnode). Factored out of line_geom_value so a caller @@ -37222,13 +37273,10 @@ SUBROUTINE range_sample_cable(cable, rl, ErrStat, ErrMsg) IF (cable%line%nn /= nn .OR. cable%line%ne /= ne .OR. SIZE(cable%line%q) < 6*nn) THEN CALL fail_solve(ErrStat, ErrMsg, 'range-graph sample: cable state not available'); RETURN END IF - DO j = 1, nn - inode = nn + 1 - j - rl%r(:, j) = cable%line%q(6*(inode - 1) + 1:6*(inode - 1) + 3) - END DO IF (rl%want_range) THEN + ! the declination of each node's tangent DOF (cable_node_tangent), as LNDec DO j = 1, nn - rl%val(CD_RQ_DECLINATION, j) = line_geom_from_r(7, rl%r, nn, j) + rl%val(CD_RQ_DECLINATION, j) = cable_tangent_angle(7, cable_node_tangent(cable%line%q, 6, nn, j)) END DO END IF DO j = 1, nn diff --git a/tests/check_end_tangent_channels.py b/tests/check_end_tangent_channels.py new file mode 100644 index 0000000..8121a28 --- /dev/null +++ b/tests/check_end_tangent_channels.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The tangent outputs of a finite-EI line clamped at End A, through the standalone driver +(driver_end_tangent_channels in CMakeLists.txt). + +A cable is clamped at End A (END CONNECTIONS Rigid) along a set direction, swept over +declinations of 120 to 180 deg and azimuths of 0 and 20 deg, and pinned at End B. Every output +of the end tangent must equal the clamp's angles to round-off (1e-6 deg, the printed digits +allowing): the FairDecl, FairIncl, L1N1Dec and L1N1Azi channels, the first row of +the static profile (Declination, Inclination, Azimuth), the range graph's DeclinationMin and +DeclinationMax at node 1 over a short held march, and the ``line tangent`` line of the +initialisation report. The line bends within its first element at the clamp, where a +difference of node positions reads several degrees off. The same deck with an EI = 0 chain +beside the cable runs on the mixed route, whose fairlead report must give the clamp too. + +Usage: check_end_tangent_channels.py +""" + +import math +import pathlib +import re +import subprocess +import sys +import tempfile + +DECLINATIONS = (180.0, 165.0, 150.0, 135.0, 120.0) +AZIMUTHS = (0.0, 20.0) +TOL = 1.0e-6 +CHANNELS = ("FairDecl1", "FairIncl1", "L1N1Dec", "L1N1Azi") + + +def deck(dec: float, azi: float, mixed: bool) -> str: + d, a = math.radians(dec), math.radians(azi) + ez = (math.sin(d) * math.cos(a), math.sin(d) * math.sin(a), math.cos(d)) + types = "cable 0.2 60.0 1.0e8 -1.0 1.0e4 1.2 0.008 1.0 0.0\n" + points = "1 Coupled 0.0 0.0 -10.0 0 0 0 0\n2 Fixed 80.0 0.0 -80.0 0 0 0 0\n" + lines = "1 1 2 r\n" + sections = "1 cable 125.0 100\n" + if mixed: + types += "chain 0.1 20.0 5.0e8 -1.0 0.0 1.2 0.4 1.0 0.0\n" + points += "3 Coupled 0.0 50.0 -10.0 0 0 0 0\n4 Fixed 80.0 50.0 -80.0 0 0 0 0\n" + lines = "1 1 2 -\n2 3 4 -\n" + sections += "2 chain 125.0 40\n" + outputs = "".join(f'"{c}"\n' for c in CHANNELS) + return f"""--------------------- CableDyn Input File ------------------------------------ +tangent outputs of a cable clamped at End A +--------------------- LINE TYPES --------------------------------------------- +TypeName Diam MassDenInAir EA BA/-zeta EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (N-s/-) (N-m^2) (-) (-) (-) (-) +{types}--------------------- POINTS ------------------------------------------------- +ID Type X Y Z Mass Vol CdA Ca +(-) (-) (m) (m) (m) (kg) (m^3) (m^2) (-) +{points}--------------------- LINES -------------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +{lines}--------------------- SECTIONS ----------------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +{sections}--------------------- END CONNECTIONS ---------------------------------------- +LineID End Stiffness EzX EzY EzZ +(-) (-) (N-m/rad) (-) (-) (-) +1 A Rigid {ez[0]:.17g} {ez[1]:.17g} {ez[2]:.17g} +--------------------- OPTIONS ------------------------------------------------ +9.80665 g +1025.0 rhoW +0.05 dtM +{0.0 if mixed else 0.1} TMax +--------------------- OUTPUTS ------------------------------------------------ +{outputs}END +""" + + +def table(path: pathlib.Path, skip: int) -> tuple[list[str], list[list[float]]]: + rows = path.read_text().splitlines() + names = [n.split("(")[0] for n in rows[1].split()] + return names, [[float(v) for v in r.split()] for r in rows[skip:] if r.strip()] + + +def main() -> int: + exe = sys.argv[1] + failures = [] + + def check(ok: bool, what: str, got: float, want: float) -> None: + print(("ok " if ok else "FAIL ") + f"{what}: {got:.9f} deg (clamp {want:.9f})") + if not ok: + failures.append(what) + + with tempfile.TemporaryDirectory() as folder: + tmp = pathlib.Path(folder) + for dec in DECLINATIONS: + for azi in AZIMUTHS if dec < 180.0 else (0.0,): + tag = f"d{dec:g}_a{azi:g}" + want = {"FairDecl1": dec, "FairIncl1": dec - 90.0, "L1N1Dec": dec, + "L1N1Azi": azi} + for mixed in (False, True): + name = tag + ("_mixed" if mixed else "") + (tmp / f"{name}.dat").write_text(deck(dec, azi, mixed)) + proc = subprocess.run([exe, f"{name}.dat", name], cwd=tmp, capture_output=True, text=True) + if proc.returncode != 0: + failures.append(f"{name}: the driver failed") + print(f"FAIL {name}: the driver failed:\n{(proc.stdout + proc.stderr)[-1500:]}") + continue + log = proc.stdout + proc.stderr + if mixed: + match = re.search(r"Line 1: FairTen=\s*\S+ N, tangent inclination=\s*(\S+) deg", log) + got = float(match.group(1)) if match else math.nan + check(abs(got - (dec - 90.0)) <= 1.0e-3, f"{name} mixed report inclination", got, dec - 90.0) + continue + names, rows = table(tmp / f"{name}.out", 2) + for ch in CHANNELS: + if ch == "L1N1Azi" and dec == 180.0: + continue + got = rows[0][names.index(ch)] + check(abs(got - want[ch]) <= TOL, f"{name} {ch} at t = 0", got, want[ch]) + names, rows = table(tmp / f"{name}.static.out", 3) + first = rows[0] + check(abs(first[names.index("Declination")] - dec) <= TOL, f"{name} static Declination", + first[names.index("Declination")], dec) + check(abs(first[names.index("Inclination")] - (dec - 90.0)) <= TOL, + f"{name} static Inclination", first[names.index("Inclination")], dec - 90.0) + if dec < 180.0: + check(abs(first[names.index("Azimuth")] - azi) <= TOL, f"{name} static Azimuth", + first[names.index("Azimuth")], azi) + names, rows = table(tmp / f"{name}.Line1.range.out", 3) + for col in ("DeclinationMin", "DeclinationMax"): + got = rows[0][names.index(col)] + check(abs(got - dec) <= TOL, f"{name} range {col}", got, dec) + match = re.search(r"line tangent: inclination=\s*(\S+) deg, declination=\s*(\S+) deg", log) + got = float(match.group(2)) if match else math.nan + check(abs(got - dec) <= 1.0e-3, f"{name} report declination", got, dec) + print("PASS" if not failures else f"FAIL: {len(failures)} check(s)") + return 0 if not failures else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_driver_channels.f90 b/tests/test_driver_channels.f90 index a5758b3..e8bde6f 100644 --- a/tests/test_driver_channels.f90 +++ b/tests/test_driver_channels.f90 @@ -323,8 +323,12 @@ SUBROUTINE test_finite_dynamic_channels() ! of every node of the initial state is tensile. CALL require(min_static_tension('chan_fin.static.out') > 0.0_wp, & 'opt-in tensile safety leaves a tensile element-mean state before initialisation') - ! declination channel matches the reported tangent. - CALL require(ABS(v(18) - ref_dec(n4 - n2)) < 1.0e-3_wp, 'finite-EI L1N3Dec matches tangent') + ! The declination channel is the cable's tangent at the node (its tangent DOF), the + ! Declination of the static profile; the chord of the neighbouring nodes is only close to it + ! on this 20-element arch (1.2 deg off). + CALL require(ABS(v(18) - static_declination('chan_fin.static.out', 3)) < 1.0e-5_wp, & + 'finite-EI L1N3Dec is the static profile tangent') + CALL require(ABS(v(18) - ref_dec(n4 - n2)) < 2.0_wp, 'finite-EI L1N3Dec is close to the chord') ! acceleration channel is finite on a dynamic run. CALL require(IEEE_IS_FINITE(v(15)) .AND. IEEE_IS_FINITE(v(16)) .AND. IEEE_IS_FINITE(v(17)), & 'finite-EI acceleration channel is finite') @@ -382,6 +386,33 @@ SUBROUTINE test_finite_straight_bendmom() CALL require(ABS(v(3)) <= 1.0e-6_wp*2.0e5_wp, 'reference-relative BendMom = 0 when the shape equals its reference') END SUBROUTINE test_finite_straight_bendmom + REAL(wp) FUNCTION static_declination(path, want_node) RESULT(dec) + !! The Declination of node want_node of line 1 in a static.out profile; HUGE when unreadable. + CHARACTER(*), INTENT(IN) :: path + INTEGER, INTENT(IN) :: want_node + INTEGER :: u, ios, lineid, node + REAL(wp) :: s, x, y, z, tn, curv, bend, d + CHARACTER(2048) :: buf + dec = HUGE(1.0_wp) + OPEN (NEWUNIT=u, FILE=path, STATUS='OLD', ACTION='READ', IOSTAT=ios) + IF (ios /= 0) RETURN + DO node = 1, 3 + READ (u, '(A)', IOSTAT=ios) buf + END DO + DO + READ (u, '(A)', IOSTAT=ios) buf + IF (ios /= 0) EXIT + IF (LEN_TRIM(buf) == 0) CYCLE + READ (buf, *, IOSTAT=ios) lineid, node, s, x, y, z, tn, curv, bend, d + IF (ios /= 0) EXIT + IF (lineid == 1 .AND. node == want_node) THEN + dec = d + EXIT + END IF + END DO + CLOSE (u) + END FUNCTION static_declination + REAL(wp) FUNCTION min_static_tension(path) RESULT(tmin) !! Smallest node Tension (segment tension) of a static.out profile; -HUGE when unreadable. CHARACTER(*), INTENT(IN) :: path diff --git a/tests/test_end_tangent_channels.f90 b/tests/test_end_tangent_channels.f90 new file mode 100644 index 0000000..7c5594c --- /dev/null +++ b/tests/test_end_tangent_channels.f90 @@ -0,0 +1,163 @@ +! File: tests/test_end_tangent_channels.f90 +! SPDX-License-Identifier: Apache-2.0 +! Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +PROGRAM test_end_tangent_channels + !! The tangent channels of a finite-EI line read its tangent, the Hermite tangent DOF m = dr/ds + !! of every node (normalised, End A -> End B), not a difference of node positions. A cable is + !! clamped at End A (END CONNECTIONS Rigid) along a set direction, swept over declinations of + !! 120 to 180 deg and azimuths of 0 and 20 deg (a clamp out of the vertical plane of the + !! ends), and pinned (bending-free) at End B. At the clamp FairDecl, FairAngle, FairIncl, + !! L1N1Dec and L1N1Azi equal the clamp's angles to round-off, though the line bends sharply + !! within its first element there; at the pinned end AnchDecl, AnchIncl and the last node's + !! Dec, and at every interior node Dec, equal the angle of the solution's own tangent. On the + !! aggregate route, which the C API and the OpenFAST shell share; the standalone outputs use + !! the same evaluator (check_end_tangent_channels.py). + USE CableDyn_Precision, ONLY: wp + USE CableDyn_OpenFAST_Aggregate, ONLY: CD_AGG_ModuleType, CD_AGG_Init_From_Deck, CD_AGG_End, CD_AGG_OK + USE CableDyn_DeckDriver, ONLY: CD_Eval_Aggregate_Channel + IMPLICIT NONE + + REAL(wp), PARAMETER :: PI = 3.14159265358979323846_wp + REAL(wp), PARAMETER :: TOL_DEG = 1.0e-9_wp + REAL(wp), PARAMETER :: DECLINATIONS(5) = [180.0_wp, 165.0_wp, 150.0_wp, 135.0_wp, 120.0_wp] + REAL(wp), PARAMETER :: AZIMUTHS(2) = [0.0_wp, 20.0_wp] + INTEGER :: nfail, id, ia + REAL(wp) :: worst_clamp, worst_node, worst_chord + ! the case under way + TYPE(CD_AGG_ModuleType) :: agg + CHARACTER(96) :: label + + nfail = 0 + worst_clamp = 0.0_wp + worst_node = 0.0_wp + worst_chord = 0.0_wp + DO id = 1, SIZE(DECLINATIONS) + DO ia = 1, SIZE(AZIMUTHS) + IF (id == 1 .AND. ia > 1) CYCLE ! a vertical clamp has no azimuth + CALL run_case(DECLINATIONS(id), AZIMUTHS(ia)) + END DO + END DO + WRITE (*, '(A,ES10.3,A,ES10.3,A)') 'largest channel error at the clamp ', worst_clamp, & + ' deg, at the other nodes ', worst_node, ' deg' + WRITE (*, '(A,F8.3,A)') 'the node-position difference at the clamp is off by up to ', worst_chord, ' deg' + IF (nfail > 0) THEN + WRITE (*, '(A,I0,A)') 'FAIL: ', nfail, ' check(s) failed' + ERROR STOP 1 + END IF + WRITE (*, '(A)') 'PASS' + +CONTAINS + + SUBROUTINE run_case(dec_clamp, azi_clamp) + REAL(wp), INTENT(IN) :: dec_clamp, azi_clamp + CHARACTER(512) :: em + CHARACTER(64) :: path, ch + REAL(wp) :: ez(3), t(3), dec_m, chord(3) + INTEGER :: es, nn, j, inode + + ez = [SIN(dec_clamp*PI/180.0_wp)*COS(azi_clamp*PI/180.0_wp), & + SIN(dec_clamp*PI/180.0_wp)*SIN(azi_clamp*PI/180.0_wp), COS(dec_clamp*PI/180.0_wp)] + path = 'end_tangent_channels.dat' + CALL write_deck(path, ez) + WRITE (label, '(A,F6.1,A,F5.1,A)') 'clamp declination ', dec_clamp, ' deg, azimuth ', azi_clamp, ' deg' + CALL CD_AGG_Init_From_Deck(agg, TRIM(path), 0.05_wp, es, em) + IF (es /= CD_AGG_OK) THEN + CALL check(.FALSE., TRIM(label)//': init: '//TRIM(em)) + CALL CD_AGG_End(agg, es, em) + RETURN + END IF + ! the clamped End A (the line's last internal node) + CALL expect('FairDecl1', dec_clamp, worst_clamp) + CALL expect('FairAngle1', dec_clamp, worst_clamp) + CALL expect('FairIncl1', dec_clamp - 90.0_wp, worst_clamp) + CALL expect('L1N1Dec', dec_clamp, worst_clamp) + IF (ABS(ez(3)) < 1.0_wp - 1.0e-12_wp) CALL expect('L1N1Azi', azi_clamp, worst_clamp) + nn = agg%cables(1)%line%nn + ! what a difference of node positions gives at the clamp + chord = agg%cables(1)%line%q(6*(nn - 2) + 1:6*(nn - 2) + 3) - agg%cables(1)%line%q(6*(nn - 1) + 1:6*(nn - 1) + 3) + worst_chord = MAX(worst_chord, ABS(ACOS(chord(3)/NORM2(chord))*180.0_wp/PI - dec_clamp)) + ! every other node, the pinned End B included: the angle of the solution's tangent DOF + DO j = 2, nn + inode = nn + 1 - j + t = -agg%cables(1)%line%q(6*(inode - 1) + 4:6*(inode - 1) + 6) + dec_m = ACOS(MAX(-1.0_wp, MIN(1.0_wp, t(3)/NORM2(t))))*180.0_wp/PI + WRITE (ch, '(A,I0,A)') 'L1N', j, 'Dec' + CALL expect(TRIM(ch), dec_m, worst_node, mesh=.TRUE.) + END DO + CALL expect('AnchDecl1', dec_m, worst_node) + CALL expect('AnchIncl1', dec_m - 90.0_wp, worst_node) + CALL CD_AGG_End(agg, es, em) + END SUBROUTINE run_case + + SUBROUTINE expect(name, want, worst, mesh) + !! The channel name of the case's line equals want [deg] to TOL_DEG; worst: the largest error. + CHARACTER(*), INTENT(IN) :: name + REAL(wp), INTENT(IN) :: want + REAL(wp), INTENT(INOUT) :: worst + LOGICAL, INTENT(IN), OPTIONAL :: mesh + REAL(wp) :: v + INTEGER :: ese + CHARACTER(512) :: eme + IF (PRESENT(mesh)) THEN + CALL CD_Eval_Aggregate_Channel(name, agg%has_sys, agg%sys%fast%system, agg%cables, agg%line_is_cable, & + agg%line_obj_index, v, ese, eme, mesh_node=mesh) + ELSE + CALL CD_Eval_Aggregate_Channel(name, agg%has_sys, agg%sys%fast%system, agg%cables, agg%line_is_cable, & + agg%line_obj_index, v, ese, eme) + END IF + IF (ese /= 0) THEN + CALL check(.FALSE., TRIM(label)//': '//name//': '//TRIM(eme)) + RETURN + END IF + worst = MAX(worst, ABS(v - want)) + IF (ABS(v - want) > TOL_DEG) THEN + WRITE (*, '(4A,F14.9,A,F14.9)') ' ', TRIM(label), ': ', name, v, ' deg, the tangent gives ', want + CALL check(.FALSE., TRIM(label)//': '//name//' is the angle of the line tangent') + END IF + END SUBROUTINE expect + + SUBROUTINE write_deck(path, ez) + CHARACTER(*), INTENT(IN) :: path + REAL(wp), INTENT(IN) :: ez(3) + INTEGER :: u + OPEN (NEWUNIT=u, FILE=path, STATUS='REPLACE', ACTION='WRITE') + WRITE (u, '(A)') '--------------------- CableDyn Input File ------------------------------------' + WRITE (u, '(A)') 'tangent channels of a cable clamped at End A' + WRITE (u, '(A)') '--------------------- LINE TYPES ---------------------------------------------' + WRITE (u, '(A)') 'TypeName Diam MassDenInAir EA BA/-zeta EI Cd_n Cd_t Ca_n Ca_t' + WRITE (u, '(A)') '(-) (m) (kg/m) (N) (N-s/-) (N-m^2) (-) (-) (-) (-)' + WRITE (u, '(A)') 'cable 0.2 60.0 1.0e8 -1.0 1.0e4 1.2 0.008 1.0 0.0' + WRITE (u, '(A)') '--------------------- POINTS -------------------------------------------------' + WRITE (u, '(A)') 'ID Type X Y Z Mass Vol CdA Ca' + WRITE (u, '(A)') '(-) (-) (m) (m) (m) (kg) (m^3) (m^2) (-)' + WRITE (u, '(A)') '1 Coupled 0.0 0.0 -10.0 0 0 0 0' + WRITE (u, '(A)') '2 Fixed 80.0 0.0 -80.0 0 0 0 0' + WRITE (u, '(A)') '--------------------- LINES --------------------------------------------------' + WRITE (u, '(A)') 'ID NodeA NodeB Outputs' + WRITE (u, '(A)') '(-) (-) (-) (-)' + WRITE (u, '(A)') '1 1 2 -' + WRITE (u, '(A)') '--------------------- SECTIONS -----------------------------------------------' + WRITE (u, '(A)') 'LineID LineType Length NumSegs' + WRITE (u, '(A)') '(-) (-) (m) (-)' + WRITE (u, '(A)') '1 cable 125.0 100' + WRITE (u, '(A)') '--------------------- END CONNECTIONS ----------------------------------------' + WRITE (u, '(A)') 'LineID End Stiffness EzX EzY EzZ' + WRITE (u, '(A)') '(-) (-) (N-m/rad) (-) (-) (-)' + WRITE (u, '(A,3(1X,ES23.16))') '1 A Rigid', ez + WRITE (u, '(A)') '--------------------- OPTIONS ------------------------------------------------' + WRITE (u, '(A)') '9.80665 g' + WRITE (u, '(A)') '1025.0 rhoW' + WRITE (u, '(A)') '--------------------- OUTPUTS ------------------------------------------------' + WRITE (u, '(A)') 'END' + CLOSE (u) + END SUBROUTINE write_deck + + SUBROUTINE check(ok, label) + LOGICAL, INTENT(IN) :: ok + CHARACTER(*), INTENT(IN) :: label + IF (ok) RETURN + nfail = nfail + 1 + WRITE (*, '(2A)') ' FAIL ', TRIM(label) + END SUBROUTINE check + +END PROGRAM test_end_tangent_channels diff --git a/tests/test_openfast_aggregate.f90 b/tests/test_openfast_aggregate.f90 index 89809f5..ab9d75f 100644 --- a/tests/test_openfast_aggregate.f90 +++ b/tests/test_openfast_aggregate.f90 @@ -758,7 +758,7 @@ SUBROUTINE case_output_channels() REAL(wp), PARAMETER :: DT = 0.05_wp, EI_BARE = 1.99e4_wp REAL(wp) :: vFairTen2, vAnchTen2, vCurv1N5, vFairAngle1, vBendMom1N5, vTen1N10 REAL(wp) :: vFairDecl1, vFairIncl1 - REAL(wp) :: r1(3), r2(3), kexp, decexp + REAL(wp) :: kexp, decexp REAL(wp), ALLOCATABLE :: tension(:), exact_curvature(:) CALL write_mixed_channels_deck('agg_channels.dat') @@ -859,10 +859,10 @@ SUBROUTINE case_output_channels() END BLOCK CALL CD_HermiteCable_Dyn_Set_Output_Recovery(agg%cables(1)%line, .FALSE.) - ! (c) cable FairAngle1 == declination of the End-A one-sided tangent (deck nodes 1,2). - base = 6*(nnode - 1); r1 = agg%cables(1)%line%q(base + 1:base + 3) - base = 6*(nnode - 2); r2 = agg%cables(1)%line%q(base + 1:base + 3) - decexp = tangent_declination_deg(r2 - r1) + ! (c) cable FairAngle1 == declination of the End-A tangent: the tangent DOF of deck node 1 + ! (internal node nnode), reversed to point End A -> End B. + base = 6*(nnode - 1) + decexp = tangent_declination_deg(-agg%cables(1)%line%q(base + 4:base + 6)) CALL require(ABS(vFairAngle1 - decexp) <= 1.0e-9_wp + 1.0e-6_wp*ABS(decexp), 'chan:FairAngle1-matches-driver') CALL require(vFairDecl1 >= 0.0_wp .AND. vFairDecl1 <= 180.0_wp, 'chan:AnchDecl1-declination-range') CALL require(ABS(vFairIncl1 - (decexp - 90.0_wp)) <= 1.0e-9_wp + 1.0e-6_wp*ABS(decexp), & From 05f795bd0ccdc4541aac519c99e900f3012ce59e Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Sun, 8 Nov 2026 22:34:24 +0900 Subject: [PATCH 06/40] fix(viv): MoorDyn's lift direction on either deck order; VIVphaseSeed (#111) Signed-off-by: Jae Hoon Seo --- VALIDATION.md | 2 +- doc/options.rst | 9 +++ doc/theory.rst | 16 +++- python/cabledyn/deck_file.py | 4 + python/tests/test_deck_file.py | 4 + src/CableDyn_DeckDriver.f90 | 90 +++++++++++++---------- src/CableDyn_HermiteCableDynamic.f90 | 30 ++++++-- src/CableDyn_Model.f90 | 32 ++++++-- src/CableDyn_VIV.f90 | 8 +- tests/test_hermite_viv.f90 | 6 +- tests/test_viv_coupled.f90 | 32 ++++++-- tests/test_viv_lockin.py | 105 ++++++++++++++++++++++++--- 12 files changed, 263 insertions(+), 75 deletions(-) diff --git a/VALIDATION.md b/VALIDATION.md index e556c0a..63dbd7f 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -62,7 +62,7 @@ article's methods, are compared in | **L2-V** | VolturnUS-S anchor tension | closed-form catenary | 0.77% | <3% | Pass | | **L2-80** | Lozon 80 m all-chain mooring | catenary (H) / MoorDyn-F (fairlead) | 0.03% / 1.8% | <2% / <5% | Pass | | **L2-arb** | Closed-form catenary reference check | OrcaFlex VolturnUS-S ref | 0.69% | <2% | Pass | -| **L2-VIV** | Cross-flow VIV lock-in of a taut 100 m line, U = 0.06–0.42 m/s | MoorDyn-F (OpenFAST v5 `moordyn_driver`, `dtM` 5e-4 s) | mode-1 and mode-3 lock-in (U ≤ 0.18 and ≥ 0.36 m/s): mid-span A/D within 1.2%, frequency within one spectral bin; same response at `dtM` 5e-4 to 1e-2 s; at 0.21–0.33 m/s the codes settle on different modes (MoorDyn-F mode 1, CableDyn mode 2) | A/D <3%, frequency within one spectral bin (`viv_lockin`) | Pass scoped | +| **L2-VIV** | Cross-flow VIV lock-in of a taut 100 m line, U = 0.06–0.42 m/s | MoorDyn-C v2.7.2 (RK2, `dtM` 5e-4 s) and MoorDyn-F (OpenFAST v5 `moordyn_driver`, `dtM` 5e-4 s) | mode-1 and mode-3 lock-in (U ≤ 0.18 and ≥ 0.36 m/s): mid-span A/D within 1.2% of MoorDyn-F, frequency within one spectral bin; same response at `dtM` 5e-4 to 1e-2 s. At 0.21–0.33 m/s two branches coexist (a run ramped up from 0.18 m/s stays in mode 1, one ramped down from 0.30 m/s stays in mode 2): from rest CableDyn and MoorDyn-C settle in mode 2 (U 0.24 m/s: A/D 0.575 against 0.574 at x/L 0.24, same frequency); MoorDyn-F, whose phases start at zero (it does not carry its seed into its state), in mode 1, which `zero VIVphaseSeed` reproduces (0.701 against 0.699, transient to four digits) | A/D <3%, frequency within one spectral bin; band: 3% (MoorDyn-C), 1% (MoorDyn-F) (`viv_lockin`) | Pass | | **L2-VIV-riser** | Catenary riser of Thorsen et al. in a uniform 0.2 m/s current (MoorDyn-C's VIV test: D 0.024 m, EI 10.5 N m², 100 segments, `Cl` 0.7), deck written anchor first | measured cross-flow frequency 1.04 Hz; MoorDyn-C v2.7.2 (RK4, `dtM` 1e-4 s) | over 20–40 s at `dtM` 1e-3 s: mean fairlead tension 69.92 N against 69.93 N, range 3.73 N against 3.72 N, tension period 0.4853 s against the measured 0.4808 s and MoorDyn-C's 0.4811 s (0.4843 / 0.4829 s at `dtM` 5e-4 / 2.5e-4 s) | mean 0.2%, range 10%, period 1.5% (`viv_riser`) | Pass | | **L2-VIV-EI** | Cross-flow VIV of a finite-EI cable: the L2-VIV line with EI = 1 N m², and a bending-stiff line (EA 5e9 N, EI 5e7 N m², β = π²EI/(TL²) ≈ 1, neutrally buoyant) against the EI = 0 string of equal first-mode frequency, U = 0.10–0.36 m/s | CableDyn `EI = 0` line (verification) | EI = 1: mid/quarter A/D 0.662/0.421 against 0.663/0.421, same frequency, at `dtM` 5e-4 to 1e-2 s; stiff line: mode-1 lock-in A/D within 2.5% of the string's at the same frequency for U ≤ 0.24 m/s; for U ≥ 0.27 m/s the string jumps to mode 2 while the stiff line stays in mode 1 (bending raises f₂/f₁ to 3.15) | EI = 1: A/D within 1% at `dtM` 0.01 and 0.002 s (5e-4 s run once over 200 s); stiff line at U 0.21 m/s: A/D within 3%; frequency within one spectral bin; at U 0.30 m/s stiff line A/D > 0.6, string < 0.15 (`viv_lockin`, `hermite_viv`) | Pass | | **L3-1** | WD0050 static | OrcaFlex | 0.15 / 0.85% | <2% | Pass | diff --git a/doc/options.rst b/doc/options.rst index ca74bbd..8f9e064 100644 --- a/doc/options.rst +++ b/doc/options.rst @@ -695,6 +695,15 @@ Ambient fluid and prescribed motion different ``dtM`` with the same averaging time - lines with a LINE TYPES ``Cl > 0``, on every route - ``0.25 VIVrmsWindow`` + * - ``VIVphaseSeed`` / ``viv_phase_seed`` + - mode + - ``spread`` + - the initial phases of the VIV synchronisation model (:ref:`theory-viv`): ``spread`` starts + node ``i`` from the deck's End A at ``2 pi i/N``, as MoorDyn-C; ``zero`` starts every node + at zero, where MoorDyn-F in OpenFAST v5.0.0 starts (it does not carry its seed into its + state). Where two lock-in branches coexist, the seed selects the one a run settles on + - lines with a LINE TYPES ``Cl > 0``, on every route + - ``zero VIVphaseSeed`` * - ``motionFile`` - path, or ``0``/``none`` - absent diff --git a/doc/theory.rst b/doc/theory.rst index 6db8e69..a16eb76 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -1037,10 +1037,24 @@ so the shedding frequency locks onto the cross-flow motion inside the band :math:`c_F \pm d_F` (MoorDyn's defaults 0.18 and 0.08). The running RMS values average over ``VIVrmsWindow`` seconds, by default 500 steps of ``dtM`` as in MoorDyn, and the phases start at :math:`2\pi i/N` at node :math:`i` counted from the deck's End A (MoorDyn's ``AttachA``), also on -a line written anchor first, which the reader turns around. On a finite-EI mesh the static solve +a line written anchor first, which the reader turns around. The node tangent +:math:`\mathbf{q}` runs from End A to End B, as MoorDyn's; turning it around turns the lift and +:math:`\mathbf{e}` around and starts the same motion from the phases plus :math:`\pi`, with +:math:`\phi_{\dot y} = \pi` instead of MoorDyn's 0 at a node with no cross-flow motion (a node at +rest, a held end of a finite-EI cable), so the lift field is MoorDyn's whichever end a deck writes +first. On a finite-EI mesh the static solve refined, the deck nodes keep that seed and the nodes it added take the seed interpolated in arc length. The static equilibrium carries no lift. +The seed decides which response the line settles on where two lock-in branches coexist. On the +taut line of the ``viv_lockin`` test, between about 0.21 and 0.33 m/s, a line locked in its first +mode at 0.18 m/s stays there as the current rises, while one coming down from 0.30 m/s stays in +its second mode, and a start from rest with the spread seed settles in the second mode, as +MoorDyn-C does. MoorDyn-F (v2.3.8, OpenFAST v5.0.0) sets the same seed but does not copy it into +the state vector it integrates, so its phases start at zero at every node: a uniform lift that +excites the symmetric modes only, after which the line stays in its first mode. ``zero +VIVphaseSeed`` starts every phase at zero, and reproduces that start (:doc:`options`). + In the implicit step the phase is explicit: the step's lift takes the phase predicted at :math:`t_{n+1}` from the step-start rate (the generalised-α level under the configuration blend), and the committed phase is the trapezoidal (Heun) update with the rate at the converged end diff --git a/python/cabledyn/deck_file.py b/python/cabledyn/deck_file.py index 5ba451b..0b77789 100644 --- a/python/cabledyn/deck_file.py +++ b/python/cabledyn/deck_file.py @@ -231,6 +231,8 @@ "max_strain": "max_strain", "vivrmswindow": "viv_rms_window", "viv_rms_window": "viv_rms_window", + "vivphaseseed": "viv_phase_seed", + "viv_phase_seed": "viv_phase_seed", "rangestart": "range_start", "range_start": "range_start", "waveseed": "wave_seed", @@ -1332,6 +1334,8 @@ def _scalar_option_value_error(keyword: str, value: str) -> str | None: if _strip_quotes(value).lower() in _STATICS_FRICTION_POLICIES else "must be asLaid or none" ) + if key == "viv_phase_seed": + return None if value.lower() in {"spread", "zero"} else "must be spread or zero" if key == "cable_statics": return ( None diff --git a/python/tests/test_deck_file.py b/python/tests/test_deck_file.py index 25fea08..a56793f 100644 --- a/python/tests/test_deck_file.py +++ b/python/tests/test_deck_file.py @@ -672,6 +672,8 @@ def test_unknown_scalar_option_keyword_fails_closed(tmp_path): ("1.5", "wave_seed", "must be an integer"), ("maybe", "alpha_force_blend", "native boolean"), ("relaxed", "cable_statics", "continuation or sequenced"), + ("random", "VIVphaseSeed", "spread or zero"), + ("0", "viv_phase_seed", "spread or zero"), ("nodal", "OutputRecovery", "equilibrium or element"), ("on", "ei0_substeps", "auto or off"), ("0", "ei0_substep_angle", r"\(0, 90\] deg"), @@ -756,6 +758,8 @@ def test_zero_e_exponent_waterkin_mode_is_accepted(tmp_path): "True alphaForceBlend", "continuation cable_statics", "sequenced cableStatics", + "spread VIVphaseSeed", + "ZERO viv_phase_seed", "equilibrium OutputRecovery", "element output_recovery", "auto ei0_substeps", diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index 0e6abd5..ed2473d 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -670,6 +670,9 @@ MODULE CableDyn_DeckDriver ! of the synchronisation model (OPTION VIVrmsWindow, s); 0 = MoorDyn's 500 time steps of ! the deck dtM (or of the run's step when the deck has none). REAL(wp) :: viv_rms_window = CD_ZERO + ! Initial VIV phases (OPTION VIVphaseSeed): spread over a turn along the line, 2 pi i/N + ! (MoorDyn-C), or zero at every node (.TRUE.: the start of MoorDyn-F in OpenFAST v5.0.0). + LOGICAL :: viv_phase_zero = .FALSE. ! WaveKinMod-1 elevation history reduced once to its real Fourier components. ! These feed the same arbitrary-component Airy kernel used by reference-matched ! irregular seas; wave_eta_mean carries the zero-frequency waterline offset. @@ -3468,27 +3471,23 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, ! coupled host may step the cable at a dt adjusted from the deck dtM. IF (PRESENT(install_viv)) THEN IF (install_viv .AND. ANY(viv_cl > CD_ZERO)) THEN - ! MoorDyn's phase seed counts the deck nodes from the deck's End A: a line the reader turned - ! around, or a mesh the static solve refined, takes it node by node (CD_Deck_VIV_Phase_Seed) - IF (ln%ends_swapped .OR. ALLOCATED(cable%deck_node)) THEN - BLOCK - REAL(wp) :: seed(cable%line%nn) - IF (ALLOCATED(cable%deck_node)) THEN - CALL CD_Deck_VIV_Phase_Seed(cable%line%l0, ln%ends_swapped, seed, deck_map=cable%deck_node) - ELSE - CALL CD_Deck_VIV_Phase_Seed(cable%line%l0, ln%ends_swapped, seed) - END IF - CALL CD_HermiteCable_Dyn_Set_VIV(cable%line, viv_cl, viv_cf, viv_df, & - MERGE(opts%viv_rms_window, & - REAL(CD_VIV_NM, wp)*MERGE(opts%dtM, dt, opts%has_dtm), & - opts%viv_rms_window > CD_ZERO), es, em, phase_seed=seed) - END BLOCK - ELSE + ! MoorDyn's phase seed counts the deck nodes from the deck's End A, with its node tangent + ! towards the deck's End B; CD_Deck_VIV_Phase_Seed takes it node by node to the model's + ! nodes and tangent (a line the reader turned around, a mesh the static solve refined) + BLOCK + REAL(wp) :: seed(cable%line%nn) + IF (ALLOCATED(cable%deck_node)) THEN + CALL CD_Deck_VIV_Phase_Seed(cable%line%l0, ln%ends_swapped, seed, deck_map=cable%deck_node, & + zero=opts%viv_phase_zero) + ELSE + CALL CD_Deck_VIV_Phase_Seed(cable%line%l0, ln%ends_swapped, seed, zero=opts%viv_phase_zero) + END IF CALL CD_HermiteCable_Dyn_Set_VIV(cable%line, viv_cl, viv_cf, viv_df, & MERGE(opts%viv_rms_window, & REAL(CD_VIV_NM, wp)*MERGE(opts%dtM, dt, opts%has_dtm), & - opts%viv_rms_window > CD_ZERO), es, em) - END IF + opts%viv_rms_window > CD_ZERO), es, em, phase_seed=seed, & + tangent_reversed=.NOT. ln%ends_swapped) + END BLOCK IF (es /= CD_HCDYN_OK) THEN CALL fail(ErrStat, ErrMsg, 'finite-EI cable VIV wiring failed: '//TRIM(em)); RETURN END IF @@ -10379,17 +10378,14 @@ RECURSIVE SUBROUTINE init_one_model(ln, sections, types, points, opts, model, Er END IF tw = opts%viv_rms_window IF (.NOT. (tw > CD_ZERO) .AND. opts%has_dtm) tw = REAL(CD_VIV_NM, wp)*opts%dtM - IF (ln%ends_swapped) THEN - ! MoorDyn counts the phase seed from the deck's End A, which the reader turned around - BLOCK - REAL(wp) :: seed(model%n_dof/3) - CALL CD_Deck_VIV_Phase_Seed(model%l0, .TRUE., seed) - CALL CD_Set_Model_VIV(model, opts%rhoW, viv_cl, hydro_diam, viv_cf, viv_df, tw, es, em, & - phase_seed=seed) - END BLOCK - ELSE - CALL CD_Set_Model_VIV(model, opts%rhoW, viv_cl, hydro_diam, viv_cf, viv_df, tw, es, em) - END IF + ! MoorDyn counts the phase seed from the deck's End A, with its node tangent towards the + ! deck's End B: CD_Deck_VIV_Phase_Seed takes it to the model's nodes and tangent + BLOCK + REAL(wp) :: seed(model%n_dof/3) + CALL CD_Deck_VIV_Phase_Seed(model%l0, ln%ends_swapped, seed, zero=opts%viv_phase_zero) + CALL CD_Set_Model_VIV(model, opts%rhoW, viv_cl, hydro_diam, viv_cf, viv_df, tw, es, em, & + phase_seed=seed, tangent_reversed=.NOT. ln%ends_swapped) + END BLOCK IF (es == CD_MODEL_OK) CALL CD_Recompute_Model_Acceleration(model, es, em) IF (es /= CD_MODEL_OK) THEN ErrStat = CD_DECKDRV_BADINPUT @@ -17154,6 +17150,15 @@ SUBROUTINE apply_option(line, opts, ErrStat, ErrMsg) opts%has_max_strain = ErrStat == 0 CASE ('vivrmswindow', 'viv_rms_window') CALL rd(val, opts%viv_rms_window, ErrStat, ErrMsg) + CASE ('vivphaseseed', 'viv_phase_seed') + SELECT CASE (TRIM(to_lower(val))) + CASE ('spread') + opts%viv_phase_zero = .FALSE. + CASE ('zero') + opts%viv_phase_zero = .TRUE. + CASE DEFAULT + CALL fail(ErrStat, ErrMsg, 'OPTION VIVphaseSeed "'//TRIM(val)//'" must be spread or zero') + END SELECT CASE ('streamorder', 'stream_order') CALL rd(val, x1, ErrStat, ErrMsg) IF (ErrStat == 0) THEN @@ -38492,18 +38497,25 @@ PURE FUNCTION cable_frame_is_identity(cable) RESULT(is_id) is_id = .NOT. (ABS(cable%frame_s) > CD_ZERO .OR. ABS(cable%frame_c - CD_ONE) > CD_ZERO) END FUNCTION cable_frame_is_identity - PURE SUBROUTINE CD_Deck_VIV_Phase_Seed(l0, swapped, seed, deck_map) - !! MoorDyn's VIV phase seed i/N (phase 2 pi i/N at node i = 0 .. N counted from the deck's - !! End A, N the deck's segment count) at every node of a line, in the model's internal node - !! order (node 1 = End B; element e of length l0(e) joins nodes e and e + 1). swapped: the - !! reader turned the line's ends around, so the deck's End A is the model's End B. deck_map: - !! the mesh node (public order, End A = 1) of each deck node, on a mesh the static solve - !! refined; a node it added takes the seed interpolated linearly in unstretched arc length - !! between its two deck nodes. Without deck_map the mesh is the deck's. + PURE SUBROUTINE CD_Deck_VIV_Phase_Seed(l0, swapped, seed, deck_map, zero) + !! The initial VIV phase of every node of a line, as a fraction of a turn, in the model's + !! internal node order (node 1 = End B; element e of length l0(e) joins nodes e and e + 1). + !! + !! MoorDyn starts node i = 0 .. N (counted from the deck's End A, N the deck's segment count) + !! at the phase 2 pi i/N (MoorDyn-C; zero: at 0, where MoorDyn-F in OpenFAST v5.0.0 starts), + !! with the node tangent from the deck's End A to its End B. The model's node tangent runs from + !! its End B to its End A: against MoorDyn's on a line the reader keeps in the deck order, + !! which turns the lift and the cross-flow axis of the synchronisation model around. That + !! motion is MoorDyn's from the phases half a turn on, which the seed adds there; a line the + !! reader turned around (swapped: the deck's End A is the model's End B) has MoorDyn's tangent. + !! deck_map: the mesh node (public order, End A = 1) of each deck node, on a mesh the static + !! solve refined; a node it added takes the seed interpolated linearly in unstretched arc + !! length between its two deck nodes. Without deck_map the mesh is the deck's. REAL(wp), INTENT(IN) :: l0(:) LOGICAL, INTENT(IN) :: swapped REAL(wp), INTENT(OUT) :: seed(:) INTEGER, INTENT(IN), OPTIONAL :: deck_map(:) + LOGICAL, INTENT(IN), OPTIONAL :: zero REAL(wp) :: s(SIZE(l0) + 1), f INTEGER :: ne, nn, p, j, nd ne = SIZE(l0) @@ -38527,6 +38539,10 @@ PURE SUBROUTINE CD_Deck_VIV_Phase_Seed(l0, swapped, seed, deck_map) f = REAL(p - 1, wp)/REAL(ne, wp) END IF IF (swapped) f = CD_ONE - f + IF (PRESENT(zero)) THEN + IF (zero) f = CD_ZERO + END IF + IF (.NOT. swapped) f = MODULO(f + 0.5_wp, CD_ONE) seed(nn + 1 - p) = f END DO END SUBROUTINE CD_Deck_VIV_Phase_Seed diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index 9889a04..2a209e5 100644 --- a/src/CableDyn_HermiteCableDynamic.f90 +++ b/src/CableDyn_HermiteCableDynamic.f90 @@ -311,6 +311,9 @@ MODULE CableDyn_HermiteCableDynamic ! the Newton iterations); viv_rate/viv_ms_next hold the step-start rate and mean squares. ! ws_recovery_viv_* is the step-start copy of the recovering step, snap_viv_* the snapshot. LOGICAL :: has_viv = .FALSE. + ! the cross-flow phase of a node at rest (CD_VIV_Phase_Rate phi_rest): pi where the material + ! tangent runs against MoorDyn's node tangent (End B to End A), 0 where it runs as MoorDyn's + REAL(wp) :: viv_phi_rest = CD_ZERO REAL(wp) :: viv_tw = CD_ZERO REAL(wp), ALLOCATABLE :: viv_cl(:), viv_k(:), viv_cf(:), viv_df(:), viv_dn(:), viv_phi(:), viv_phi_eval(:) REAL(wp), ALLOCATABLE :: viv_rate(:), viv_ms(:, :), viv_ms_next(:, :) @@ -1053,14 +1056,17 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_Friction_Axial(model, mu_axial, ErrStat, ErrM model%tr_valid = .FALSE. END SUBROUTINE CD_HermiteCable_Dyn_Set_Friction_Axial - SUBROUTINE CD_HermiteCable_Dyn_Set_VIV(model, cl, cf, df, t_window, ErrStat, ErrMsg, phase_seed) + SUBROUTINE CD_HermiteCable_Dyn_Set_VIV(model, cl, cf, df, t_window, ErrStat, ErrMsg, phase_seed, tangent_reversed) !! Install the cross-flow VIV synchronisation model (CableDyn_VIV) on a cable with drag: !! per element the lift coefficient cl (0: no lift) and the band df/cf, and the running-RMS !! averaging time t_window (s; 0 takes 500 steps of the first step). The lift acts on the !! wetted Gauss points of every element with cl > 0, its phase lives on the nodes of those - !! elements; the phases start at 2 pi i/N with node i counted from End A (the model's last - !! node), as MoorDyn's; phase_seed, per node, gives i/N instead (a line whose deck End A the - !! reader turned around, or a mesh refined from the deck's). The phase follows the drag's + !! elements; MoorDyn starts node i counted from End A (the model's last node) at 2 pi i/N + !! with its tangent from End A to End B, and the material tangent here runs from End B to + !! End A, which turns the lift and the cross-flow axis around: the phases start at 2 pi i/N + !! plus pi, the same motion. phase_seed, per node, gives the start as a fraction of a turn + !! instead (CD_Deck_VIV_Phase_Seed: a line whose deck End A the reader turned around, a mesh + !! refined from the deck's). The phase follows the drag's !! current and a held host field (which !! carries any waves); the internal wave families (Set_Waves) are refused with VIV. The !! committed acceleration is refreshed with the new lift; if that fails, the previous VIV @@ -1070,8 +1076,12 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_VIV(model, cl, cf, df, t_window, ErrStat, Err INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg REAL(wp), INTENT(IN), OPTIONAL :: phase_seed(:) + !! tangent_reversed (default .TRUE., the model's own seed): the material tangent runs against + !! MoorDyn's node tangent, so a node at rest takes the cross-flow phase pi + !! (CD_VIV_Phase_Rate phi_rest); .FALSE. on a line the reader turned around + LOGICAL, INTENT(IN), OPTIONAL :: tangent_reversed INTEGER :: e, k, ne, nn, es - REAL(wp) :: wsum, tw_old + REAL(wp) :: wsum, tw_old, rest_old LOGICAL :: had_viv CHARACTER(LEN=1024) :: em REAL(wp), ALLOCATABLE :: a_old(:), o_cl(:), o_k(:), o_cf(:), o_df(:), o_dn(:), o_phi(:), o_phi_eval(:), & @@ -1117,6 +1127,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_VIV(model, cl, cf, df, t_window, ErrStat, Err ! the previous configuration and state, kept for a rollback had_viv = model%has_viv tw_old = model%viv_tw + rest_old = model%viv_phi_rest a_old = model%a CALL move_viv(.FALSE.) CALL clear_viv(model) @@ -1152,7 +1163,7 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_VIV(model, cl, cf, df, t_window, ErrStat, Err model%viv_node(k) = .TRUE. model%viv_cf(k) = model%viv_cf(k)/wsum model%viv_df(k) = model%viv_df(k)/wsum - model%viv_phi(k) = CD_VIV_TWO_PI*REAL(nn - k, wp)/REAL(ne, wp) + model%viv_phi(k) = CD_VIV_Wrap_Phase(CD_VIV_TWO_PI*(REAL(nn - k, wp)/REAL(ne, wp) + 0.5_wp)) IF (PRESENT(phase_seed)) model%viv_phi(k) = CD_VIV_TWO_PI*phase_seed(k) END DO CALL viv_drag_terms(model) @@ -1164,6 +1175,8 @@ SUBROUTINE CD_HermiteCable_Dyn_Set_VIV(model, cl, cf, df, t_window, ErrStat, Err model%snap_viv_phi = model%viv_phi model%snap_viv_ms = model%viv_ms model%viv_tw = t_window + model%viv_phi_rest = 0.5_wp*CD_VIV_TWO_PI + IF (PRESENT(tangent_reversed)) model%viv_phi_rest = MERGE(0.5_wp*CD_VIV_TWO_PI, CD_ZERO, tangent_reversed) model%has_viv = .TRUE. ! the lift is part of the committed load: the first step's predictor and phase rate need it CALL refresh_acceleration() @@ -1176,6 +1189,7 @@ SUBROUTINE refresh_acceleration() CALL move_viv(.TRUE.) model%has_viv = had_viv model%viv_tw = tw_old + model%viv_phi_rest = rest_old model%a = a_old ErrStat = es ErrMsg = 'CD_HermiteCable_Dyn_Set_VIV: acceleration refresh failed: '//TRIM(em) @@ -1287,7 +1301,7 @@ SUBROUTINE viv_step_begin(model, dt, ErrStat, ErrMsg) model%viv_ms_next(2, k) = CD_VIV_Mean_Square(model%viv_ms(2, k), ydd, w) model%viv_rate(k) = CD_VIV_Phase_Rate(yd, ydd, model%viv_ms_next(1, k), model%viv_ms_next(2, k), & model%viv_phi(k), un, model%viv_dn(k), model%viv_cf(k), & - model%viv_df(k)) + model%viv_df(k), phi_rest=model%viv_phi_rest) model%viv_phi_eval(k) = model%viv_phi(k) + frac*dt*model%viv_rate(k) END DO END SUBROUTINE viv_step_begin @@ -1313,7 +1327,7 @@ SUBROUTINE viv_step_end(model, dt) ms1 = CD_VIV_Mean_Square(model%viv_ms_next(1, k), yd, w) ms2 = CD_VIV_Mean_Square(model%viv_ms_next(2, k), ydd, w) rate_end = CD_VIV_Phase_Rate(yd, ydd, ms1, ms2, model%viv_phi(k) + dt*model%viv_rate(k), un, & - model%viv_dn(k), model%viv_cf(k), model%viv_df(k)) + model%viv_dn(k), model%viv_cf(k), model%viv_df(k), phi_rest=model%viv_phi_rest) IF (.NOT. CD_Is_Finite(rate_end)) rate_end = model%viv_rate(k) END IF model%viv_phi(k) = CD_VIV_Wrap_Phase(model%viv_phi(k) + 0.5_wp*dt*(model%viv_rate(k) + rate_end)) diff --git a/src/CableDyn_Model.f90 b/src/CableDyn_Model.f90 index 683b18b..166b2e8 100644 --- a/src/CableDyn_Model.f90 +++ b/src/CableDyn_Model.f90 @@ -398,6 +398,9 @@ MODULE CableDyn_Model ! explicit generalised-alpha force-level phase. viv_rate (phase rate at the step start) ! and viv_ms_next (mean squares updated with the step-start sample) are step scratch. LOGICAL :: has_viv = .FALSE. + ! the cross-flow phase of a node at rest (CD_VIV_Phase_Rate phi_rest): pi where the node + ! tangent runs against MoorDyn's (End B to End A), 0 where it runs as MoorDyn's + REAL(wp) :: viv_phi_rest = CD_ZERO REAL(wp) :: viv_rho = CD_ZERO REAL(wp) :: viv_tw = CD_ZERO REAL(wp), ALLOCATABLE :: viv_cl(:), viv_d(:) @@ -3879,16 +3882,20 @@ SUBROUTINE CD_Set_Model_Syrope_State(model, slow, tmax, ErrStat, ErrMsg) model%recovery_syrope_tmax = tmax END SUBROUTINE CD_Set_Model_Syrope_State - SUBROUTINE CD_Set_Model_VIV(model, rho, cl, diameter, cf, df, t_window, ErrStat, ErrMsg, phase_seed) + SUBROUTINE CD_Set_Model_VIV(model, rho, cl, diameter, cf, df, t_window, ErrStat, ErrMsg, phase_seed, & + tangent_reversed) !! Install the cross-flow VIV model (CableDyn_VIV) on an initialised line. Per element: !! the lift coefficient cl (0 = no lift on that element), hydrodynamic diameter, and the !! synchronisation centre cf and half-range df of the non-dimensional shedding frequency; !! rho is the water density. t_window is the averaging time of the running cross-flow mean !! squares [s]; 0 selects MoorDyn's 500 time steps, latched at the first step. Every !! interior free node next to a cl > 0 element carries a lift and a shedding phase; the - !! phases start spread over [0, 2 pi) along the line (MoorDyn-C: 2 pi i/N at node i from - !! End A; phase_seed, per node, gives i/N instead, for a line whose deck End A the reader - !! turned around), the mean squares at 0. + !! phases start spread over [0, 2 pi) along the line, the mean squares at 0. MoorDyn-C starts + !! node i from End A at 2 pi i/N with the node tangent from End A to End B; the model's + !! tangent (from node nd - 1 to nd + 1) runs from End B to End A, which turns the lift and the + !! cross-flow axis around, so the same motion starts here from those phases plus pi. + !! phase_seed, per node, gives the start as a fraction of a turn instead + !! (CD_Deck_VIV_Phase_Seed: a line whose deck End A the reader turned around, a refined mesh). !! An all-zero cl leaves the model without VIV. The mesh must be the single chain the !! line builder makes (element e joins nodes e and e+1). The caller recomputes the !! acceleration if the lift should enter the initial state (CD_Recompute_Model_Acceleration). @@ -3897,6 +3904,10 @@ SUBROUTINE CD_Set_Model_VIV(model, rho, cl, diameter, cf, df, t_window, ErrStat, INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg REAL(wp), INTENT(IN), OPTIONAL :: phase_seed(:) + !! tangent_reversed (default .TRUE., the model's own seed): the node tangent runs against + !! MoorDyn's, so a node at rest takes the cross-flow phase pi (CD_VIV_Phase_Rate phi_rest); + !! .FALSE. on a line the reader turned around, whose tangent runs as MoorDyn's + LOGICAL, INTENT(IN), OPTIONAL :: tangent_reversed INTEGER :: n_nodes, n_elem, e, nd, istat, k REAL(wp) :: wsum @@ -3993,8 +4004,9 @@ SUBROUTINE CD_Set_Model_VIV(model, rho, cl, diameter, cf, df, t_window, ErrStat, model%viv_df(nd) = model%viv_df(nd)/wsum model%viv_dn(nd) = model%viv_dn(nd)/wsum ! MoorDyn: phi_i = 2 pi i/N at node i = 0..N counted from End A; the model numbers its - ! nodes from End B, so node nd is MoorDyn's node n_nodes - nd - model%viv_phi(nd) = CD_VIV_TWO_PI*REAL(n_nodes - nd, wp)/REAL(n_elem, wp) + ! nodes from End B, so node nd is MoorDyn's node n_nodes - nd, and its tangent runs the other + ! way (half a turn on) + model%viv_phi(nd) = CD_VIV_Wrap_Phase(CD_VIV_TWO_PI*(REAL(n_nodes - nd, wp)/REAL(n_elem, wp) + 0.5_wp)) IF (PRESENT(phase_seed)) model%viv_phi(nd) = CD_VIV_TWO_PI*phase_seed(nd) END DO model%viv_phi_rollback = model%viv_phi @@ -4005,6 +4017,8 @@ SUBROUTINE CD_Set_Model_VIV(model, rho, cl, diameter, cf, df, t_window, ErrStat, model%viv_rate = CD_ZERO model%viv_ms_next = model%viv_ms model%viv_phi_next = model%viv_phi + model%viv_phi_rest = 0.5_wp*CD_VIV_TWO_PI + IF (PRESENT(tangent_reversed)) model%viv_phi_rest = MERGE(0.5_wp*CD_VIV_TWO_PI, CD_ZERO, tangent_reversed) model%has_viv = .TRUE. END SUBROUTINE CD_Set_Model_VIV @@ -4191,7 +4205,7 @@ SUBROUTINE viv_step_begin(model, dt, ErrStat, ErrMsg) model%viv_ms_next(2, nd) = CD_VIV_Mean_Square(model%viv_ms(2, nd), ydd, w) model%viv_rate(nd) = CD_VIV_Phase_Rate(yd, ydd, model%viv_ms_next(1, nd), model%viv_ms_next(2, nd), & model%viv_phi(nd), un, model%viv_dn(nd), model%viv_cf(nd), & - model%viv_df(nd)) + model%viv_df(nd), phi_rest=model%viv_phi_rest) model%viv_phi_eval(nd) = model%viv_phi(nd) + (CD_ONE - alpha_f)*dt*model%viv_rate(nd) END DO END SUBROUTINE viv_step_begin @@ -4225,7 +4239,7 @@ SUBROUTINE viv_step_end(model, dt, ErrStat, ErrMsg) ms1 = CD_VIV_Mean_Square(model%viv_ms_next(1, nd), yd, w) ms2 = CD_VIV_Mean_Square(model%viv_ms_next(2, nd), ydd, w) rate_end = CD_VIV_Phase_Rate(yd, ydd, ms1, ms2, model%viv_phi(nd) + dt*model%viv_rate(nd), un, & - model%viv_dn(nd), model%viv_cf(nd), model%viv_df(nd)) + model%viv_dn(nd), model%viv_cf(nd), model%viv_df(nd), phi_rest=model%viv_phi_rest) model%viv_phi_next(nd) = CD_VIV_Wrap_Phase(model%viv_phi(nd) + 0.5_wp*dt*(model%viv_rate(nd) + rate_end)) END DO IF (.NOT. CD_All_Finite(model%viv_phi_next)) THEN @@ -6872,6 +6886,7 @@ SUBROUTINE CD_Copy_Model(src, dst, ErrStat, ErrMsg) work%has_viv = src%has_viv work%viv_rho = src%viv_rho work%viv_tw = src%viv_tw + work%viv_phi_rest = src%viv_phi_rest CALL copy_real1(src%viv_cl, work%viv_cl, 'viv_cl', ErrStat, ErrMsg) IF (ErrStat /= CD_MODEL_OK) RETURN CALL copy_real1(src%viv_d, work%viv_d, 'viv_d', ErrStat, ErrMsg) @@ -7043,6 +7058,7 @@ SUBROUTINE commit_model_copy(from, to) to%has_viv = from%has_viv to%viv_rho = from%viv_rho to%viv_tw = from%viv_tw + to%viv_phi_rest = from%viv_phi_rest CALL MOVE_ALLOC(from%viv_cl, to%viv_cl) CALL MOVE_ALLOC(from%viv_d, to%viv_d) CALL MOVE_ALLOC(from%viv_node, to%viv_node) diff --git a/src/CableDyn_VIV.f90 b/src/CableDyn_VIV.f90 index 27d742c..94a60ae 100644 --- a/src/CableDyn_VIV.f90 +++ b/src/CableDyn_VIV.f90 @@ -268,15 +268,19 @@ ELEMENTAL REAL(wp) FUNCTION CD_VIV_Mean_Square(ms_old, sample, weight) RESULT(ms ms = ms_old + weight*(sample*sample - ms_old) END FUNCTION CD_VIV_Mean_Square - PURE REAL(wp) FUNCTION CD_VIV_Phase_Rate(yd, ydd, ms_yd, ms_ydd, phi, un, diameter, cf, df) RESULT(rate) + PURE REAL(wp) FUNCTION CD_VIV_Phase_Rate(yd, ydd, ms_yd, ms_ydd, phi, un, diameter, cf, df, phi_rest) RESULT(rate) !! Rate of the vortex-shedding phase, d phi/dt = 2 pi (cF + dF sin(phi_y - phi)) U/D, with !! the cross-flow velocity phase phi_y from y', y'' normalised by their running RMS values - !! (unnormalised while either mean square is still zero, as MoorDyn). + !! (unnormalised while either mean square is still zero, as MoorDyn). A node with no + !! cross-flow motion takes phi_y = phi_rest (default 0, MoorDyn's): pi on a line whose node + !! tangent runs against MoorDyn's, which turns the cross-flow axis and so phi_y half a turn. REAL(wp), INTENT(IN) :: yd, ydd, ms_yd, ms_ydd, phi, un, diameter, cf, df + REAL(wp), INTENT(IN), OPTIONAL :: phi_rest REAL(wp) :: phi_y IF (.NOT. (ABS(yd) > CD_ZERO .OR. ABS(ydd) > CD_ZERO)) THEN phi_y = CD_ZERO + IF (PRESENT(phi_rest)) phi_y = phi_rest ELSE IF (ms_yd > CD_ZERO .AND. ms_ydd > CD_ZERO) THEN phi_y = ATAN2(-ydd/SQRT(ms_ydd), yd/SQRT(ms_yd)) ELSE diff --git a/tests/test_hermite_viv.f90 b/tests/test_hermite_viv.f90 index a1c7588..698b453 100644 --- a/tests/test_hermite_viv.f90 +++ b/tests/test_hermite_viv.f90 @@ -73,8 +73,10 @@ PROGRAM test_hermite_viv CALL require(es == CD_HCDYN_OK .AND. model%has_viv, 'VIV installs again: '//TRIM(em)) CALL CD_HermiteCable_Dyn_Set_Waves(model, 1.0_wp, 8.0_wp, 0.0_wp, 100.0_wp, GACC, es, em) CALL require(es /= CD_HCDYN_OK, 'an internal wave family is refused on a VIV cable') - CALL require(ABS(model%viv_phi(NE + 1)) < TINY(1.0_wp) .AND. & - ABS(model%viv_phi(1) - 2.0_wp*PI) < 1.0e-12_wp, 'the phases start at 2 pi i/N from End A') + ! MoorDyn's 2 pi i/N from End A, half a turn on: the material tangent runs from End B to End A + CALL require(ABS(model%viv_phi(NE + 1) - PI) < 1.0e-12_wp .AND. & + ABS(model%viv_phi(NE) - (2.0_wp*PI/REAL(NE, wp) + PI)) < 1.0e-12_wp .AND. & + ABS(model%viv_phi(1) - PI) < 1.0e-12_wp, 'the phases start at 2 pi i/N + pi from End A') ! a drag reconfigured after VIV carries the lift scale and shedding diameter with it BLOCK REAL(wp) :: k0(NE), dn0(NE + 1), hd(NE), hn(NE), ht(NE) diff --git a/tests/test_viv_coupled.f90 b/tests/test_viv_coupled.f90 index 9272a1b..fd9aa2a 100644 --- a/tests/test_viv_coupled.f90 +++ b/tests/test_viv_coupled.f90 @@ -175,14 +175,21 @@ SUBROUTINE case_seed() INTEGER :: k, pass, nn ! four mesh elements (internal order, End B first) over two deck segments: deck nodes at public ! nodes 1, 3 and 5, so public node 4 lies 3/4 of the way along the second deck segment + ! MoorDyn's i/N from the deck's End A; a line kept in the deck order adds half a turn, as the + ! model's tangent runs against MoorDyn's there (End B to End A) CALL CD_Deck_VIV_Phase_Seed([1.0_wp, 3.0_wp, 2.0_wp, 2.0_wp], .FALSE., seed, deck_map=[1, 3, 5]) want = [1.0_wp, 0.875_wp, 0.5_wp, 0.25_wp, 0.0_wp] - CALL check(MAXVAL(ABS(seed - want)) < 1.0e-14_wp, 'refined mesh: deck nodes keep i/N, added nodes interpolate') + CALL check(MAXVAL(ABS(seed - MODULO(want + 0.5_wp, 1.0_wp))) < 1.0e-14_wp, & + 'refined mesh: deck nodes keep i/N, added nodes interpolate') CALL CD_Deck_VIV_Phase_Seed([1.0_wp, 3.0_wp, 2.0_wp, 2.0_wp], .TRUE., seed, deck_map=[1, 3, 5]) CALL check(MAXVAL(ABS(seed - (1.0_wp - want))) < 1.0e-14_wp, 'a turned-around line counts from its End B') CALL CD_Deck_VIV_Phase_Seed([1.0_wp, 3.0_wp, 2.0_wp, 2.0_wp], .FALSE., seed) - CALL check(MAXVAL(ABS(seed - [4.0_wp, 3.0_wp, 2.0_wp, 1.0_wp, 0.0_wp]/4.0_wp)) < 1.0e-14_wp, & + CALL check(MAXVAL(ABS(seed - [0.5_wp, 0.25_wp, 0.0_wp, 0.75_wp, 0.5_wp])) < 1.0e-14_wp, & 'the deck mesh keeps the node index') + CALL CD_Deck_VIV_Phase_Seed([1.0_wp, 3.0_wp, 2.0_wp, 2.0_wp], .FALSE., seed, zero=.TRUE.) + CALL check(MAXVAL(ABS(seed - 0.5_wp)) < 1.0e-14_wp, 'zero: every phase at 0 in MoorDyn''s frame') + CALL CD_Deck_VIV_Phase_Seed([1.0_wp, 3.0_wp, 2.0_wp, 2.0_wp], .TRUE., seed, zero=.TRUE.) + CALL check(MAXVAL(ABS(seed)) < 1.0e-14_wp, 'zero on a turned-around line') CALL write_deck('viv_coupled_stock.dat', 0.8_wp, anchor_first=.TRUE.) DO pass = 1, 2 IF (.NOT. start(agg, MERGE('viv_coupled.dat ', 'viv_coupled_stock.dat', pass == 1))) RETURN @@ -191,12 +198,17 @@ SUBROUTINE case_seed() worst_cable = 0.0_wp DO k = 2, nn - 1 ! internal node k (k = 1 at the anchor); MoorDyn's node from the deck's End A + ! (half a turn on where the reader keeps the deck order: the model's tangent runs against + ! MoorDyn's there) IF (pass == 1) THEN - worst_line = MAX(worst_line, ABS(agg%sys%fast%system%lines(1)%viv_phi(k) - TWO_PI*REAL(nn - k, wp)/50.0_wp)) - worst_cable = MAX(worst_cable, ABS(agg%cables(1)%line%viv_phi(k) - TWO_PI*REAL(nn - k, wp)/50.0_wp)) + worst_line = MAX(worst_line, turn_gap(agg%sys%fast%system%lines(1)%viv_phi(k), & + TWO_PI*(REAL(nn - k, wp)/50.0_wp + 0.5_wp))) + worst_cable = MAX(worst_cable, turn_gap(agg%cables(1)%line%viv_phi(k), & + TWO_PI*(REAL(nn - k, wp)/50.0_wp + 0.5_wp))) ELSE - worst_line = MAX(worst_line, ABS(agg%sys%fast%system%lines(1)%viv_phi(k) - TWO_PI*REAL(k - 1, wp)/50.0_wp)) - worst_cable = MAX(worst_cable, ABS(agg%cables(1)%line%viv_phi(k) - TWO_PI*REAL(k - 1, wp)/50.0_wp)) + worst_line = MAX(worst_line, turn_gap(agg%sys%fast%system%lines(1)%viv_phi(k), & + TWO_PI*REAL(k - 1, wp)/50.0_wp)) + worst_cable = MAX(worst_cable, turn_gap(agg%cables(1)%line%viv_phi(k), TWO_PI*REAL(k - 1, wp)/50.0_wp)) END IF END DO CALL check(worst_line < 1.0e-12_wp .AND. worst_cable < 1.0e-12_wp, & @@ -206,6 +218,14 @@ SUBROUTINE case_seed() END DO END SUBROUTINE case_seed + PURE REAL(wp) FUNCTION turn_gap(a, b) RESULT(gap) + !! Distance of two phases on the circle (rad). + REAL(wp), INTENT(IN) :: a, b + REAL(wp), PARAMETER :: TWO_PI = 8.0_wp*ATAN(1.0_wp) + gap = MODULO(a - b, TWO_PI) + gap = MIN(gap, TWO_PI - gap) + END FUNCTION turn_gap + LOGICAL FUNCTION start(agg, path) RESULT(ok) !! A host-fluid build of the deck at dt. TYPE(CD_AGG_ModuleType), INTENT(INOUT) :: agg diff --git a/tests/test_viv_lockin.py b/tests/test_viv_lockin.py index 7c9fa78..10cab73 100644 --- a/tests/test_viv_lockin.py +++ b/tests/test_viv_lockin.py @@ -31,7 +31,15 @@ the same first-mode frequency and shape: at U 0.21 m/s both lock into mode 1 with the same mid-span A/D (within 3 %) and frequency; at U 0.30 m/s the string jumps to mode 2 (mid-span A/D below 0.15) while the cable, whose bending raises f2/f1 to about 3.15, stays in mode 1 - (mid-span A/D above 0.6). + (mid-span A/D above 0.6); +* at U 0.24 m/s, where two lock-in branches coexist, a start from rest with the spread seed settles + in mode 2 as MoorDyn-C v2.7.2 does (x/L 0.24 A/D within 3 % of its 0.574, frequency within one + bin of 0.527 Hz), and VIVphaseSeed zero in mode 1 as MoorDyn-F in OpenFAST v5.0.0 (whose phases + start at zero) does (mid-span A/D within 1 % of its 0.699, frequency within one bin of 0.353 Hz); +* the lift at t = 0, 2 m from the deck's End A, has MoorDyn's direction and size on an EI = 0 line + and a finite-EI cable, whether the deck writes the line fairlead first or anchor first, and the + mid-span response over 60 s is the same on both orders (within 1e-5 m: a node at rest takes + MoorDyn's cross-flow phase in the line's own frame). usage: test_viv_lockin.py --exe --output

""" @@ -54,6 +62,10 @@ FAILURES: list[str] = [] MD_AD, MD_F = 0.662, 0.293 # MoorDyn-F reference, mid span +# U 0.24 m/s, where two lock-in branches coexist (VALIDATION.md L2-VIV): MoorDyn-C v2.7.2 (seeded +# phases, RK2, dtM 5e-4 s) settles on mode 2 (quarter span x/L = 0.24: A/D 0.574 at 0.527 Hz); +# MoorDyn-F v2.3.8 (OpenFAST v5.0.0, phases from zero) on mode 1 (mid span: 0.699 at 0.353 Hz) +MDC_Q24, MDC_F24, MDF_M24, MDF_F24 = 0.574, 0.527, 0.699, 0.353 D = 0.1 @@ -151,24 +163,48 @@ def lift_std(path: Path) -> np.ndarray: return data[:, cols].std(axis=0) -def response(path: Path) -> tuple[float, float, float]: - """Mid A/D, mid peak frequency, quarter A/D over 60-120 s.""" +def response(path: Path, mid_name: str = "L1N25PZ", qtr_name: str = "L1N12PZ", + peak_of: str = "mid") -> tuple[float, float, float]: + """Mid A/D, peak frequency (of the mid or the quarter channel), quarter A/D over 60-120 s.""" hist = read_output(path) t = hist.column(hist.channels[0]) mask = t >= 60.0 - mid = hist.column("L1N25PZ")[mask] - qtr = hist.column("L1N12PZ")[mask] + mid = hist.column(mid_name)[mask] + qtr = hist.column(qtr_name)[mask] mid = mid - mid.mean() + qtr = qtr - qtr.mean() + sig = mid if peak_of == "mid" else qtr dt = float(np.median(np.diff(t[mask]))) - freq = np.fft.rfftfreq(mid.size, dt) - power = np.abs(np.fft.rfft(mid * np.hanning(mid.size))) + freq = np.fft.rfftfreq(sig.size, dt) + power = np.abs(np.fft.rfft(sig * np.hanning(sig.size))) return ( math.sqrt(2.0) * float(mid.std()) / D, float(freq[1:][np.argmax(power[1:])]), - math.sqrt(2.0) * float((qtr - qtr.mean()).std()) / D, + math.sqrt(2.0) * float(qtr.std()) / D, ) +def anchor_first(text: str) -> str: + """The deck with End A on the fixed point at x = 0 and End B on the coupled point at x = 100 m: + the same line, which the reader turns around (it runs from its fairlead).""" + a, b = "1 Coupled 0.0 0.0 -50.0", "2 Fixed 100.0 0.0 -50.0" + assert a in text and b in text + return text.replace(a, "1 Fixed 0.0 0.0 -50.0").replace(b, "2 Coupled 100.0 0.0 -50.0") + + +def first_lift(path: Path, x: float, from_end_b: bool) -> float: + """Cross-flow (z) lift at t = 0 at the node nearest x of a V file (public node order).""" + lines = path.read_text().splitlines() + head = next(i for i, line in enumerate(lines) if line.startswith("Time")) + names = lines[head].split("\t") + row = np.array([float(v) for v in lines[head + 1].split()]) + cols = [j for j, name in enumerate(names) if name.endswith("LiftZ(N)")] + xs = np.linspace(0.0, 100.0, len(cols)) + if from_end_b: + xs = xs[::-1] + return float(row[cols[int(np.argmin(np.abs(xs - x)))]]) + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--exe", required=True) @@ -178,7 +214,7 @@ def main() -> int: if out.exists(): shutil.rmtree(out) out.mkdir(parents=True) - for u in (0.15, 0.21, 0.30): + for u in (0.15, 0.21, 0.24, 0.30): (out / f"current_{u}.dat").write_text(CURRENT.format(u=u), encoding="utf-8", newline="\n") # the stiff cable and its equal-frequency string (neutrally buoyant: no sag stiffening) beta = math.pi**2 * 5.0e7 / (5.0e4 * 100.0**2) @@ -195,6 +231,23 @@ def main() -> int: for u in (0.21, 0.30): runs.append((("stiff", u), f"viv_stiff_u{u}", deck(0.01, waterkin=f"current_{u}.dat", **stiff))) runs.append((("string", u), f"viv_string_u{u}", deck(0.01, waterkin=f"current_{u}.dat", **string))) + # the two-branch band at U 0.24 m/s, probed at MoorDyn's mid (deck node 25: N26 here) and + # x/L = 0.24 (node 12: N13): the spread seed, and VIVphaseSeed zero + band = deck(0.01, waterkin="current_0.24.dat").replace("L1N25PZ\nL1N12PZ", "L1N26PZ\nL1N13PZ") + runs.append(("band", "viv_band_u0.24", band)) + runs.append(("band_zero", "viv_band_u0.24_zero", + band.replace("0.25 VIVrmsWindow", "0.25 VIVrmsWindow\nzero VIVphaseSeed"))) + # the lift at t = 0 has MoorDyn's direction whichever end the deck writes first + for ei, tag in ((0.0, "ei0"), (1.0, "fei")): + start = deck(0.01, ei=ei, waterkin="current_0.24.dat", outputs="V").replace("120.0 TMax", + "0.02 TMax") + runs.append((("t0", tag, False), f"viv_t0_{tag}", start)) + runs.append((("t0", tag, True), f"viv_t0_{tag}_anchor_first", anchor_first(start))) + # and the whole response is the same: 60 s at U 0.24 m/s, mid span, either deck order + order = deck(0.01, ei=ei, waterkin="current_0.24.dat").replace( + "L1N25PZ\nL1N12PZ", "L1N26PZ\nL1N13PZ").replace("120.0 TMax", "60.0 TMax") + runs.append((("order", tag, False), f"viv_order_{tag}", order)) + runs.append((("order", tag, True), f"viv_order_{tag}_anchor_first", anchor_first(order))) for _, name, text in runs: (out / f"{name}.dat").write_text(text, encoding="utf-8", newline="\n") env = dict(os.environ, OMP_NUM_THREADS="1") @@ -212,7 +265,16 @@ def run(name: str) -> subprocess.CompletedProcess[str]: print(proc.stdout[-2000:], proc.stderr[-2000:]) check(False, f"{name}: the run completes (exit {proc.returncode})") continue - results[key] = response(out / f"{name}.out") + if isinstance(key, tuple) and key[0] == "t0": + results[key] = first_lift(out / f"{name}.Line1.V.out", 2.0, key[2]) + elif isinstance(key, tuple) and key[0] == "order": + hist = read_output(out / f"{name}.out") + results[key] = hist.column("L1N26PZ") + elif key in ("band", "band_zero"): + results[key] = response(out / f"{name}.out", "L1N26PZ", "L1N13PZ", + "qtr" if key == "band" else "mid") + else: + results[key] = response(out / f"{name}.out") if 0.01 in results: ad, f, qtr = results[0.01] check(abs(ad - MD_AD) <= 0.03 * MD_AD, @@ -267,6 +329,29 @@ def run(name: str) -> subprocess.CompletedProcess[str]: check(a1 > 0.6 and a0 < 0.15, f"U 0.30 m/s: stiff cable stays in mode 1 (mid-span A/D {a1:.3f}), the string jumps to " f"mode 2 (mid-span A/D {a0:.3f})") + if "band" in results: + mid, f, qtr = results["band"] + check(abs(qtr - MDC_Q24) <= 0.03 * MDC_Q24 and abs(f - MDC_F24) <= 1.0 / 60.0 and mid < 0.1, + f"U 0.24 m/s, seeded phases: mode 2, x/L 0.24 A/D {qtr:.3f} at {f:.3f} Hz, mid span {mid:.3f}; " + f"MoorDyn-C {MDC_Q24} at {MDC_F24} Hz (within 3 % and one bin)") + if "band_zero" in results: + mid, f, _ = results["band_zero"] + check(abs(mid - MDF_M24) <= 0.01 * MDF_M24 and abs(f - MDF_F24) <= 1.0 / 60.0, + f"U 0.24 m/s, VIVphaseSeed zero: mode 1, mid-span A/D {mid:.3f} at {f:.3f} Hz; MoorDyn-F " + f"{MDF_M24} at {MDF_F24} Hz (within 1 % and one bin)") + for tag, what in (("ei0", "EI = 0 line"), ("fei", "finite-EI cable")): + a, b = results.get(("t0", tag, False)), results.get(("t0", tag, True)) + if a is not None and b is not None: + check(a > 4.0 and abs(b - a) <= 1.0e-9 * abs(a), + f"{what}: lift at t = 0, 2 m from the deck's End A, {a:+.4f} N fairlead first and " + f"{b:+.4f} N anchor first (MoorDyn's +z, the same on both)") + for tag, what in (("ei0", "EI = 0 line"), ("fei", "finite-EI cable")): + a, b = results.get(("order", tag, False)), results.get(("order", tag, True)) + if a is not None and b is not None and a.size == b.size: + gap = float(np.max(np.abs(a - b))) + check(gap <= 1.0e-5, + f"{what}: mid-span response over 60 s at U 0.24 m/s, fairlead first against anchor first, " + f"within {gap:.1e} m (1e-5 m; amplitude about 1.6e-2 m)") print(f"{len(FAILURES)} failure(s)") return 1 if FAILURES else 0 From 72e83f57d821fda0a639475e8d1d4f1cb46dd61a Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Sun, 8 Nov 2026 23:05:20 +0900 Subject: [PATCH 07/40] perf(ei0): coupled loads from the end rows; no OpenMP region on one thread (bit-identical) (#117) Signed-off-by: Jae Hoon Seo --- src/CableDyn_Model.f90 | 131 +++++++++++++++++++++++++++++++++++++++- src/CableDyn_System.f90 | 98 ++++++++++++++++++++---------- 2 files changed, 194 insertions(+), 35 deletions(-) diff --git a/src/CableDyn_Model.f90 b/src/CableDyn_Model.f90 index 166b2e8..638ba1f 100644 --- a/src/CableDyn_Model.f90 +++ b/src/CableDyn_Model.f90 @@ -4442,6 +4442,14 @@ SUBROUTINE CD_Calc_Model_CoupledLoads(model, coupled_loads, ErrStat, ErrMsg) ErrMsg = 'CableDyn_Model: internal-force assembly failed during coupled-load calculation: '//TRIM(em) RETURN END IF + ! A line coupled at its end nodes only (a line between points): the remaining terms on the + ! end rows alone (coupled_loads_at_ends), bit-identical to the whole-line residual below. + ! The whole-line terms validate the accelerations element by element (the added-mass + ! kernels): with any non-finite one they are formed whole, to fail as they do. + IF (ALL(model%fixed_dofs <= 3 .OR. model%fixed_dofs > model%n_dof - 3) .AND. CD_All_Finite(model%a)) THEN + CALL coupled_loads_at_ends(model, fint, coupled_loads, ErrStat, ErrMsg) + RETURN + END IF CALL model_structural_mass_product(model, model%a, residual) residual = residual + fint - model%f_ext IF (model%has_added_mass) THEN @@ -4467,6 +4475,119 @@ SUBROUTINE CD_Calc_Model_CoupledLoads(model, coupled_loads, ErrStat, ErrMsg) END ASSOCIATE END SUBROUTINE CD_Calc_Model_CoupledLoads + SUBROUTINE coupled_loads_at_ends(model, fint, coupled_loads, ErrStat, ErrMsg) + !! The rest of CD_Calc_Model_CoupledLoads, given the whole-line internal force fint, for a + !! line whose coupled DOFs all belong to its first or last node and whose accelerations are + !! finite. Only the two end rows of M a + f_int - f_ext + M_add a - load are formed, from + !! the elements that touch an end node and the end nodes' own terms (model_contributor_force + !! end_nodes_only). Each row takes its terms in the element order of the whole-line + !! assembly and its operations in the same order, so the loads are bit-identical to it, as + !! in CD_Get_Model_EndForces; the elements that touch no end node, which add nothing to + !! these rows, are not evaluated. + TYPE(CD_ModelType), INTENT(IN) :: model + REAL(wp), INTENT(IN) :: fint(:) + REAL(wp), INTENT(OUT) :: coupled_loads(:) + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + + INTEGER :: n, nn, e, a, b, ia, ib, sa, sb, k, d, es, idx(6), lk + REAL(wp) :: inertia(3, 2), added(3, 2), residual(3, 2), load(9) + REAL(wp) :: coeff, q2(6), a2(6), wl2(2), ma2(6) + CHARACTER(1024) :: em + + ErrStat = CD_MODEL_OK + ErrMsg = '' + coupled_loads = CD_ZERO + lk = MIN(LEN(em), KERNEL_MSG_LEN) + n = model%n_dof + nn = n/3 + ! M a (model_structural_mass_product) on the end rows, from zero in element order + inertia = CD_ZERO + DO e = 1, model%n_elem + a = model%elem_conn(1, e) + b = model%elem_conn(2, e) + sa = end_slot(a) + sb = end_slot(b) + IF (sa == 0 .AND. sb == 0) CYCLE + ia = 3*a - 2 + ib = 3*b - 2 + coeff = model%rho_a(e)*model%l0(e)/6.0_wp + IF (sa /= 0) inertia(:, sa) = inertia(:, sa) + coeff*(2.0_wp*model%a(ia:ia + 2) + model%a(ib:ib + 2)) + IF (sb /= 0) inertia(:, sb) = inertia(:, sb) + coeff*(model%a(ia:ia + 2) + 2.0_wp*model%a(ib:ib + 2)) + END DO + DO k = 1, 2 + residual(:, k) = inertia(:, k) + fint(end_base(k) + 1:end_base(k) + 3) - & + model%f_ext(end_base(k) + 1:end_base(k) + 3) + END DO + ! M_add a (model_added_mass_force), from zero in element order + IF (model%has_added_mass) THEN + added = CD_ZERO + DO e = 1, model%n_elem + sa = end_slot(model%elem_conn(1, e)) + sb = end_slot(model%elem_conn(2, e)) + IF (sa == 0 .AND. sb == 0) CYCLE + CALL extract_element_positions(model, e, model%q, idx, q2) + a2 = model%a(idx) + CALL extract_nodal_scalar(model%added_mass_waterline_z, model, e, wl2) + em = '' + CALL CD_Added_Mass_Element_Force(q2(1:3), q2(4:6), a2(1:3), a2(4:6), model%l0(e), wl2(1), wl2(2), & + model%added_mass_rho, model%added_mass_diameter_elem(e), & + model%added_mass_can_elem(e), model%added_mass_cat_elem(e), & + ma2, es, em(1:lk)) + IF (es /= 0) THEN + ErrStat = CD_MODEL_SOLVEFAIL + ErrMsg = 'CableDyn_Model: added-mass assembly failed during coupled-load calculation: '//TRIM(em) + RETURN + END IF + ! the far node's share stays finite-checked as in the whole-line force + IF (.NOT. CD_All_Finite(ma2)) THEN + CALL fail(es, em, 'added-mass force returned non-finite values') + ErrStat = CD_MODEL_SOLVEFAIL + ErrMsg = 'CableDyn_Model: added-mass assembly failed during coupled-load calculation: '//TRIM(em) + RETURN + END IF + IF (sa /= 0) added(:, sa) = added(:, sa) + ma2(1:3) + IF (sb /= 0) added(:, sb) = added(:, sb) + ma2(4:6) + END DO + residual = residual + added + END IF + CALL model_contributor_force(model, model%q, model%v, load, es, em, end_nodes_only=.TRUE.) + IF (es /= 0) THEN + ErrStat = CD_MODEL_SOLVEFAIL + ErrMsg = 'CableDyn_Model: load assembly failed during coupled-load calculation: '//TRIM(em) + RETURN + END IF + residual(:, 1) = residual(:, 1) - load(1:3) + residual(:, 2) = residual(:, 2) - load(4:6) + DO k = 1, SIZE(model%fixed_dofs) + d = model%fixed_dofs(k) + IF (d <= 3) THEN + coupled_loads(k) = -residual(d, 1) + ELSE + coupled_loads(k) = -residual(d - end_base(2), 2) + END IF + END DO + + CONTAINS + + PURE INTEGER FUNCTION end_slot(node) RESULT(s) + !! 1 for the first node, 2 for the last, 0 for an interior node. + INTEGER, INTENT(IN) :: node + s = 0 + IF (node == 1) THEN + s = 1 + ELSE IF (node == nn) THEN + s = 2 + END IF + END FUNCTION end_slot + + PURE INTEGER FUNCTION end_base(s) RESULT(base) + !! DOF offset of end slot s. + INTEGER, INTENT(IN) :: s + base = MERGE(0, n - 3, s == 1) + END FUNCTION end_base + END SUBROUTINE coupled_loads_at_ends + SUBROUTINE CD_Calc_Model_CoupledKinematicDerivatives(model, dload_dq, dload_dv, ErrStat, ErrMsg) !! Analytic reduced coupled-load derivatives with respect to prescribed !! coupled positions and velocities. Free DOFs are statically condensed with @@ -9122,8 +9243,11 @@ PURE SUBROUTINE extract_element_state(model, e, q, v, idx, q2, v2) a = model%elem_conn(1, e) b = model%elem_conn(2, e) idx = [3*a - 2, 3*a - 1, 3*a, 3*b - 2, 3*b - 1, 3*b] - q2 = q(idx) - v2 = v(idx) + ! the two nodes' contiguous triples (the copies q(idx), v(idx) without a vector subscript) + q2(1:3) = q(3*a - 2:3*a) + q2(4:6) = q(3*b - 2:3*b) + v2(1:3) = v(3*a - 2:3*a) + v2(4:6) = v(3*b - 2:3*b) END SUBROUTINE extract_element_state PURE SUBROUTINE extract_element_positions(model, e, q, idx, q2) @@ -9138,7 +9262,8 @@ PURE SUBROUTINE extract_element_positions(model, e, q, idx, q2) a = model%elem_conn(1, e) b = model%elem_conn(2, e) idx = [3*a - 2, 3*a - 1, 3*a, 3*b - 2, 3*b - 1, 3*b] - q2 = q(idx) + q2(1:3) = q(3*a - 2:3*a) + q2(4:6) = q(3*b - 2:3*b) END SUBROUTINE extract_element_positions PURE SUBROUTINE extract_nodal_vector(field, model, e, field2) diff --git a/src/CableDyn_System.f90 b/src/CableDyn_System.f90 index 6ca7fc8..272563b 100644 --- a/src/CableDyn_System.f90 +++ b/src/CableDyn_System.f90 @@ -1493,27 +1493,24 @@ RECURSIVE SUBROUTINE CD_Step_System(system, dt, q_coupled, v_coupled, a_coupled, ! Line models own disjoint state and Newton workspaces. Workers stage results; ! the following serial pass selects the first error and reduces flags in deck order. n_team = line_team_size(system) - !$OMP PARALLEL DEFAULT(SHARED) PRIVATE(i) NUM_THREADS(n_team) IF(n_team > 1) - CALL CD_Fatal_Thread_Init() ! this thread can report its own stack overflow - ! DYNAMIC: a line that is sub-stepped (CD_Step_Model) costs several of its plain steps, so the - ! lines take unequal times; each line's result does not depend on the thread that steps it - !$OMP DO SCHEDULE(DYNAMIC) - DO i = 1, system%n_lines - ! the halves of a stall recovery (0 < depth < MAX_SUBDIV) take their steps as given, as the - ! rungs of CD_Step_Model_Recovering do: no rotation- and snap-resolved sub-steps of - ! sub-steps. The plain full-dt rerun at the depth floor (depth = MAX_SUBDIV) steps every line - ! as the nominal step (depth 0) does, so its result is that of the step without the - ! fallback; the junction step (JUNCTION_NO_SUBDIV = MAX_SUBDIV) likewise, its armed lines - ! being taken as given by CD_Step_Model itself - CALL CD_Step_Model(system%lines(i), dt, system%line_converged(i), system%line_stalled(i), & - system%line_iter(i), system%line_stat(i), system%line_msg(i), & - prescribed_q=system%lines(i)%q_prescribed_work, & - prescribed_v=system%lines(i)%v_prescribed_work, & - prescribed_a=system%lines(i)%a_prescribed_work, & - as_given=depth > 0 .AND. depth < MAX_SUBDIV) - END DO - !$OMP END DO - !$OMP END PARALLEL + IF (n_team > 1) THEN + !$OMP PARALLEL DEFAULT(SHARED) PRIVATE(i) NUM_THREADS(n_team) + CALL CD_Fatal_Thread_Init() ! this thread can report its own stack overflow + ! DYNAMIC: a line that is sub-stepped (CD_Step_Model) costs several of its plain steps, so the + ! lines take unequal times; each line's result does not depend on the thread that steps it + !$OMP DO SCHEDULE(DYNAMIC) + DO i = 1, system%n_lines + CALL step_line(i) + END DO + !$OMP END DO + !$OMP END PARALLEL + ELSE + ! one thread: the same loop without entering a parallel region + CALL CD_Fatal_Thread_Init() + DO i = 1, system%n_lines + CALL step_line(i) + END DO + END IF DO i = 1, system%n_lines IF (system%line_stat(i) /= CD_MODEL_OK) THEN CALL restore_failed_step(system, system%line_stat(i), 'line step failed: '//TRIM(system%line_msg(i)), & @@ -1676,6 +1673,25 @@ RECURSIVE SUBROUTINE CD_Step_System(system, dt, q_coupled, v_coupled, a_coupled, END IF ErrStat = CD_SYSTEM_OK ErrMsg = '' + + CONTAINS + + SUBROUTINE step_line(il) + !! Step line il with its staged prescribed motion. The halves of a stall recovery + !! (0 < depth < MAX_SUBDIV) take their steps as given, as the rungs of + !! CD_Step_Model_Recovering do: no rotation- and snap-resolved sub-steps of sub-steps. The + !! plain full-dt rerun at the depth floor (depth = MAX_SUBDIV) steps every line as the + !! nominal step (depth 0) does, so its result is that of the step without the fallback; the + !! junction step (JUNCTION_NO_SUBDIV = MAX_SUBDIV) likewise, its armed lines being taken as + !! given by CD_Step_Model itself. + INTEGER, INTENT(IN) :: il + CALL CD_Step_Model(system%lines(il), dt, system%line_converged(il), system%line_stalled(il), & + system%line_iter(il), system%line_stat(il), system%line_msg(il), & + prescribed_q=system%lines(il)%q_prescribed_work, & + prescribed_v=system%lines(il)%v_prescribed_work, & + prescribed_a=system%lines(il)%a_prescribed_work, & + as_given=depth > 0 .AND. depth < MAX_SUBDIV) + END SUBROUTINE step_line END SUBROUTINE CD_Step_System SUBROUTINE CD_Step_System_Junction(system, dt, q_coupled, v_coupled, a_coupled, active, warm, loads, kmat, & @@ -2432,17 +2448,23 @@ SUBROUTINE CD_Calc_System_CoupledLoads(system, coupled_loads, ErrStat, ErrMsg) RETURN END IF n_team = line_team_size(system) - !$OMP PARALLEL DEFAULT(SHARED) PRIVATE(i, lo, hi) NUM_THREADS(n_team) IF(n_team > 1) - CALL CD_Fatal_Thread_Init() ! this thread can report its own stack overflow - !$OMP DO SCHEDULE(STATIC) - DO i = 1, system%n_lines - lo = system%line_off(i) - hi = system%line_off(i + 1) - 1 - CALL CD_Calc_Model_CoupledLoads(system%lines(i), system%line_load_work(lo:hi), & - system%line_stat(i), system%line_msg(i)) - END DO - !$OMP END DO - !$OMP END PARALLEL + IF (n_team > 1) THEN + !$OMP PARALLEL DEFAULT(SHARED) PRIVATE(i) NUM_THREADS(n_team) + CALL CD_Fatal_Thread_Init() ! this thread can report its own stack overflow + !$OMP DO SCHEDULE(STATIC) + DO i = 1, system%n_lines + CALL line_coupled_loads(i) + END DO + !$OMP END DO + !$OMP END PARALLEL + ELSE + ! one thread: the same loop without entering a parallel region (whose team setup costs + ! more than a short line's loads) + CALL CD_Fatal_Thread_Init() + DO i = 1, system%n_lines + CALL line_coupled_loads(i) + END DO + END IF DO i = 1, system%n_lines IF (system%line_stat(i) /= CD_MODEL_OK) THEN ErrStat = CD_SYSTEM_SOLVEFAIL @@ -2453,6 +2475,18 @@ SUBROUTINE CD_Calc_System_CoupledLoads(system, coupled_loads, ErrStat, ErrMsg) hi = system%line_off(i + 1) - 1 CALL accumulate_mapped_loads(system, lo, hi, system%line_load_work(lo:hi), coupled_loads) END DO + + CONTAINS + + SUBROUTINE line_coupled_loads(il) + !! The coupled loads of line il into its slice of the per-line workspace. + INTEGER, INTENT(IN) :: il + INTEGER :: l_lo, l_hi + l_lo = system%line_off(il) + l_hi = system%line_off(il + 1) - 1 + CALL CD_Calc_Model_CoupledLoads(system%lines(il), system%line_load_work(l_lo:l_hi), & + system%line_stat(il), system%line_msg(il)) + END SUBROUTINE line_coupled_loads END SUBROUTINE CD_Calc_System_CoupledLoads INTEGER FUNCTION line_team_size(system) RESULT(n_team) From 65a740e11c1f76fc2b0359c032fe32e59fad3b22 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Sun, 8 Nov 2026 23:55:11 +0900 Subject: [PATCH 08/40] perf(modal): shift-invert block Lanczos eigenvalues for long lines (#116) Signed-off-by: Jae Hoon Seo --- ARCHITECTURE.md | 18 +- doc/examples.rst | 2 +- doc/options.rst | 4 +- doc/outputs.rst | 4 +- doc/theory.rst | 32 ++- doc/tutorial_lazywave.rst | 2 +- src/CableDyn_Modal.f90 | 404 ++++++++++++++++++++++++++++---- tests/test_lapack_guard_pages.c | 72 +++++- tests/test_modal.f90 | 196 +++++++++++++++- 9 files changed, 645 insertions(+), 89 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5f28987..e6d4440 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -322,14 +322,16 @@ analytic Jacobians. about its static equilibrium, `K φ = ω² M φ`, over the free DOFs, with `K` the static tangent (element stiffness and linearised seabed contact) and `M` the consistent mass plus Morison added mass. `K` and `M` are assembled in symmetric -band storage; LAPACK `DSBGVX` gives the eigenvalues and inverse iteration on the -banded pencil gives each mode shape, so the cost grows with line length rather -than with its square. Dense `DSYGV` is kept as the test reference. Both line paths -are supported (`nModes`). A finite-EI line's modes (`CD_Modal_Hermite_Line`) take -its end connections and attachments from the dynamic model, and a line restrained -in torsion adds the twist angle of every node: its lumped polar inertia and the -second variation of the nodal-twist energy (`CD_HermiteCable_Dyn_Twist_Hessian`) -in a band of 7 DOFs per node. +band storage. A shift-invert block Lanczos iteration on the banded Cholesky factor +of `K` gives the eigenvalues, certified by a Sturm count of `K - σM` (LAPACK +`DSBGVX` takes short lines, an indefinite or singular `K`, and an uncertified run), +and inverse iteration on the banded pencil gives each mode shape, so the cost +grows with line length rather than with its square. Dense `DSYGV` is kept as the +test reference. Both line paths are supported (`nModes`). A finite-EI line's +modes (`CD_Modal_Hermite_Line`) take its end connections and attachments from the +dynamic model, and a line restrained in torsion adds the twist angle of every +node: its lumped polar inertia and the second variation of the nodal-twist energy +(`CD_HermiteCable_Dyn_Twist_Hessian`) in a band of 7 DOFs per node. ### Outputs diff --git a/doc/examples.rst b/doc/examples.rst index cb8abf5..8db6ddc 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -360,7 +360,7 @@ touchdown and lazy-wave shape from the geometry alone; there is no initial-shape - the 10 lowest natural periods and mode shapes (``nModes``) of the 80 m cable on its 1024-element mesh — :doc:`tutorial_lazywave` - Hermite, modal - - ~2 s + - ~1 s * - ``torsion_lazy_wave_hangoff_twist.dat`` - the 80 m cable with an illustrative ``GJ`` of 50 kN·m², clamped and restrained in torsion at both ends, twisted two turns at the hang-off over 60 s by the ``motionFile`` roll column diff --git a/doc/options.rst b/doc/options.rst index 8f9e064..b249223 100644 --- a/doc/options.rst +++ b/doc/options.rst @@ -622,8 +622,8 @@ Ambient fluid and prescribed motion lines stops with ``OPTION nModes is not supported for mixed EI=0/finite-EI decks; remove it or set it to 0``. A line restrained in torsion adds the twist angle of every node to its modes (``PolarShare`` and ``dTwist`` in :doc:`outputs`). The banded solver has - no line-length limit: a - 1024-element finite-EI cable takes a few seconds. See :doc:`theory` + no line-length limit: the 10 modes of a 1024-element finite-EI cable take a fraction of a + second. See :doc:`theory` - standalone - ``10 nModes`` * - ``WaveSeed`` / ``wave_seed`` diff --git a/doc/outputs.rst b/doc/outputs.rst index b8e57da..ac7e6fb 100644 --- a/doc/outputs.rst +++ b/doc/outputs.rst @@ -463,7 +463,9 @@ row: * ``# Mode shapes``: ``LineID Mode Node X Y Z dX dY dZ``, one row per mode and node. ``X Y Z`` is the static node position and ``dX dY dZ`` the nodal translation of the mode, scaled so that the largest nodal displacement of the mode is 1. Nodes are numbered End A first, - as in the other line files; the held end nodes have zero displacement. + as in the other line files; the held end nodes have zero displacement. The sign of a mode + makes the largest component of its mode vector positive; where two components are equally + large (the mirrored nodes of a symmetric line), the first of them in the solver's node order. When a line is restrained in torsion (both ends, see :doc:`driver_format`), its modes carry the twist angle of every node (:ref:`nodal twist `) and both tables gain a diff --git a/doc/theory.rst b/doc/theory.rst index a16eb76..18d5f2a 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -2004,13 +2004,31 @@ element, the Hessian of its strain energy. The linearised seabed normal contact stiffness on the contacting nodes. :math:`\mathbf M` is the consistent structural mass plus the Morison added mass at the static shape. Friction, damping, and drag are left out, as in any undamped modal analysis. :math:`\mathbf K` and :math:`\mathbf M` are banded (the nodes are -numbered along the line), and only the ``nModes`` lowest modes are computed: LAPACK ``dsbgvx`` -gives the eigenvalues, inverse iteration with the banded factors of -:math:`\mathbf K - \omega^2\mathbf M` gives each shape (M-orthogonal to the shapes before -it, so a repeated frequency gets independent shapes), and the returned :math:`\omega^2` is the -Rayleigh quotient of the shape, summed in quadruple precision. That quotient keeps the lowest -frequencies of a long, axially stiff line accurate, where the band reduction alone loses -digits. Memory grows linearly with the line length. The frequencies +numbered along the line), and only the ``nModes`` lowest modes are computed. Their eigenvalues +come from a block Lanczos iteration on the shift-inverted operator +:math:`\mathbf K^{-1}\mathbf M`, applied with the banded Cholesky factor of :math:`\mathbf K`, +in the :math:`\mathbf M` inner product with full reorthogonalisation. The block holds two +vectors, so both planes of a straight line, whose transverse frequencies coincide, are found. +A Ritz value is accepted when the residual of the shift-inverted eigenproblem is below +:math:`10^{-10}` of its eigenvalue. The count is then certified: the Cholesky factor shows that +no eigenvalue lies at or below zero, and the inertia of :math:`\mathbf K - \sigma\mathbf M` +(the negative pivots of its banded :math:`\mathbf L\mathbf D\mathbf L^{\mathsf T}` +factorisation, by Sylvester's law of inertia) at a shift :math:`\sigma` in a gap above the +wanted eigenvalues must equal the number of Ritz values below :math:`\sigma`, so no frequency +is missed or counted twice. The iteration's time grows linearly with the line length. Short +lines, many modes of a short line, an indefinite or singular :math:`\mathbf K` (a compressed +or slack line), and an iteration that does not converge or is not certified within its basis +bound take LAPACK ``dsbgvx`` instead (band reduction and bisection), whose time grows with the +square of the line length. Inverse iteration with the banded factors of +:math:`\mathbf K - \omega^2\mathbf M` then gives each shape (M-orthogonal to the shapes +before it, so a repeated frequency gets independent shapes), and the returned +:math:`\omega^2` is the Rayleigh quotient of the shape, summed in quadruple precision. That +quotient keeps the lowest frequencies of a long, axially stiff line accurate, where the band +reduction alone loses digits. A shape the Lanczos eigenvalues do not give is solved again on +the eigenvalues of the band reduction. Each shape is signed so that its largest component is +positive; where two components are equally large to round-off (the mirrored nodes of a +symmetric line), the first of them in the solver's node order is. Memory grows linearly with the line +length. The frequencies :math:`f = \omega/2\pi` and the nodal translations of each mode (scaled to a unit largest nodal displacement) are written to ``.modes.out``. For a taut string of tension :math:`T` the transverse frequencies are :math:`f_n = (n/2L)\sqrt{T/m}`; for a shallow sagging diff --git a/doc/tutorial_lazywave.rst b/doc/tutorial_lazywave.rst index 40790db..c324eef 100644 --- a/doc/tutorial_lazywave.rst +++ b/doc/tutorial_lazywave.rst @@ -184,7 +184,7 @@ vertical), 19.9 s (out of plane, along the grounded run) and 16.9 s (in plane). 2048 elements the out-of-plane periods and the 20.4 s mode agree to four digits. The in-plane modes that move the touchdown (39.9 s, 16.9 s and 11.7 s) shift by up to 0.5 %, because the linearised seabed contact at the touchdown changes with the node spacing. The run takes about -2 s, most of it in the modal solve. +a second, a quarter of it in the modal solve. Exercises --------- diff --git a/src/CableDyn_Modal.f90 b/src/CableDyn_Modal.f90 index 3c15668..ec0c34d 100644 --- a/src/CableDyn_Modal.f90 +++ b/src/CableDyn_Modal.f90 @@ -13,14 +13,16 @@ MODULE CableDyn_Modal !! !! K and M are assembled as their symmetric parts in LAPACK symmetric band storage (the !! nodes of a line are numbered along it, so the bandwidth is set by one element) and - !! CD_Modal_Solve_Band computes only the requested lowest modes: the eigenvalues by - !! LAPACK DSBGVX (band reduction and bisection), each mode shape by inverse iteration - !! on the banded pencil, M-orthogonalised against the modes before it, and the returned - !! eigenvalue as the Rayleigh quotient of that shape. Memory and time - !! grow linearly with the line length for the shapes and quadratically, with a small - !! constant, for the band reduction. The dense routines (CD_Modal_Bar_Matrices, - !! CD_Modal_Hermite_Matrices, CD_Modal_Solve with LAPACK DSYGV) are kept as the reference - !! the banded solver is tested against, for small lines only. + !! CD_Modal_Solve_Band computes only the requested lowest modes: the eigenvalues by a + !! shift-invert block Lanczos iteration on the banded Cholesky factor of K, certified by + !! a Sturm count of K - sigma M (or, for a short line, many modes, an indefinite or + !! singular K, or a Lanczos run that is not certified, by LAPACK DSBGVX: band reduction + !! and bisection), each mode shape by inverse iteration on the banded pencil, + !! M-orthogonalised against the modes before it, and the returned eigenvalue as the + !! Rayleigh quotient of that shape. Memory and time grow linearly with the line length + !! (quadratically, with a small constant, for the band reduction). The dense routines + !! (CD_Modal_Bar_Matrices, CD_Modal_Hermite_Matrices, CD_Modal_Solve with LAPACK DSYGV) + !! are kept as the reference the banded solver is tested against, for small lines only. !! !! EI = 0 lines (two-node bars, three translations per node): an element of rest length !! l0 and current length l along the unit vector e, tension T = EA (l/l0 - 1) (zero when @@ -58,7 +60,25 @@ MODULE CableDyn_Modal PRIVATE INTEGER, PARAMETER, PUBLIC :: CD_MODAL_OK = 0, CD_MODAL_BADINPUT = 1, CD_MODAL_FAIL = 2 + ! Eigenvalue stage of CD_Modal_Solve_Band: chosen by problem size, the shift-invert Lanczos + ! iteration (falling back to the band reduction when it is not certified), or the band + ! reduction (DSBGVX) + INTEGER, PARAMETER, PUBLIC :: CD_MODAL_EIG_AUTO = 0, CD_MODAL_EIG_LANCZOS = 1, CD_MODAL_EIG_BANDRED = 2 INTEGER, PARAMETER :: I8 = SELECTED_INT_KIND(18) + ! Shift-invert block Lanczos: block size (two, for the exactly repeated frequencies of the + ! two transverse planes of a straight line), the residual tolerance of a wanted Ritz value + ! and of the Ritz values that bound the Sturm interval (relative to the Ritz value of the + ! shift-inverted operator, with a floor at round-off of the largest one), the relative gap + ! the Sturm shift sits in, and the breakdown threshold of a new Lanczos vector. Below + ! LZ_MIN_DOF free DOFs, or where the basis and its M-image would hold more than + ! LZ_MAX_WORDS values (256 MB), the band reduction is used. + INTEGER, PARAMETER :: LZ_BLOCK = 2, LZ_MIN_DOF = 200 + INTEGER(I8), PARAMETER :: LZ_MAX_WORDS = 32000000_I8 + REAL(wp), PARAMETER :: LZ_TOL = 1.0e-10_wp, LZ_TOL_MAX = 1.0e-7_wp, LZ_TOL_GUARD = 1.0e-6_wp + REAL(wp), PARAMETER :: LZ_FLOOR = 1.0e-13_wp, LZ_GAP = 1.0e-3_wp, LZ_BREAK = 1.0e-10_wp + ! Relative tolerance within which two components of a mode shape count as equally large in + ! its sign convention + REAL(wp), PARAMETER :: SIGN_TIE = 1.0e-6_wp ! Largest problem of the dense reference solver: three 4000 x 4000 matrices are 400 MB INTEGER, PARAMETER :: DENSE_MAX_DOF = 4000 ! Inverse iterations per mode: at least MIN_ITER, at most MAX_ITER @@ -715,19 +735,25 @@ SUBROUTINE CD_Modal_Hermite_Matrices(q, l0, ea, ei, rho_a, K, M, ErrStat, ErrMsg CALL band_to_dense(mb, kd, M) END SUBROUTINE CD_Modal_Hermite_Matrices - SUBROUTINE CD_Modal_Solve_Band(K, M, kd, free, n_modes, omega2, shapes, n_found, ErrStat, ErrMsg) + RECURSIVE SUBROUTINE CD_Modal_Solve_Band(K, M, kd, free, n_modes, omega2, shapes, n_found, ErrStat, ErrMsg, & + eig_method, eig_used) !! The n_modes lowest eigenpairs of K phi = omega^2 M phi on the free DOFs, K and M in !! symmetric upper band storage of half-bandwidth kd (CD_Modal_Bar_Band, - !! CD_Modal_Hermite_Band). The held DOFs are removed, which keeps the band. DSBGVX - !! returns the lowest n_modes eigenvalues without eigenvectors (its eigenvectors would - !! need an n x n work matrix); each shape is then found by inverse iteration with the - !! banded LU factors of K - omega^2 M, M-orthogonalised against the shapes before it - !! (so a repeated frequency, such as the two transverse planes of a taut string, gets - !! two orthogonal shapes), and accepted on the residual of its eigen-equation. omega2 - !! is the Rayleigh quotient of the shape (quadruple-precision sums), in ascending order. - !! shapes(:, j) is the full-length mode vector (zero on the held DOFs), M-orthonormal - !! on the free DOFs, signed so that its largest component is positive. A negative - !! omega2 (an unstable or compressed state) is returned as computed. + !! CD_Modal_Hermite_Band). The held DOFs are removed, which keeps the band. The lowest + !! n_modes eigenvalues come from a shift-invert block Lanczos iteration (lanczos_lowest) + !! when it is the cheaper solver and its count is certified, else from DSBGVX (without + !! eigenvectors: those would need an n x n work matrix); each shape is then found by + !! inverse iteration with the banded LU factors of K - omega^2 M, M-orthogonalised + !! against the shapes before it (so a repeated frequency, such as the two transverse + !! planes of a taut string, gets two orthogonal shapes), and accepted on the residual of + !! its eigen-equation. omega2 is the Rayleigh quotient of the shape (quadruple-precision + !! sums), in ascending order. shapes(:, j) is the full-length mode vector (zero on the + !! held DOFs), M-orthonormal on the free DOFs, signed so that its largest component is + !! positive (the first of those equal to it within SIGN_TIE: a symmetric line's mirrored + !! nodes). A negative omega2 (an unstable or compressed state) is returned as computed. + !! eig_method (CD_MODAL_EIG_AUTO by default) selects the eigenvalue stage; eig_used + !! returns the one that produced the eigenvalues (CD_MODAL_EIG_LANCZOS or + !! CD_MODAL_EIG_BANDRED; CD_MODAL_EIG_AUTO when none did). REAL(wp), INTENT(IN) :: K(:, :), M(:, :) INTEGER, INTENT(IN) :: kd LOGICAL, INTENT(IN) :: free(:) @@ -735,13 +761,15 @@ SUBROUTINE CD_Modal_Solve_Band(K, M, kd, free, n_modes, omega2, shapes, n_found, REAL(wp), INTENT(OUT) :: omega2(:), shapes(:, :) INTEGER, INTENT(OUT) :: n_found, ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg - INTEGER :: n, nf, kc, i, j, ii, jj, info, m_found, mode, it, ntry, prev, pass, es + INTEGER, INTENT(IN), OPTIONAL :: eig_method + INTEGER, INTENT(OUT), OPTIONAL :: eig_used + INTEGER :: n, nf, kc, i, j, ii, jj, info, m_found, mode, it, ntry, prev, pass, es, method INTEGER, ALLOCATABLE :: map(:), iwork(:), ifail(:), ipiv(:) INTEGER(I8) :: seed REAL(wp), ALLOCATABLE :: ac(:, :), bc(:, :), a(:, :), b(:, :), w(:), work(:), lu(:, :), x(:), y(:), r(:) REAL(wp), ALLOCATABLE :: vec(:, :), bvec(:, :) - REAL(wp) :: qdum(1, 1), zdum(1, 1), anorm, bnorm, shift, scale, nrm, res, rq, noise - LOGICAL :: converged + REAL(wp) :: qdum(1, 1), zdum(1, 1), anorm, bnorm, shift, scale, nrm, res, rq, noise, xmax + LOGICAL :: converged, lz_ok CHARACTER(1024) :: em EXTERNAL :: dsbgvx @@ -750,6 +778,9 @@ SUBROUTINE CD_Modal_Solve_Band(K, M, kd, free, n_modes, omega2, shapes, n_found, omega2 = CD_ZERO shapes = CD_ZERO n_found = 0 + IF (PRESENT(eig_used)) eig_used = CD_MODAL_EIG_AUTO + method = CD_MODAL_EIG_AUTO + IF (PRESENT(eig_method)) method = eig_method n = SIZE(free) IF (kd < 0 .OR. SIZE(K, 1) /= kd + 1 .OR. SIZE(M, 1) /= kd + 1 .OR. SIZE(K, 2) /= n .OR. SIZE(M, 2) /= n .OR. & SIZE(shapes, 1) /= n .OR. SIZE(shapes, 2) < n_modes .OR. SIZE(omega2) < n_modes .OR. n_modes < 1) THEN @@ -789,35 +820,46 @@ SUBROUTINE CD_Modal_Solve_Band(K, M, kd, free, n_modes, omega2, shapes, n_found, END DO n_found = MIN(n_modes, nf) - ! The lowest n_found eigenvalues (DSBGVX overwrites its band copies). - ALLOCATE (a(kc + 1, nf), b(kc + 1, nf), w(nf), work(7*nf), iwork(5*nf), ifail(nf)) - a = ac - b = bc - CALL dsbgvx('N', 'I', 'U', nf, kc, kc, a, kc + 1, b, kc + 1, qdum, 1, CD_ZERO, CD_ZERO, 1, n_found, & - 2.0_wp*TINY(CD_ONE), m_found, w, zdum, 1, work, iwork, ifail, info) - IF (info /= 0 .OR. m_found /= n_found) THEN - ErrStat = CD_MODAL_FAIL - IF (info == CD_BLAS_UNAVAILABLE_INFO) THEN - CALL CD_Blas_Runtime_Check('CableDyn_Modal (DSBGVX)', es, em) - ErrMsg = 'CableDyn_Modal: '//TRIM(em) - ELSE IF (info > nf) THEN - ErrMsg = 'CableDyn_Modal: the mass matrix is not positive definite (DSBGVX)' - ELSE - BLOCK - ! Local buffer: a record longer than the caller's ErrMsg truncates - ! instead of aborting the internal write. - CHARACTER(1024) :: wmsg - INTEGER :: wios - wmsg = '' - WRITE (wmsg, '(A,I0,A,I0,A,I0)', IOSTAT=wios) 'CableDyn_Modal: DSBGVX failed, INFO = ', info, ', ', m_found, & - ' of the requested eigenvalues found: ', n_found - ErrMsg = wmsg - END BLOCK + ! The lowest n_found eigenvalues: the shift-invert Lanczos iteration where it is the + ! cheaper solver, else (or when its count is not certified) the band reduction. + ALLOCATE (w(nf)) + lz_ok = .FALSE. + IF (method == CD_MODAL_EIG_LANCZOS .OR. (method == CD_MODAL_EIG_AUTO .AND. lanczos_cheaper(nf, kc, n_found))) & + CALL lanczos_lowest(ac, bc, kc, n_found, w, lz_ok) + IF (lz_ok) THEN + IF (PRESENT(eig_used)) eig_used = CD_MODAL_EIG_LANCZOS + ELSE + ! DSBGVX overwrites its band copies. + ALLOCATE (a(kc + 1, nf), b(kc + 1, nf), work(7*nf), iwork(5*nf), ifail(nf)) + a = ac + b = bc + CALL dsbgvx('N', 'I', 'U', nf, kc, kc, a, kc + 1, b, kc + 1, qdum, 1, CD_ZERO, CD_ZERO, 1, n_found, & + 2.0_wp*TINY(CD_ONE), m_found, w, zdum, 1, work, iwork, ifail, info) + IF (info /= 0 .OR. m_found /= n_found) THEN + ErrStat = CD_MODAL_FAIL + IF (info == CD_BLAS_UNAVAILABLE_INFO) THEN + CALL CD_Blas_Runtime_Check('CableDyn_Modal (DSBGVX)', es, em) + ErrMsg = 'CableDyn_Modal: '//TRIM(em) + ELSE IF (info > nf) THEN + ErrMsg = 'CableDyn_Modal: the mass matrix is not positive definite (DSBGVX)' + ELSE + BLOCK + ! Local buffer: a record longer than the caller's ErrMsg truncates + ! instead of aborting the internal write. + CHARACTER(1024) :: wmsg + INTEGER :: wios + wmsg = '' + WRITE (wmsg, '(A,I0,A,I0,A,I0)', IOSTAT=wios) 'CableDyn_Modal: DSBGVX failed, INFO = ', info, ', ', & + m_found, ' of the requested eigenvalues found: ', n_found + ErrMsg = wmsg + END BLOCK + END IF + n_found = 0 + RETURN END IF - n_found = 0 - RETURN + DEALLOCATE (a, b, work, iwork, ifail) + IF (PRESENT(eig_used)) eig_used = CD_MODAL_EIG_BANDRED END IF - DEALLOCATE (a, b, work, iwork, ifail) ! The shapes by inverse iteration on the banded pencil. anorm = sym_band_norm(ac, kc) @@ -894,7 +936,12 @@ SUBROUTINE CD_Modal_Solve_Band(K, M, kd, free, n_modes, omega2, shapes, n_found, RETURN END IF END DO - i = MAXLOC(ABS(x), 1) + ! the sign: the largest component positive; of components equal to it in magnitude to + ! round-off (the mirrored nodes of a symmetric line) the first one + xmax = MAXVAL(ABS(x)) + DO i = 1, nf - 1 + IF (ABS(x(i)) >= (CD_ONE - SIGN_TIE)*xmax) EXIT + END DO IF (x(i) < CD_ZERO) x = -x vec(:, mode) = x CALL sym_band_mv(bc, kc, x, bvec(:, mode)) @@ -934,6 +981,15 @@ END SUBROUTINE shifted_general_band SUBROUTINE mode_fail(msg) CHARACTER(*), INTENT(IN) :: msg + INTEGER :: used + IF (lz_ok) THEN + ! A shape the Lanczos eigenvalues did not give: the whole solve again on the + ! eigenvalues of the band reduction, which succeeds or fails as it does alone. + CALL CD_Modal_Solve_Band(K, M, kd, free, n_modes, omega2, shapes, n_found, ErrStat, ErrMsg, & + eig_method=CD_MODAL_EIG_BANDRED, eig_used=used) + IF (PRESENT(eig_used)) eig_used = used + RETURN + END IF ErrStat = CD_MODAL_FAIL BLOCK ! Local buffer: a record longer than the caller's ErrMsg truncates @@ -951,6 +1007,254 @@ END SUBROUTINE mode_fail END SUBROUTINE CD_Modal_Solve_Band + INTEGER FUNCTION lanczos_max_basis(nf, nwant) RESULT(mb) + !! The bound on the Lanczos basis for nwant of nf eigenvalues: the iteration usually + !! converges with about twice nwant vectors. + INTEGER, INTENT(IN) :: nf, nwant + mb = MIN(nf, 2*nwant + MAX(nwant, 40)) + END FUNCTION lanczos_max_basis + + LOGICAL FUNCTION lanczos_cheaper(nf, kc, nwant) RESULT(yes) + !! Whether the shift-invert Lanczos iteration is expected to cost less than the band + !! reduction for nwant of nf eigenvalues at half-bandwidth kc: the work of its + !! reorthogonalisation grows as nf m**2 with the basis size m (about 2 nwant + 20), + !! that of the reduction as nf**2 kc (the constant measured on lines of 300 to 24000 + !! free DOFs). + !! The memory of the basis is bounded (LZ_MAX_WORDS). + INTEGER, INTENT(IN) :: nf, kc, nwant + INTEGER(I8) :: m + m = INT(MIN(nf, 2*nwant + 20), I8) + yes = nf >= LZ_MIN_DOF .AND. m*m <= 4_I8*INT(nf, I8)*INT(MAX(kc, 1), I8) .AND. & + 2_I8*INT(lanczos_max_basis(nf, nwant), I8)*INT(nf, I8) <= LZ_MAX_WORDS + END FUNCTION lanczos_cheaper + + SUBROUTINE lanczos_lowest(ac, bc, kc, nwant, w, ok) + !! The nwant lowest eigenvalues w (ascending) of K x = lambda M x, K and M symmetric in + !! upper band storage of half-bandwidth kc, both positive definite: a block Lanczos + !! iteration (block LZ_BLOCK, a deterministic pseudo-random start, full + !! M-reorthogonalisation by two passes of classical Gram-Schmidt) on the shift-invert + !! operator S = K^-1 M (banded Cholesky factor of K), whose largest eigenvalues + !! theta = 1/lambda belong to the wanted modes. The Ritz values are those of the + !! projection V^T M S V, each accepted on its Lanczos residual, the norm of + !! S x - theta x. Shift-invert keeps the low end of the spectrum to relative accuracy + !! where a band reduction loses it to the round-off of the axial stiffness. The count is + !! certified: the Cholesky factor of K shows that no eigenvalue lies at or below zero, + !! and the inertia of K - sigma M (sturm_count) at a shift sigma in a gap above the + !! wanted values equals the number of Ritz values below sigma, so no mode is missed (a + !! frequency repeated more often than the block size, an eigenvector the start misses) + !! and none is counted twice. ok is false (w undefined) when K or M is not positive + !! definite, the basis reaches lanczos_max_basis unconverged or uncertified, or a LAPACK + !! call fails; the caller then uses the band reduction. + REAL(wp), INTENT(IN) :: ac(:, :), bc(:, :) + INTEGER, INTENT(IN) :: kc, nwant + REAL(wp), INTENT(OUT) :: w(:) + LOGICAL, INTENT(OUT) :: ok + INTEGER :: nf, mmax, nb, c0, pb, pn, nnew, i, j, r, k, info, lwork, next_check, pass + INTEGER(I8) :: seed + REAL(wp), ALLOCATABLE :: fk(:, :), v(:, :), mv(:, :), wb(:, :), mwb(:, :), h(:, :), t(:, :), theta(:) + REAL(wp), ALLOCATABLE :: work(:), c(:, :), cc(:), est(:), lam(:), y(:), my(:) + REAL(wp) :: wq(1), g(LZ_BLOCK, LZ_BLOCK), ref(LZ_BLOCK), thmax, sigma, avg + LOGICAL :: good, conv + EXTERNAL :: dpbtrf, dpbtrs, dsyev + + ok = .FALSE. + w = CD_ZERO + nf = SIZE(ac, 2) + IF (nwant < 1 .OR. nwant > nf .OR. nf < 2*LZ_BLOCK) RETURN + mmax = lanczos_max_basis(nf, nwant) + ! M, then K, positive definite (the factor of M is not used further) + ALLOCATE (fk(kc + 1, nf)) + fk = bc + CALL dpbtrf('U', nf, kc, fk, kc + 1, info) + IF (info /= 0) RETURN + fk = ac + CALL dpbtrf('U', nf, kc, fk, kc + 1, info) + IF (info /= 0) RETURN + ALLOCATE (v(nf, mmax), mv(nf, mmax), wb(nf, LZ_BLOCK), mwb(nf, LZ_BLOCK), h(mmax, mmax), t(mmax, mmax), & + theta(mmax), c(mmax, LZ_BLOCK), cc(mmax), est(mmax), lam(mmax), y(nf), my(nf)) + h = CD_ZERO + t = CD_ZERO + CALL dsyev('V', 'U', mmax, t, mmax, theta, wq, -1, info) + IF (info /= 0) RETURN + lwork = MAX(3*mmax, INT(wq(1))) + ALLOCATE (work(lwork)) + + nb = 0 + DO j = 1, LZ_BLOCK + CALL random_vector(j) + CALL append(1, SQRT(SUM(y*y)), good) + IF (.NOT. good) RETURN + END DO + c0 = 0 + next_check = MIN(nwant + 1, nf) + DO + ! the block c0 + 1..nb: wb = S v, projected out of the basis (twice), the projection + ! coefficients the new columns of H = V^T M S V + pb = nb - c0 + wb(:, 1:pb) = mv(:, c0 + 1:nb) + CALL dpbtrs('U', nf, kc, pb, fk, kc + 1, wb, nf, info) + IF (info /= 0) RETURN + IF (.NOT. CD_All_Finite(wb(:, 1:pb))) RETURN + DO j = 1, pb + ref(j) = SQRT(SUM(wb(:, j)**2)) + END DO + DO pass = 1, 2 + c(1:nb, 1:pb) = MATMUL(TRANSPOSE(mv(:, 1:nb)), wb(:, 1:pb)) + wb(:, 1:pb) = wb(:, 1:pb) - MATMUL(v(:, 1:nb), c(1:nb, 1:pb)) + h(1:nb, c0 + 1:nb) = h(1:nb, c0 + 1:nb) + c(1:nb, 1:pb) + END DO + h(c0 + 1:nb, 1:c0) = TRANSPOSE(h(1:c0, c0 + 1:nb)) + DO j = c0 + 1, nb + DO i = c0 + 1, j - 1 + avg = 0.5_wp*(h(i, j) + h(j, i)) + h(i, j) = avg + h(j, i) = avg + END DO + END DO + + IF (nb >= next_check .OR. nb == nf .OR. nb >= mmax) THEN + next_check = nb + MAX(LZ_BLOCK, nb/8) + ! Ritz values lam(r) = 1/theta, ascending, and the norms est(r) of their residuals + ! wb s (s the Ritz vector's components on the last block) + t(1:nb, 1:nb) = h(1:nb, 1:nb) + CALL dsyev('V', 'U', nb, t, mmax, theta, work, lwork, info) + IF (info /= 0) RETURN + DO j = 1, pb + CALL sym_band_mv(bc, kc, wb(:, j), mwb(:, j)) + END DO + DO j = 1, pb + DO i = 1, pb + g(i, j) = DOT_PRODUCT(wb(:, i), mwb(:, j)) + END DO + END DO + thmax = theta(nb) + DO r = 1, nb + k = nb + 1 - r + lam(r) = HUGE(CD_ONE) + IF (theta(k) > CD_ZERO) lam(r) = CD_ONE/theta(k) + est(r) = SQRT(MAX(DOT_PRODUCT(t(c0 + 1:nb, k), MATMUL(g(1:pb, 1:pb), t(c0 + 1:nb, k))), CD_ZERO)) + END DO + conv = .TRUE. + DO r = 1, nwant + conv = conv .AND. converged_ritz(r, LZ_TOL) .AND. est(r) <= LZ_TOL_MAX*theta(nb + 1 - r) + END DO + IF (conv) THEN + IF (nb == nf) THEN + ! a complete basis: its Ritz values are the eigenvalues + ok = .TRUE. + ELSE + ! the first gap above the wanted values with every Ritz value below it converged + DO k = nwant, nb - 1 + IF (k > nwant .AND. .NOT. converged_ritz(k, LZ_TOL_GUARD)) EXIT + IF (.NOT. theta(nb - k) > CD_ZERO) EXIT + IF (lam(k + 1) - lam(k) >= LZ_GAP*lam(k + 1)) THEN + sigma = 0.5_wp*(lam(k) + lam(k + 1)) + ok = sturm_count(ac, bc, kc, sigma) == k + EXIT + END IF + END DO + END IF + IF (ok) THEN + w(1:nwant) = lam(1:nwant) + RETURN + END IF + END IF + END IF + IF (nb >= mmax .OR. nb == nf) RETURN + + ! the next block: the projected wb, M-orthonormalised (a column that has vanished, an + ! invariant subspace, is replaced by a fresh pseudo-random vector) + pn = MIN(LZ_BLOCK, nf - nb, mmax - nb, pb) + nnew = nb + DO j = 1, pn + y = wb(:, j) + CALL append(nnew + 1, ref(j), good) + IF (.NOT. good) THEN + CALL random_vector(nb + LZ_BLOCK + 1) + CALL append(1, SQRT(SUM(y*y)), good) + IF (.NOT. good) RETURN + END IF + END DO + c0 = nnew + END DO + + CONTAINS + + LOGICAL FUNCTION converged_ritz(r, tol) RESULT(yes) + !! Ritz value r (ascending) converged to tol relative to its theta, or to round-off of + !! the largest theta. + INTEGER, INTENT(IN) :: r + REAL(wp), INTENT(IN) :: tol + yes = theta(nb + 1 - r) > CD_ZERO .AND. est(r) <= MAX(tol*theta(nb + 1 - r), LZ_FLOOR*thmax) + END FUNCTION converged_ritz + + SUBROUTINE random_vector(stream) + !! y: a deterministic pseudo-random vector of the given stream. + INTEGER, INTENT(IN) :: stream + INTEGER :: ir + seed = 24680_I8 + 7919_I8*INT(stream, I8) + DO ir = 1, nf + seed = MOD(69069_I8*seed + 1_I8, 2147483647_I8) + y(ir) = REAL(seed, wp)/2147483647.0_wp - 0.5_wp + END DO + END SUBROUTINE random_vector + + SUBROUTINE append(lo, yref, good) + !! y orthogonalised (two passes) against the basis vectors lo..nb, M-normalised and + !! appended; good is false when it has vanished against its reference norm yref. + INTEGER, INTENT(IN) :: lo + REAL(wp), INTENT(IN) :: yref + LOGICAL, INTENT(OUT) :: good + INTEGER :: ip + REAL(wp) :: nrm2 + good = .FALSE. + DO ip = 1, 2 + IF (nb < lo) EXIT + cc(1:nb - lo + 1) = MATMUL(TRANSPOSE(mv(:, lo:nb)), y) + y = y - MATMUL(v(:, lo:nb), cc(1:nb - lo + 1)) + END DO + CALL sym_band_mv(bc, kc, y, my) + nrm2 = DOT_PRODUCT(y, my) + IF (.NOT. (nrm2 > CD_ZERO .AND. SQRT(SUM(y*y)) > LZ_BREAK*yref .AND. nrm2 <= HUGE(nrm2))) RETURN + nb = nb + 1 + v(:, nb) = y/SQRT(nrm2) + mv(:, nb) = my/SQRT(nrm2) + good = .TRUE. + END SUBROUTINE append + + END SUBROUTINE lanczos_lowest + + INTEGER FUNCTION sturm_count(ac, bc, kc, sigma) RESULT(cnt) + !! The number of eigenvalues of K x = lambda M x below sigma, K and M symmetric in upper + !! band storage of half-bandwidth kc and M positive definite: by Sylvester's law of + !! inertia, the number of negative pivots of the LDL^T factorisation of K - sigma M + !! without pivoting (which keeps the band). -1 when a pivot is zero or not finite. + REAL(wp), INTENT(IN) :: ac(:, :), bc(:, :), sigma + INTEGER, INTENT(IN) :: kc + REAL(wp), ALLOCATABLE :: a(:, :) + REAL(wp) :: d, f + INTEGER :: nf, k, i, j + nf = SIZE(ac, 2) + ALLOCATE (a(kc + 1, nf)) + a = ac - sigma*bc + cnt = 0 + DO k = 1, nf + d = a(kc + 1, k) + IF (.NOT. (ABS(d) > CD_ZERO .AND. ABS(d) <= HUGE(d))) THEN + cnt = -1 + RETURN + END IF + IF (d < CD_ZERO) cnt = cnt + 1 + ! the trailing block of the band: a(i, j) -= a(k, i) a(k, j) / d, k < i <= j <= k + kc + DO j = k + 1, MIN(nf, k + kc) + f = a(kc + 1 + k - j, j)/d + DO i = k + 1, j + a(kc + 1 + i - j, j) = a(kc + 1 + i - j, j) - f*a(kc + 1 + k - i, i) + END DO + END DO + END DO + END FUNCTION sturm_count + SUBROUTINE CD_Modal_Solve(K, M, free, n_modes, omega2, shapes, n_found, ErrStat, ErrMsg) !! Dense reference solver: the n_modes lowest eigenpairs of K phi = omega^2 M phi on the !! free DOFs (DSYGV, symmetric parts of K and M), for lines of at most 4000 free DOFs. diff --git a/tests/test_lapack_guard_pages.c b/tests/test_lapack_guard_pages.c index b55d69c..172df57 100644 --- a/tests/test_lapack_guard_pages.c +++ b/tests/test_lapack_guard_pages.c @@ -2,14 +2,16 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University * - * The modal eigensolves (DSYGV, the dense reference; DSBGVX, the banded production solver) - * called through CableDyn's LAPACK layer -- in Windows GNU builds the run-time forwarders to - * openblas.dll -- with every argument array placed against a no-access page, at its end and, - * in a second pass, at its start. Any read or write outside the arrays the modal code passes - * faults at once and names the call. Sizes cover the test_modal cases and more (n <= 96, half - * band widths 0..11), with the argument sizes CableDyn_Modal uses (WORK 7n and IWORK 5n for - * DSBGVX, the queried LWORK for DSYGV). CTest runs it with OPENBLAS_CORETYPE=Haswell, the - * kernel selected on the hosts where test_modal was seen to fault. + * The modal eigensolves (DSYGV, the dense reference; DSBGVX, the band reduction; DPBTRF and + * DPBTRS, the banded Cholesky factor and solves of the shift-invert Lanczos stage; DSYEV, its + * projected eigenproblem) called through CableDyn's LAPACK layer -- in Windows GNU builds the + * run-time forwarders to openblas.dll -- with every argument array placed against a no-access + * page, at its end and, in a second pass, at its start. Any read or write outside the arrays + * the modal code passes faults at once and names the call. Sizes cover the test_modal cases and + * more (n <= 96, half band widths 0..11), with the argument sizes CableDyn_Modal uses (WORK 7n + * and IWORK 5n for DSBGVX, two right-hand sides for DPBTRS, the queried LWORK for DSYGV and + * DSYEV). CTest runs it with OPENBLAS_CORETYPE=Haswell, the kernel selected on the hosts where + * test_modal was seen to fault. */ #if !defined(_WIN32) #define _DEFAULT_SOURCE @@ -28,6 +30,11 @@ void dsbgvx_(const char *jobz, const char *range, const char *uplo, const int *n const double *vu, const int *il, const int *iu, const double *abstol, int *m, double *w, double *z, const int *ldz, double *work, int *iwork, int *ifail, int *info, size_t jobz_len, size_t range_len, size_t uplo_len); +void dsyev_(const char *jobz, const char *uplo, const int *n, double *a, const int *lda, double *w, double *work, + const int *lwork, int *info, size_t jobz_len, size_t uplo_len); +void dpbtrf_(const char *uplo, const int *n, const int *kd, double *ab, const int *ldab, int *info, size_t uplo_len); +void dpbtrs_(const char *uplo, const int *n, const int *kd, const int *nrhs, const double *ab, const int *ldab, + double *b, const int *ldb, int *info, size_t uplo_len); static char where[96] = "start"; @@ -160,6 +167,25 @@ int main(void) fprintf(stderr, "FAIL: DSYGV n = %d returned INFO = %d\n", n, info); ++failures; } + for (j = 0; j < n; ++j) { + for (i = 0; i <= j; ++i) { + a[i + j * n] = a[j + i * n] = next_random(&state) - 0.5; + } + } + lwork = -1; + snprintf(where, sizeof where, "DSYEV workspace query, n = %d", n); + dsyev_("V", "U", &n, a, &n, w, &query, &lwork, &info, 1, 1); + lwork = (int)query; + if (lwork < 3 * n) { + lwork = 3 * n; + } + work = guarded(sizeof(double) * (size_t)lwork, front); + snprintf(where, sizeof where, "DSYEV, n = %d, %s guard", n, front ? "front" : "end"); + dsyev_("V", "U", &n, a, &n, w, work, &lwork, &info, 1, 1); + if (info != 0) { + fprintf(stderr, "FAIL: DSYEV n = %d returned INFO = %d\n", n, info); + ++failures; + } for (kd = 0; kd <= 11 && kd < n; ++kd) { int ld = kd + 1, il = 1, iu = n < 6 ? n : 6, m = 0, one = 1; double vl = 0.0, vu = 0.0, tol = 4.4501477170144028e-308, q = 0.0, z = 0.0; @@ -183,12 +209,40 @@ int main(void) fprintf(stderr, "FAIL: DSBGVX n = %d kd = %d returned INFO = %d, M = %d\n", n, kd, info, m); ++failures; } + { + int two = 2; + double *fk = guarded(sizeof(double) * (size_t)ld * (size_t)n, front); + double *rhs = guarded(sizeof(double) * 2 * (size_t)n, front); + for (j = 0; j < n; ++j) { + for (i = j - kd > 0 ? j - kd : 0; i <= j; ++i) { + fk[(kd + i - j) + j * ld] = i == j ? 2.0 * n + kd : next_random(&state) - 0.5; + } + rhs[j] = next_random(&state); + rhs[j + n] = next_random(&state); + } + snprintf(where, sizeof where, "DPBTRF, n = %d, kd = %d, %s guard", n, kd, + front ? "front" : "end"); + dpbtrf_("U", &n, &kd, fk, &ld, &info, 1); + if (info != 0) { + fprintf(stderr, "FAIL: DPBTRF n = %d kd = %d returned INFO = %d\n", n, kd, info); + ++failures; + continue; + } + snprintf(where, sizeof where, "DPBTRS, n = %d, kd = %d, %s guard", n, kd, + front ? "front" : "end"); + dpbtrs_("U", &n, &kd, &two, fk, &ld, rhs, &n, &info, 1); + if (info != 0) { + fprintf(stderr, "FAIL: DPBTRS n = %d kd = %d returned INFO = %d\n", n, kd, info); + ++failures; + } + } } } } if (failures != 0) { return 1; } - printf("PASS: DSYGV and DSBGVX stay inside the arrays the modal code passes (n <= 96)\n"); + printf("PASS: DSYGV, DSYEV, DSBGVX, DPBTRF and DPBTRS stay inside the arrays the modal code passes " + "(n <= 96)\n"); return 0; } diff --git a/tests/test_modal.f90 b/tests/test_modal.f90 index a6cdee8..7fef0e7 100644 --- a/tests/test_modal.f90 +++ b/tests/test_modal.f90 @@ -41,6 +41,8 @@ END FUNCTION blas_config CALL case_hermite_beam() CALL case_hermite_long_beam() CALL case_slack_string() + CALL case_solver_agreement() + CALL case_repeated_frequency() CALL case_irvine() CALL case_deck_hermite_and_rules() IF (nfail > 0) THEN @@ -201,23 +203,35 @@ SUBROUTINE case_hermite_beam() END SUBROUTINE case_hermite_beam SUBROUTINE check_band_against_dense(label, k, m, kb, mb, kd, free, om2_dense) - !! The banded solver on the band form of the dense matrices k, m: the frequencies of the - !! dense reference, and shapes that are M-orthonormal, zero on the held DOFs and satisfy - !! K phi = omega^2 M phi. + !! The banded solver on the band form of the dense matrices k, m, with each eigenvalue + !! stage (the shift-invert Lanczos iteration and the band reduction): the frequencies of + !! the dense reference, and shapes that are M-orthonormal, zero on the held DOFs and + !! satisfy K phi = omega^2 M phi. CHARACTER(*), INTENT(IN) :: label REAL(wp), INTENT(IN) :: k(:, :), m(:, :), kb(:, :), mb(:, :), om2_dense(:) INTEGER, INTENT(IN) :: kd LOGICAL, INTENT(IN) :: free(:) + CALL check_band_method(label//'[lanczos]', k, m, kb, mb, kd, free, om2_dense, CD_MODAL_EIG_LANCZOS) + CALL check_band_method(label//'[band-reduction]', k, m, kb, mb, kd, free, om2_dense, CD_MODAL_EIG_BANDRED) + END SUBROUTINE check_band_against_dense + + SUBROUTINE check_band_method(label, k, m, kb, mb, kd, free, om2_dense, method) + !! check_band_against_dense with one eigenvalue stage, which must be the one used. + CHARACTER(*), INTENT(IN) :: label + REAL(wp), INTENT(IN) :: k(:, :), m(:, :), kb(:, :), mb(:, :), om2_dense(:) + INTEGER, INTENT(IN) :: kd, method + LOGICAL, INTENT(IN) :: free(:) REAL(wp), ALLOCATABLE :: om2(:), sh(:, :), ksh(:, :), msh(:, :), gram(:, :) REAL(wp) :: knorm, fdiff, resid, ortho - INTEGER :: nm, nf, es, j + INTEGER :: nm, nf, es, j, used LOGICAL :: held_zero CHARACTER(256) :: em nm = SIZE(om2_dense) ALLOCATE (om2(nm), sh(SIZE(free), nm)) - CALL CD_Modal_Solve_Band(kb, mb, kd, free, nm, om2, sh, nf, es, em) + CALL CD_Modal_Solve_Band(kb, mb, kd, free, nm, om2, sh, nf, es, em, eig_method=method, eig_used=used) CALL require(es == CD_MODAL_OK .AND. nf == nm, label//':band-solve: '//TRIM(em)) IF (es /= CD_MODAL_OK .OR. nf /= nm) RETURN + CALL require(used == method, label//':eigenvalue-stage-used') knorm = MAXVAL(SUM(ABS(k), 1)) fdiff = 0.0_wp DO j = 1, nm @@ -244,7 +258,7 @@ SUBROUTINE check_band_against_dense(label, k, m, kb, mb, kd, free, om2_dense) CALL require(resid < 1.0e-9_wp, label//':band-shape-residual') CALL require(ortho < 1.0e-8_wp, label//':band-shapes-M-orthonormal') CALL require(held_zero, label//':band-held-dofs-zero') - END SUBROUTINE check_band_against_dense + END SUBROUTINE check_band_method SUBROUTINE case_slack_string() !! A slack tension-only string has no stiffness (K = 0): every frequency is zero and the @@ -252,7 +266,7 @@ SUBROUTINE case_slack_string() INTEGER, PARAMETER :: NE = 20, NN = NE + 1, NM = 6 REAL(wp) :: q(3*NN), l0(NE), ea_(NE), rho_a(NE), om2(NM), gram(NM, NM) REAL(wp), ALLOCATABLE :: kb(:, :), mb(:, :), k(:, :), m(:, :), sh(:, :) - INTEGER :: conn(2, NE), i, es, nf, kd + INTEGER :: conn(2, NE), i, es, nf, kd, used LOGICAL :: free(3*NN) CHARACTER(256) :: em ALLOCATE (k(3*NN, 3*NN), m(3*NN, 3*NN), sh(3*NN, NM)) @@ -280,18 +294,25 @@ SUBROUTINE case_slack_string() gram(i, i) = gram(i, i) - 1.0_wp END DO CALL require(nan_max_abs(gram) < 1.0e-10_wp, 'slack:shapes-M-orthonormal') + ! K = 0 is not positive definite: the Lanczos stage hands over to the band reduction + CALL CD_Modal_Solve_Band(kb, mb, kd, free, NM, om2, sh, nf, es, em, eig_method=CD_MODAL_EIG_LANCZOS, & + eig_used=used) + CALL require(es == CD_MODAL_OK .AND. nf == NM .AND. used == CD_MODAL_EIG_BANDRED, & + 'slack:lanczos-hands-over: '//TRIM(em)) + CALL require(nan_max_abs(om2) < 1.0e-9_wp, 'slack:lanczos-hands-over-zero-frequencies') END SUBROUTINE case_slack_string SUBROUTINE case_hermite_long_beam() !! A pre-tensioned 1024-element beam-string (6150 DOFs, beyond the dense reference): the !! banded solver against the closed-form frequencies; each repeated frequency (the two - !! transverse planes) gets shapes in both planes. + !! transverse planes) gets shapes in both planes. A line this long takes the shift-invert + !! Lanczos eigenvalue stage. INTEGER, PARAMETER :: NE = 1024, NN = NE + 1, NM = 10 REAL(wp), PARAMETER :: L = 500.0_wp, EI = 1.0e6_wp, RHO = 100.0_wp, EPS = 1.0e-4_wp REAL(wp), ALLOCATABLE :: q(:), l0(:), ea(:), eiv(:), rho_a(:), kb(:, :), mb(:, :), sh(:, :) LOGICAL, ALLOCATABLE :: free(:) REAL(wp) :: om2(NM), t, mc, f_exp, f_got, sy, sz - INTEGER :: i, es, kd, nf, j, n + INTEGER :: i, es, kd, nf, j, n, used INTEGER(8) :: c0, c1, rate CHARACTER(256) :: em ALLOCATE (q(6*NN), l0(NE), ea(NE), eiv(NE), rho_a(NE), sh(6*NN, NM), free(6*NN)) @@ -308,10 +329,11 @@ SUBROUTINE case_hermite_long_beam() CALL SYSTEM_CLOCK(c0, rate) CALL CD_Modal_Hermite_Band(q, l0, ea, eiv, rho_a, kd, kb, mb, es, em) CALL require(es == CD_MODAL_OK, 'long-beam:band-matrices: '//TRIM(em)) - CALL CD_Modal_Solve_Band(kb, mb, kd, free, NM, om2, sh, nf, es, em) + CALL CD_Modal_Solve_Band(kb, mb, kd, free, NM, om2, sh, nf, es, em, eig_used=used) CALL SYSTEM_CLOCK(c1) CALL require(es == CD_MODAL_OK .AND. nf == NM, 'long-beam:band-solve: '//TRIM(em)) IF (es /= CD_MODAL_OK .OR. nf /= NM) RETURN + CALL require(used == CD_MODAL_EIG_LANCZOS, 'long-beam:lanczos-eigenvalues') WRITE (*, '(A,I0,A,F8.3,A)') 'long beam: ', COUNT(free), ' free DOFs, banded solve in ', & REAL(c1 - c0, wp)/REAL(rate, wp), ' s' t = 1.0e9_wp*EPS @@ -330,6 +352,160 @@ SUBROUTINE case_hermite_long_beam() END DO END SUBROUTINE case_hermite_long_beam + SUBROUTINE sagged_line(finite_ei, ne, kb, mb, kd, free) + !! A line sagged on a parabola (span 500 m, sag 60 m, stretch 1e-3, ends held) with the + !! Morison added mass: a finite-EI power cable or an EI = 0 chain, in band storage. + LOGICAL, INTENT(IN) :: finite_ei + INTEGER, INTENT(IN) :: ne + REAL(wp), ALLOCATABLE, INTENT(OUT) :: kb(:, :), mb(:, :) + INTEGER, INTENT(OUT) :: kd + LOGICAL, ALLOCATABLE, INTENT(OUT) :: free(:) + REAL(wp), PARAMETER :: SPAN = 500.0_wp, SAG = 60.0_wp, EPS = 1.0e-3_wp + REAL(wp), ALLOCATABLE :: q(:), l0(:), ea(:), ei(:), rho(:), dia(:), can(:), cat(:), wl(:), xs(:), zs(:) + REAL(wp) :: dzdx, tl + INTEGER :: i, nn, es + INTEGER, ALLOCATABLE :: conn(:, :) + CHARACTER(256) :: em + nn = ne + 1 + ALLOCATE (l0(ne), ea(ne), ei(ne), rho(ne), dia(ne), can(ne), cat(ne), xs(0:ne), zs(0:ne)) + DO i = 0, ne + xs(i) = SPAN*REAL(i, wp)/ne + zs(i) = -4.0_wp*SAG*(xs(i)/SPAN)*(1.0_wp - xs(i)/SPAN) + END DO + DO i = 1, ne + l0(i) = SQRT((xs(i) - xs(i - 1))**2 + (zs(i) - zs(i - 1))**2)/(1.0_wp + EPS) + END DO + dia = 0.16_wp + can = 1.0_wp + cat = 0.0_wp + IF (finite_ei) THEN + ALLOCATE (q(6*nn), free(6*nn)) + DO i = 0, ne + dzdx = -4.0_wp*SAG/SPAN*(1.0_wp - 2.0_wp*xs(i)/SPAN) + tl = SQRT(1.0_wp + dzdx**2) + q(6*i + 1:6*i + 6) = [xs(i), 0.0_wp, zs(i), (1.0_wp + EPS)/tl, 0.0_wp, (1.0_wp + EPS)*dzdx/tl] + END DO + ea = 4.69e8_wp + ei = 1.99e4_wp + rho = 36.7_wp + free = .TRUE. + free(1:3) = .FALSE. + free(6*nn - 5:6*nn - 3) = .FALSE. + CALL CD_Modal_Hermite_Band(q, l0, ea, ei, rho, kd, kb, mb, es, em, am_rho=1025.0_wp, am_waterline=0.0_wp, & + am_diam=dia, am_can=can, am_cat=cat) + ELSE + ALLOCATE (q(3*nn), free(3*nn), wl(nn), conn(2, ne)) + DO i = 0, ne + q(3*i + 1:3*i + 3) = [xs(i), 0.0_wp, zs(i)] + END DO + DO i = 1, ne + conn(:, i) = [i, i + 1] + END DO + ea = 8.0e8_wp + rho = 150.0_wp + wl = 0.0_wp + free = .TRUE. + free(1:3) = .FALSE. + free(3*nn - 2:3*nn) = .FALSE. + CALL CD_Modal_Bar_Band(q, conn, l0, ea, rho, .TRUE., kd, kb, mb, es, em, am_rho=1025.0_wp, am_waterline=wl, & + am_diam=dia, am_can=can, am_cat=cat) + END IF + CALL require(es == CD_MODAL_OK, 'sagged-line:band-matrices: '//TRIM(em)) + END SUBROUTINE sagged_line + + SUBROUTINE case_solver_agreement() + !! The two eigenvalue stages on lines of distinct frequencies: a sagged finite-EI line + !! with added mass (300 elements, 1800 free DOFs) and a sagged EI = 0 chain (400 + !! elements, 1197). The production route takes the shift-invert Lanczos stage and gives + !! the frequencies of the band reduction to 1e-10 and the same shapes, with the same + !! signs (the symmetric sag puts equally large components on mirrored nodes: the first + !! one is positive). + INTEGER, PARAMETER :: NM = 20 + REAL(wp), ALLOCATABLE :: kb(:, :), mb(:, :), sh_l(:, :), sh_b(:, :) + LOGICAL, ALLOCATABLE :: free(:) + REAL(wp) :: om2_l(NM), om2_b(NM), fdiff, sdiff, gap + INTEGER :: kd, nf_l, nf_b, es_l, es_b, used, j, icase + CHARACTER(256) :: em + CHARACTER(16) :: label + DO icase = 1, 2 + IF (icase == 1) THEN + label = 'agree-hermite' + CALL sagged_line(.TRUE., 300, kb, mb, kd, free) + ELSE + label = 'agree-chain' + CALL sagged_line(.FALSE., 400, kb, mb, kd, free) + END IF + IF (ALLOCATED(sh_l)) DEALLOCATE (sh_l, sh_b) + ALLOCATE (sh_l(SIZE(free), NM), sh_b(SIZE(free), NM)) + CALL CD_Modal_Solve_Band(kb, mb, kd, free, NM, om2_l, sh_l, nf_l, es_l, em, eig_used=used) + CALL require(es_l == CD_MODAL_OK .AND. nf_l == NM, TRIM(label)//':solve: '//TRIM(em)) + CALL require(used == CD_MODAL_EIG_LANCZOS, TRIM(label)//':lanczos-eigenvalues') + CALL CD_Modal_Solve_Band(kb, mb, kd, free, NM, om2_b, sh_b, nf_b, es_b, em, eig_method=CD_MODAL_EIG_BANDRED) + CALL require(es_b == CD_MODAL_OK .AND. nf_b == NM, TRIM(label)//':band-reduction-solve: '//TRIM(em)) + IF (es_l /= CD_MODAL_OK .OR. es_b /= CD_MODAL_OK .OR. nf_l /= NM .OR. nf_b /= NM) CYCLE + fdiff = 0.0_wp + sdiff = 0.0_wp + DO j = 1, NM + fdiff = MAX(fdiff, ABS(om2_l(j) - om2_b(j))/ABS(om2_b(j))) + gap = HUGE(1.0_wp) + IF (j > 1) gap = MIN(gap, (om2_b(j) - om2_b(j - 1))/om2_b(j)) + IF (j < NM) gap = MIN(gap, (om2_b(j + 1) - om2_b(j))/om2_b(j)) + IF (gap > 1.0e-4_wp) sdiff = MAX(sdiff, nan_max_abs(sh_l(:, j) - sh_b(:, j))/nan_max_abs(sh_b(:, j))) + END DO + WRITE (*, '(A,2ES11.3)') TRIM(label)//': Lanczos vs band reduction, max relative omega^2 and shape '// & + 'differences: ', fdiff, sdiff + CALL require(fdiff <= 1.0e-10_wp, TRIM(label)//':frequencies-equal') + CALL require(sdiff <= 1.0e-8_wp, TRIM(label)//':shapes-and-signs-equal') + END DO + END SUBROUTINE case_solver_agreement + + SUBROUTINE case_repeated_frequency() + !! A frequency repeated six times, three times the Lanczos block: NN nodes of six DOFs + !! joined along a chain by identical springs on each DOF (ends held), K = L (x) I6 with L + !! the chain Laplacian, M = I, eigenvalues 2 k (1 - cos(j pi/(NN + 1))), each sixfold; the + !! 8 lowest modes cut the second value's copies. The Sturm count of the Lanczos stage + !! rejects a run that misses a copy (without it, a single-vector block accepts one): each + !! stage returns every copy, with M-orthonormal shapes. The same lattice shifted to a + !! negative lowest eigenvalue (K - s M, indefinite): the Lanczos stage hands over to the + !! band reduction. + INTEGER, PARAMETER :: NN = 150, NC = 6, N = NC*NN, NM = 8, KD = NC + REAL(wp), PARAMETER :: KS = 1.0e4_wp + REAL(wp), ALLOCATABLE :: kb(:, :), mb(:, :), sh(:, :), gram(:, :) + REAL(wp) :: om2(NM), lam(NM), shift, err + LOGICAL :: free(N) + INTEGER :: i, j, nf, es, used, method, pass + CHARACTER(256) :: em + ALLOCATE (kb(KD + 1, N), mb(KD + 1, N), sh(N, NM)) + free = .TRUE. + DO pass = 1, 2 + shift = 0.0_wp + IF (pass == 2) shift = 3.0_wp*KS*(1.0_wp - COS(PI/REAL(NN + 1, wp))) + kb = 0.0_wp + mb = 0.0_wp + kb(KD + 1, :) = 2.0_wp*KS - shift + kb(1, KD + 1:N) = -KS + mb(KD + 1, :) = 1.0_wp + DO j = 1, NM + lam(j) = 2.0_wp*KS*(1.0_wp - COS(REAL((j + NC - 1)/NC, wp)*PI/REAL(NN + 1, wp))) - shift + END DO + DO method = CD_MODAL_EIG_AUTO, CD_MODAL_EIG_LANCZOS + CALL CD_Modal_Solve_Band(kb, mb, KD, free, NM, om2, sh, nf, es, em, eig_method=method, eig_used=used) + CALL require(es == CD_MODAL_OK .AND. nf == NM, 'repeated:solve: '//TRIM(em)) + IF (es /= CD_MODAL_OK .OR. nf /= NM) CYCLE + err = MAXVAL(ABS(om2 - lam))/MAXVAL(ABS(lam)) + gram = MATMUL(TRANSPOSE(sh), sh) + DO i = 1, NM + gram(i, i) = gram(i, i) - 1.0_wp + END DO + WRITE (*, '(A,I0,A,I0,A,I0,A,2ES11.3)') 'repeated frequency: pass ', pass, ', method ', method, ', used ', & + used, ': omega^2 error, M-orthonormality error ', err, nan_max_abs(gram) + CALL require(err < 1.0e-9_wp, 'repeated:every-copy-found') + CALL require(nan_max_abs(gram) < 1.0e-10_wp, 'repeated:shapes-M-orthonormal') + IF (pass == 2) CALL require(used == CD_MODAL_EIG_BANDRED, 'repeated:indefinite-hands-over') + END DO + END DO + END SUBROUTINE case_repeated_frequency + ! ------------------------------------------------------------------ deck helpers SUBROUTINE read_table(path, marker, ncol, data, nrow) From 9582feb72fa99b94f364187d6d8f92e3f186ab68 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 00:08:04 +0900 Subject: [PATCH 09/40] perf(hermite): evaluate each element kernel once for load and tangent (bit-identical) (#119) Signed-off-by: Jae Hoon Seo --- doc/cli.rst | 6 +- src/CableDyn_HermiteCableDynamic.f90 | 221 ++++++++++++++++++++++++--- 2 files changed, 203 insertions(+), 24 deletions(-) diff --git a/doc/cli.rst b/doc/cli.rst index 6003ec4..9abd967 100644 --- a/doc/cli.rst +++ b/doc/cli.rst @@ -208,7 +208,11 @@ The counts follow the march: 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 + tangents of each element together. A residual evaluation that a tangent is expected to follow + at the same state (the predictor of a step, and the full Newton step of an iteration the + previous step went past) runs them with the element loads, so their time then counts to the + residual assembly and the tangent assembly only scatters their blocks; 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 diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index 2a209e5..2210e47 100644 --- a/src/CableDyn_HermiteCableDynamic.f90 +++ b/src/CableDyn_HermiteCableDynamic.f90 @@ -363,10 +363,27 @@ MODULE CableDyn_HermiteCableDynamic REAL(wp), ALLOCATABLE :: ws_Kb(:, :), ws_Kvb(:, :) REAL(wp), ALLOCATABLE :: ws_Keffb(:, :), ws_Msumb(:, :), ws_Mamb(:, :) INTEGER, ALLOCATABLE :: ws_ipiv(:) ! band-factorization pivots - REAL(wp), ALLOCATABLE :: ws_elem_f(:, :), ws_elem_K(:, :, :), ws_elem_Kdrag(:, :, :) + REAL(wp), ALLOCATABLE :: ws_elem_K(:, :, :), ws_elem_Kdrag(:, :, :) REAL(wp), ALLOCATABLE :: ws_elem_Kv(:, :, :), ws_elem_Kwave(:, :, :), ws_elem_Kheld(:, :, :) INTEGER, ALLOCATABLE :: ws_elem_es(:) CHARACTER(200), ALLOCATABLE :: ws_elem_em(:) + ! Element tangents kept by a residual evaluation (assemble_residual with stash_tangent): the + ! element loads of each kernel, ws_elem_f(:, k, e) for k = structure, axial damping, drag, VIV + ! lift, wave and held Froude-Krylov, the element tangent blocks in ws_elem_K..ws_elem_Kheld, + ! and the configuration, velocity and time of that evaluation. A tangent assembly at the same + ! state (use_stash) scatters the kept blocks instead of evaluating the kernels again. Every + ! other assembly clears ws_stash_ok. + REAL(wp), ALLOCATABLE :: ws_elem_f(:, :, :), ws_stash_q(:), ws_stash_v(:) + REAL(wp) :: ws_stash_t = CD_ZERO + LOGICAL :: ws_stash_ok = .FALSE. + ! The Newton solves of the last committed step (1 before the first): the predictor, which the + ! first iteration's tangent follows at the same state, keeps its element tangents when the + ! last step made a solve (did not converge at its predictor). + INTEGER :: ws_stash_solves = 1 + ! The tangents the last committed step's line searches assembled at their accepted trial: the + ! full step of a full-Newton line search keeps its element tangents while the step has + ! assembled fewer. + INTEGER :: ws_stash_ls = 0 REAL(wp) :: alpha_m = CD_ZERO, alpha_f = CD_ZERO, beta = CD_ZERO, gamma = CD_ZERO ! Optional Morison drag (transverse + tangential quadratic drag on the submerged cable, relative ! to a uniform fluid velocity). Off by default; wired in via CD_HermiteCable_Dyn_Set_Drag. @@ -844,9 +861,12 @@ SUBROUTINE CD_HermiteCable_Dyn_Init(model, l0, EA, EI, rho_a, w, seed, fixed_dof model%ws_Kb = CD_ZERO; model%ws_Kvb = CD_ZERO; model%ws_Keffb = CD_ZERO model%ws_Msumb = CD_ZERO; model%ws_Mamb = CD_ZERO ALLOCATE (model%ws_ipiv(ndof)) - ALLOCATE (model%ws_elem_f(12, ne), model%ws_elem_K(12, 12, ne), model%ws_elem_Kdrag(12, 12, ne), & + ALLOCATE (model%ws_elem_f(12, 6, ne), model%ws_elem_K(12, 12, ne), model%ws_elem_Kdrag(12, 12, ne), & model%ws_elem_Kv(12, 12, ne), model%ws_elem_Kwave(12, 12, ne), model%ws_elem_Kheld(12, 12, ne), & - model%ws_elem_es(ne), model%ws_elem_em(ne)) + model%ws_elem_es(ne), model%ws_elem_em(ne), model%ws_stash_q(ndof), model%ws_stash_v(ndof)) + model%ws_stash_ok = .FALSE. + model%ws_stash_solves = 1 + model%ws_stash_ls = 0 ! state: at-rest seed, v = 0, consistent a0 ALLOCATE (model%q(ndof), model%v(ndof), model%a(ndof)) @@ -3707,6 +3727,7 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r INTEGER :: ndof, it, es, i, np, ip, ls, gdof, nsolve_step, axial_element, axial_es INTEGER :: axial_worst_element, iv, attempt, n_attempt + INTEGER :: n_ls_tan ! tangents the step's line searches assembled at their accepted trial REAL(wp) :: c_a, c_v, c_a2, am, af, bt, gm, rnorm, fscale, rnt, lambda, chosen, lam_ok, cw, t_alpha, dth_lin REAL(wp) :: e_bal, e_ma, tors_theta_new, tors_mt_new REAL(wp) :: axial_qe(12), axial_min @@ -4103,7 +4124,13 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r ! A carried factor starts the iteration already factored (and stale). mn_factored = xs_active mn_fresh = .FALSE. - CALL eval_res(model%ws_qk, .FALSE., rnorm, es, em2) + n_ls_tan = 0 + ! The first iteration assembles its tangent at the predictor unless a carried factor is + ! reused, so the predictor evaluation keeps its element tangents for it (stash), unless the + ! last step converged at its predictor (made no solve). Either way every result is the same: + ! only where the element kernels run changes. + CALL eval_res(model%ws_qk, .FALSE., rnorm, es, em2, & + stash=nsolve_step < model%ws_stash_solves .AND. .NOT. ((mn .OR. xs_active) .AND. mn_factored)) IF (es /= CD_HCDYN_OK) THEN ErrStat = es; ErrMsg = 'CD_HermiteCable_Dyn_Step: '//TRIM(em2) IF (attempt < n_attempt) CYCLE predict @@ -4157,8 +4184,9 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r IF (.NOT. ((mn .OR. xs_active) .AND. mn_factored)) THEN IF (.NOT. have_tangent) THEN IF (prof_enabled) CALL SYSTEM_CLOCK(pc0) + ! the element tangents the predictor (or refresh) evaluation kept at this very state CALL assemble_residual(model, model%ws_qa, model%ws_va, t_alpha, .FALSE., .TRUE., es, em2, & - endconn_direction_eval=step_endconn_eval) + endconn_direction_eval=step_endconn_eval, use_stash=.TRUE.) IF (prof_enabled) THEN CALL SYSTEM_CLOCK(pc1) prof_n_tan = prof_n_tan + 1 @@ -4280,7 +4308,11 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r model%ws_qtrial(iv) = model%ws_qk(iv) + lambda*model%ws_dq(iv) END DO IF (model%tw_active) CALL tw_trial(lambda) - CALL eval_res(model%ws_qtrial, .TRUE., rnt, es, em2) + ! The full step of a full-Newton iteration keeps its element tangents while this step's + ! line searches have assembled fewer tangents than the last step's (an accepted trial that + ! iterates again assembles its tangent below, at this state). + CALL eval_res(model%ws_qtrial, .TRUE., rnt, es, em2, & + stash=ls == 1 .AND. .NOT. (mn .OR. xs_active) .AND. n_ls_tan < model%ws_stash_ls) IF (es == CD_HCDYN_OK) THEN lam_ok = lambda ! loop shrinks lambda, so this ends smallest IF (rnt < rnorm) THEN @@ -4306,7 +4338,7 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r END IF IF (prof_enabled) CALL SYSTEM_CLOCK(pc0) CALL assemble_residual(model, model%ws_qa, model%ws_va, t_alpha, .FALSE., .TRUE., es, em2, & - endconn_direction_eval=step_endconn_eval) + endconn_direction_eval=step_endconn_eval, use_stash=.TRUE.) IF (prof_enabled) THEN CALL SYSTEM_CLOCK(pc1) prof_n_tan = prof_n_tan + 1 @@ -4314,6 +4346,7 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r END IF IF (es == CD_HCDYN_OK) THEN have_tangent = .TRUE. + n_ls_tan = n_ls_tan + 1 chosen = lambda; EXIT END IF END IF @@ -4342,8 +4375,9 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r ! assembles the fresh tangent at the right point. At most one refresh fires per ! iteration (the rebuild sets mn_fresh, which blocks this guard). If the fresh ! direction still cannot improve, the step is genuinely hard and the fallback - ! below handles it exactly as full Newton would. - CALL eval_res(model%ws_qk, .FALSE., rnorm, es, em2) + ! below handles it exactly as full Newton would. The retry-top tangent is at this + ! evaluation's state, so it keeps its element tangents (stash). + CALL eval_res(model%ws_qk, .FALSE., rnorm, es, em2, stash=.TRUE.) IF (es /= CD_HCDYN_OK) THEN ErrStat = es; ErrMsg = 'CD_HermiteCable_Dyn_Step: '//TRIM(em2) IF (attempt < n_attempt) CYCLE predict @@ -4432,6 +4466,8 @@ SUBROUTINE dyn_step_core(model, dt, max_iter, tol, ErrStat, ErrMsg, iters_out, r END DO EXIT predict END DO predict + model%ws_stash_solves = nsolve_step + model%ws_stash_ls = n_ls_tan ! Force blend: the committed force F_{n+1} enters every later step through af F_n, so the ! accepted state is taken one Newton correction beyond the tolerance, with the factor of @@ -4810,7 +4846,7 @@ LOGICAL FUNCTION force_cache_matches() RESULT(ok) END DO END FUNCTION force_cache_matches - SUBROUTINE eval_res(qcur, use_trial, rn, esx, emx) + SUBROUTINE eval_res(qcur, use_trial, rn, esx, emx, stash) !! gen-alpha residual G = M a_alpha + f_int - f_ext, its banded position/velocity !! tangents, and the dimensionless per-slot residual norm rn at the trial !! configuration qcur. Prescribed DOFs carry their prescribed acceleration/velocity; @@ -4820,11 +4856,15 @@ SUBROUTINE eval_res(qcur, use_trial, rn, esx, emx) !! at the loop top, directly into the committed band buffers) -- through pointers into the !! host's model, so no model subobject is ever argument-associated alongside the !! model itself (which the Fortran aliasing rule would forbid). Uses the ws_* Newmark scratch and the host fscale. + !! stash (optional): an evaluation the next tangent assembly follows at the same state + !! also keeps the element tangents (assemble_residual, stash_tangent); the residual is + !! the same either way. REAL(wp), INTENT(IN) :: qcur(:) LOGICAL, INTENT(IN) :: use_trial REAL(wp), INTENT(OUT) :: rn INTEGER, INTENT(OUT) :: esx CHARACTER(*), INTENT(OUT) :: emx + LOGICAL, INTENT(IN), OPTIONAL :: stash REAL(wp), POINTER :: Gout(:) INTEGER :: ii, nd INTEGER(INT64) :: ec0, ec1 @@ -4836,6 +4876,7 @@ SUBROUTINE eval_res(qcur, use_trial, rn, esx, emx) CALL SYSTEM_CLOCK(ec0) prof_n_resid = prof_n_resid + 1 END IF + model%ws_stash_ok = .FALSE. ! kept element tangents never outlive a new evaluation IF (use_trial) THEN Gout => model%ws_Gt ELSE @@ -4894,7 +4935,7 @@ SUBROUTINE eval_res(qcur, use_trial, rn, esx, emx) END IF CALL vector_blend(CD_ONE - am, model%ws_ak, am, model%ws_an, model%ws_aa) CALL assemble_residual(model, model%ws_qa, model%ws_va, t_alpha, .TRUE., .FALSE., esx, emx, & - endconn_direction_eval=step_endconn_eval) + endconn_direction_eval=step_endconn_eval, stash_tangent=stash) IF (esx /= CD_HCDYN_OK) THEN rn = CD_ZERO IF (prof_enabled) THEN @@ -8363,7 +8404,8 @@ SUBROUTINE CD_HermiteCable_Dyn_End(model) DEALLOCATE (model%ws_ipiv) DEALLOCATE (model%ws_elem_f, model%ws_elem_K, model%ws_elem_Kdrag, model%ws_elem_Kv, & model%ws_elem_Kwave, model%ws_elem_Kheld, & - model%ws_elem_es, model%ws_elem_em) + model%ws_elem_es, model%ws_elem_em, model%ws_stash_q, model%ws_stash_v) + model%ws_stash_ok = .FALSE. END IF 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. @@ -8453,7 +8495,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, node_only, R_out) + endconn_direction_eval, node_only, R_out, stash_tangent, use_stash) !! 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 @@ -8485,11 +8527,25 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! (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(:) + ! stash_tangent: a full residual-only assembly that evaluates each element kernel once for + ! its load AND its tangent (evaluate_tangent_element with forces, in parallel like the + ! tangent assembly), adds the loads to R in the order of the residual-only assembly and keeps + ! the element tangent blocks (ws_stash_ok). R is bit-for-bit that of the residual-only + ! assembly. If any element evaluation fails (a tangent can overflow where the load stays + ! finite), this assembly evaluates the loads again residual-only, so its result and message + ! are those of the residual-only assembly, and keeps nothing. + ! use_stash: a tangent-only assembly at exactly the configuration, velocity and time of the + ! last assembly, when that one kept its element tangents, scatters those blocks instead of + ! evaluating the kernels again. Otherwise it evaluates them as usual. + LOGICAL, INTENT(IN), OPTIONAL :: stash_tangent, use_stash 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 + LOGICAL :: stash ! this residual assembly keeps its element tangents (stash_tangent) + LOGICAL :: kept ! the element loads and tangents of this assembly's kernels are in ws_elem_* + LOGICAL :: reuse ! this tangent assembly scatters the element tangents the last one kept 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) REAL(wp) :: fdamp(12), kdq(12, 12), kdv(12, 12) @@ -8512,6 +8568,29 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta !$ omp_threads = MIN(4, omp_get_max_threads()) !$ IF (omp_in_parallel()) omp_threads = 1 tangent_only = want_tangent .AND. .NOT. want_residual + ! The kept element tangents belong to the last assembly alone: decide whether this tangent + ! assembly is at its exact state, then drop them (a keeping assembly sets them on success). + reuse = .FALSE. + IF (PRESENT(use_stash)) THEN + IF (use_stash .AND. tangent_only .AND. model%ws_stash_ok) THEN + reuse = bits_equal(t_eval, model%ws_stash_t) .AND. SIZE(q_cfg) == SIZE(model%ws_stash_q) .AND. & + SIZE(v_cfg) == SIZE(model%ws_stash_v) + IF (reuse) THEN + DO gi = 1, SIZE(q_cfg) + IF (.NOT. (bits_equal(q_cfg(gi), model%ws_stash_q(gi)) .AND. & + bits_equal(v_cfg(gi), model%ws_stash_v(gi)))) THEN + reuse = .FALSE. + EXIT + END IF + END DO + END IF + END IF + END IF + model%ws_stash_ok = .FALSE. + stash = .FALSE. + IF (PRESENT(stash_tangent)) stash = stash_tangent .AND. want_residual .AND. .NOT. want_tangent .AND. & + .NOT. PRESENT(node_only) .AND. .NOT. PRESENT(R_out) + kept = .FALSE. R => model%ws_R Kb => model%ws_Kb Kvb => model%ws_Kvb @@ -8546,22 +8625,50 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta 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 - ! combined-output requests retain the lower-overhead serial path. A four-thread cap avoids + ! buffers, then validate and scatter serially in the original element order. Plain residual + ! and combined-output requests retain the lower-overhead serial path. A four-thread cap avoids ! tiny-kernel oversubscription while respecting a smaller host OpenMP thread limit. - IF (tangent_only) THEN - !$OMP PARALLEL DEFAULT(SHARED) PRIVATE(qe) NUM_THREADS(omp_threads) IF(ne >= 32) + ! A residual assembly that keeps its element tangents (stash) runs the same parallel + ! kernels with their loads; the loads then enter R serially, phase by phase, in the + ! element order of the residual-only assembly below. A tangent assembly at the kept + ! state (reuse) scatters the kept blocks. + IF (stash) THEN + !$OMP PARALLEL DEFAULT(SHARED) NUM_THREADS(omp_threads) IF(ne >= 32) CALL CD_Fatal_Thread_Init() ! this thread can report its own stack overflow !$OMP DO SCHEDULE(STATIC) DO e = 1, ne CALL evaluate_tangent_element(model, e, q_cfg, v_cfg, t_eval, model%ws_elem_K(:, :, e), & model%ws_elem_Kdrag(:, :, e), model%ws_elem_Kv(:, :, e), & model%ws_elem_Kwave(:, :, e), model%ws_elem_Kheld(:, :, e), & - model%ws_elem_es(e), model%ws_elem_em(e)) + model%ws_elem_es(e), model%ws_elem_em(e), & + forces=model%ws_elem_f(:, :, e)) END DO !$OMP END DO !$OMP END PARALLEL CALL asm_mark(HC_ASM_TAN_KERNELS, asm_t) + kept = .TRUE. + DO e = 1, ne + IF (model%ws_elem_es(e) /= CD_HCABLE_OK) THEN + kept = .FALSE. + EXIT + END IF + END DO + END IF + IF (tangent_only) THEN + IF (.NOT. reuse) THEN + !$OMP PARALLEL DEFAULT(SHARED) PRIVATE(qe) NUM_THREADS(omp_threads) IF(ne >= 32) + CALL CD_Fatal_Thread_Init() ! this thread can report its own stack overflow + !$OMP DO SCHEDULE(STATIC) + DO e = 1, ne + CALL evaluate_tangent_element(model, e, q_cfg, v_cfg, t_eval, model%ws_elem_K(:, :, e), & + model%ws_elem_Kdrag(:, :, e), model%ws_elem_Kv(:, :, e), & + model%ws_elem_Kwave(:, :, e), model%ws_elem_Kheld(:, :, e), & + model%ws_elem_es(e), model%ws_elem_em(e)) + END DO + !$OMP END DO + !$OMP END PARALLEL + CALL asm_mark(HC_ASM_TAN_KERNELS, asm_t) + END IF 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 @@ -8571,6 +8678,13 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta CALL asm_mark(HC_ASM_SCATTER, asm_t) ELSE DO e = e_lo, e_hi + IF (kept) THEN + DO a = 1, 12 + gi = elem_gdof(e, a) + R(gi) = R(gi) + model%ws_elem_f(a, 1, e) + END DO + CYCLE + END IF 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) @@ -8725,6 +8839,13 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! reference-arc Hermite interpolation and axial quadrature as the elastic resultant. IF (model%has_axial_damping .AND. .NOT. tangent_only) THEN DO e = e_lo, e_hi + IF (kept) THEN + DO a = 1, 12 + gi = elem_gdof(e, a) + R(gi) = R(gi) + model%ws_elem_f(a, 2, e) + END DO + CYCLE + END IF 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) @@ -8771,6 +8892,13 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! Kv -= djv over all 12 element DOFs. IF (model%has_drag .AND. .NOT. tangent_only) THEN DO e = e_lo, e_hi + IF (kept) THEN + DO a = 1, 12 + gi = elem_gdof(e, a) + R(gi) = R(gi) - model%ws_elem_f(a, 3, e) + END DO + CYCLE + END IF 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) @@ -8849,6 +8977,13 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! Cross-flow VIV lift (an external load like the drag, at the step's phase viv_phi_eval). IF (model%has_viv .AND. .NOT. tangent_only) THEN DO e = e_lo, e_hi + IF (kept) THEN + DO a = 1, 12 + gi = elem_gdof(e, a) + R(gi) = R(gi) - model%ws_elem_f(a, 4, e) + END DO + CYCLE + END IF IF (want_tangent) THEN CALL viv_element_lift(model, q_cfg, v_cfg, e, fdrag, es, em_e, djq, djv) ELSE @@ -8903,6 +9038,13 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! 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 = e_lo, e_hi + IF (kept) THEN + DO a = 1, 12 + gi = elem_gdof(e, a) + R(gi) = R(gi) - model%ws_elem_f(a, 5, e) + END DO + CYCLE + END IF 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 @@ -8957,6 +9099,13 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta ! 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 = e_lo, e_hi + IF (kept) THEN + DO a = 1, 12 + gi = elem_gdof(e, a) + R(gi) = R(gi) - model%ws_elem_f(a, 6, e) + END DO + CYCLE + END IF 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 @@ -9011,6 +9160,13 @@ SUBROUTINE assemble_residual(model, q_cfg, v_cfg, t_eval, want_residual, want_ta END IF END IF CALL asm_mark(HC_ASM_TORSION, asm_t) + ! A complete keeping assembly records the state its element tangents belong to. + IF (kept) THEN + model%ws_stash_q = q_cfg + model%ws_stash_v = v_cfg + model%ws_stash_t = t_eval + model%ws_stash_ok = .TRUE. + END IF END SUBROUTINE assemble_residual SUBROUTINE assemble_torsion(model, q_cfg, want_residual, want_tangent, ErrStat, ErrMsg) @@ -10253,10 +10409,16 @@ SUBROUTINE scatter_contact_band(ab, row_dof, col_dof, value) END SUBROUTINE scatter_contact_band SUBROUTINE evaluate_tangent_element(model, e, q_cfg, v_cfg, t_eval, Kstruct, Kdrag, Kvel, Kwave, Kheld, & - ErrStat, ErrMsg) + ErrStat, ErrMsg, forces) !! Evaluate the complete element-local Newton package. Keeping structural, drag and !! fluid-inertia tangents in one worker avoids three OpenMP regions per iteration; !! the caller scatters these private blocks in deterministic element order. + !! forces (optional) also returns the element load of each kernel from the same call: + !! forces(:, k) for k = 1 structure (f_int), 2 axial damping, 3 drag, 4 VIV lift, 5 wave and + !! 6 held Froude-Krylov (the loads as the kernels return them, before the residual's signs). + !! Every kernel forms its load by the same statements with or without its tangent, so these + !! are bit-for-bit the loads of a residual-only evaluation. The columns of the kernels the + !! model does not configure are not defined. TYPE(CD_HermiteCableDynType), INTENT(IN) :: model INTEGER, INTENT(IN) :: e REAL(wp), INTENT(IN) :: q_cfg(:), v_cfg(:), t_eval @@ -10264,6 +10426,7 @@ SUBROUTINE evaluate_tangent_element(model, e, q_cfg, v_cfg, t_eval, Kstruct, Kdr REAL(wp), INTENT(OUT) :: Kwave(12, 12), Kheld(12, 12) INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg + REAL(wp), INTENT(OUT), OPTIONAL :: forces(12, 6) REAL(wp) :: qe(12), ve(12), energy, force(12), fload(12) REAL(wp) :: djq(12, 12), djv(12, 12), fjq(12, 12) @@ -10284,10 +10447,17 @@ SUBROUTINE evaluate_tangent_element(model, e, q_cfg, v_cfg, t_eval, Kstruct, Kdr ! so they are not cleared. Kdrag = CD_ZERO; Kvel = CD_ZERO - CALL CD_HermiteCable_Element(qe, model%l0(e), model%EA(e), model%EI(e), energy, force, Kstruct, es, em, & - symmetric_half=.TRUE., tangent_only=.TRUE., & - axial_quadrature_order=model%axial_quadrature_order, & - bending_quadrature_order=model%bending_quadrature_order) + IF (PRESENT(forces)) THEN + CALL CD_HermiteCable_Element(qe, model%l0(e), model%EA(e), model%EI(e), energy, forces(:, 1), Kstruct, & + es, em, symmetric_half=.TRUE., & + axial_quadrature_order=model%axial_quadrature_order, & + bending_quadrature_order=model%bending_quadrature_order) + ELSE + CALL CD_HermiteCable_Element(qe, model%l0(e), model%EA(e), model%EI(e), energy, force, Kstruct, es, em, & + symmetric_half=.TRUE., tangent_only=.TRUE., & + axial_quadrature_order=model%axial_quadrature_order, & + bending_quadrature_order=model%bending_quadrature_order) + END IF IF (es /= CD_HCABLE_OK) THEN ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = 'element: '//TRIM(em); RETURN END IF @@ -10305,6 +10475,7 @@ SUBROUTINE evaluate_tangent_element(model, e, q_cfg, v_cfg, t_eval, Kstruct, Kdr IF (es /= CD_HCDYN_OK) THEN ErrStat = CD_HCDYN_NOCONVERGE; ErrMsg = TRIM(em); RETURN END IF + IF (PRESENT(forces)) forces(:, 2) = fload Kdrag = Kdrag + djq Kvel = Kvel + djv END IF @@ -10336,6 +10507,7 @@ SUBROUTINE evaluate_tangent_element(model, e, q_cfg, v_cfg, t_eval, Kstruct, Kdr IF (es /= CD_HCDYN_OK) THEN ErrStat = es; ErrMsg = TRIM(em); RETURN END IF + IF (PRESENT(forces)) forces(:, 3) = fload Kdrag = Kdrag - djq Kvel = Kvel - djv END IF @@ -10345,6 +10517,7 @@ SUBROUTINE evaluate_tangent_element(model, e, q_cfg, v_cfg, t_eval, Kstruct, Kdr IF (es /= CD_HCDYN_OK) THEN ErrStat = es; ErrMsg = TRIM(em); RETURN END IF + IF (PRESENT(forces)) forces(:, 4) = fload Kdrag = Kdrag - djq Kvel = Kvel - djv END IF @@ -10367,6 +10540,7 @@ SUBROUTINE evaluate_tangent_element(model, e, q_cfg, v_cfg, t_eval, Kstruct, Kdr IF (es /= CD_HCDYN_OK) THEN ErrStat = es; ErrMsg = TRIM(em); RETURN END IF + IF (PRESENT(forces)) forces(:, 5) = fload Kwave = -fjq END IF @@ -10382,6 +10556,7 @@ SUBROUTINE evaluate_tangent_element(model, e, q_cfg, v_cfg, t_eval, Kstruct, Kdr IF (es /= CD_HCDYN_OK) THEN ErrStat = es; ErrMsg = TRIM(em); RETURN END IF + IF (PRESENT(forces)) forces(:, 6) = fload Kheld = -fjq END IF END SUBROUTINE evaluate_tangent_element From 7e3938cbff501539818610a4f02ef9543389dd9e Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 00:17:56 +0900 Subject: [PATCH 10/40] fix(statics): smear the attachment drag in a cross current (#110) Signed-off-by: Jae Hoon Seo --- CMakeLists.txt | 22 +- doc/driver_format.md | 5 +- doc/theory.rst | 24 +- src/CableDyn_DeckDriver.f90 | 114 ++++++++-- tests/test_attachment_cross_current.py | 21 +- tests/test_attachment_polish_current.py | 289 ++++++++++++++++++++++++ 6 files changed, 437 insertions(+), 38 deletions(-) create mode 100644 tests/test_attachment_polish_current.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 96dcf84..aa169d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1758,9 +1758,8 @@ if(Python3_Interpreter_FOUND) TIMEOUT 600 ENVIRONMENT "${_cabledyn_python_test_environment}") - # Discrete buoyancy modules on a lazy wave in a cross current (the attachment polish falls - # back from diverging Newton steps on the frictional seabed): the static solve converges and - # its end tensions match the equivalent smeared section. + # Discrete buoyancy modules on a lazy wave in a cross current on a frictional seabed: the static + # solve converges without a fallback and its end tensions match the equivalent smeared section. add_test(NAME attachment_cross_current COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_attachment_cross_current.py --exe $ @@ -1967,8 +1966,23 @@ if(Python3_Interpreter_FOUND) LABELS "python;integration;static;dynamic;capi" TIMEOUT 600 ENVIRONMENT "${_cabledyn_python_test_environment}") + # Discrete buoyancy modules in oblique and cross currents (speed, heading, module count and + # mesh refinement swept): the attachment polish converges without its fallbacks on a frictional + # seabed, and a mid-water lazy wave's end forces balance its weight, buoyancy and drag (C API). + # PROCESSORS reserves the four driver processes the test runs at once. + add_test(NAME attachment_polish_current + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_attachment_polish_current.py + --exe $ + --library ${_node_channels_library} + --source ${CMAKE_CURRENT_SOURCE_DIR} + --output ${CMAKE_BINARY_DIR}/attachment_polish_current) + set_tests_properties(attachment_polish_current PROPERTIES + LABELS "python;hermite;static;robustness;attachments;capi" + PROCESSORS 4 + TIMEOUT 900 + ENVIRONMENT "${_cabledyn_python_test_environment}") else() - message(STATUS "node_channels_refined_line: skipped (numpy not importable in ${Python3_EXECUTABLE})") + message(STATUS "node_channels_refined_line, attachment_polish_current: skipped (numpy not importable in ${Python3_EXECUTABLE})") endif() # The arbiter imports numpy + scipy at load time; only register the test when they are diff --git a/doc/driver_format.md b/doc/driver_format.md index 84d808e..eb2d010 100644 --- a/doc/driver_format.md +++ b/doc/driver_format.md @@ -1041,8 +1041,9 @@ Behaviour: with the same drag law at rest, so a run in a current starts in equilibrium. An attachment above the free surface carries its weight only, in the static initial condition too. Attachments have no seabed contact of their own; the node's contact acts on the line. -- The static equilibrium is solved first with each attachment's net weight spread over the two - elements at its node, then carried to the discrete loads by continuation. The current drag +- The static equilibrium is solved first with each attachment's net weight, and in a current + across the vertical plane of the line its drag areas, spread over the two elements at its node, + then carried to the discrete loads by continuation. The current drag depends on the node's position and line direction, and the weight on the side of the surface an attachment is on, so the final solve is repeated until the node positions change by less than 1e-9 m and no attachment changes side. On a line restrained in torsion the torsion stage diff --git a/doc/theory.rst b/doc/theory.rst index 18d5f2a..124c259 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -3002,8 +3002,12 @@ the nodal tangent :math:`\mathbf{m}_j/\lvert\mathbf{m}_j\rvert` (the line's Mori :math:`C_dA = C_{d,n} d` and :math:`C_dA_x = \pi C_{d,t} d`). The drag enters the velocity tangent and, through :math:`\mathbf{m}_j`, the position tangent exactly. Above the free surface an attachment carries its weight :math:`-m g\,\mathbf{e}_z` alone. The statics start from the -equilibrium with the net weight smeared over the node's two elements and continue to the lumped -load in warm-started Newton solves; in a deck current the drag at rest is added, each solve takes +equilibrium with the net weight smeared over the node's two elements, in a deck current across +the vertical plane of the chord with the drag areas smeared over them too (per-metre drag +coefficients :math:`C_dA/(d\,L)` and :math:`C_dA_x/(\pi d\,L)` added to an element of length +:math:`L` for each share), and continue to the lumped loads in warm-started Newton solves, the +smeared weight and drag moving to the node together; in a deck current the drag at rest is +lumped at the node, each solve takes every attachment's load on the side of the still-water level its node is on, and the final solve is repeated with the loads of its own solution until the nodes stop moving and no attachment changes side (an attachment that its weight in air takes below the surface and its @@ -3021,12 +3025,16 @@ At most 64 load steps may fail in all. A discrete state reached this way passes tensile, physical-branch and stability checks of the smeared state before it is accepted, the kink limit :math:`L_0\kappa \le 0.7` applied away from the attachment nodes: a lumped load turns the line by a slope jump :math:`\theta` at its node, which the :math:`C^1` elements -beside the node carry as :math:`L_0\kappa \approx 2\theta` at any mesh size. The smeared state carries no -attachment drag, and on a frictional seabed, whose regularised friction force turns from its -sticking stiffness to its sliding capacity over a short slip, the Newton update for a -cross-flow drag can overshoot and diverge from the first step (a lazy wave with 25 modules in a -0.3 m/s cross current on 2 m elements, even in steps of 1/64), which the pseudo-transient -regularisation avoids. +beside the node carry as :math:`L_0\kappa \approx 2\theta` at any mesh size. On a frictional +seabed, whose regularised friction force turns from its sticking stiffness to its sliding +capacity over a short slip, a smeared state without the attachment drag left the polish to apply +the whole module drag, and its damped Newton updates diverged from the first step as the +grounded run slid metres sideways (a lazy wave with 25 modules in a 0.3 m/s cross current on 2 m +elements); with the drag smeared, each load step moves the line by millimetres and converges in +a few iterations. A current along the plane does not move the line out of it, and the polish +carries the attachment drag from the smeared state without it, as the smeared start of a coarse +deck mesh could otherwise stop at the grounded-run compression check (a lazy wave restrained in +torsion in 0.5 m/s along its plane). As the pitch :math:`p` of identical modules shrinks, the discrete equilibrium converges to the smeared section at :math:`O(p^2)`. diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index ed2473d..41e822d 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -1877,6 +1877,8 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, LOGICAL :: bathymetry_flat, require_3d_static, sym_broken, planar_unstable, saddle_kept ! whether the line would be solved in 3D without its torsion (a twisted line always is) LOGICAL :: require_3d_untwisted + ! the deck current has a component across the vertical plane of the chord + LOGICAL :: cross_current ! A planar equilibrium in a current that is a saddle out of its plane (see route_pass). REAL(wp), ALLOCATABLE :: l0_saddle(:), ea_saddle(:), ei_saddle(:), w_saddle(:), q_saddle(:), curv_saddle(:) INTEGER, ALLOCATABLE :: fixed_saddle(:) @@ -1919,6 +1921,10 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, ! smeared equivalent first), and the attachment nodes on the final mesh. LOGICAL :: has_att REAL(wp), ALLOCATABLE :: w_bare_deck(:), l0_deck_att(:), w_bare(:) + ! In a current across the line's plane, the line's own drag coefficients (cur_obj, deck mesh), + ! to which the attachments' drag smeared into cur_obj (smear_attachment_drag) returns in the + ! polish; unallocated when no attachment drag is smeared. + REAL(wp), ALLOCATABLE :: att_cdn_bare(:), att_cdt_bare(:) INTEGER, ALLOCATABLE :: att_node(:) ! STIFFENERS: the cable's own bending stiffness per element (internal order), without the ! sleeve, for the cable's share of the bend moment (allocated only on a stiffened line) @@ -2119,6 +2125,7 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, ! A current with a component across the vertical plane of the chord pushes the line out ! of that plane, which the dynamics (endpoints held, every other DOF free) follow: the ! static equilibrium is then solved in 3D as well. + cross_current = .FALSE. IF (opts%has_current) THEN IF (opts%has_current_grid) THEN ! a current grid (Currents 2 and 5): the static start takes its t = 0 sample, every point @@ -2132,7 +2139,7 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, IF (ABS(-frame_cs(2)*opts%current_grid%velocity(1, ix, iy, iz, 1) + & frame_cs(1)*opts%current_grid%velocity(2, ix, iy, iz, 1)) > & 64.0_wp*EPSILON(CD_ONE)*MAX(CD_ONE, NORM2(opts%current_grid%velocity(:, ix, iy, iz, 1)))) & - require_3d_static = .TRUE. + cross_current = .TRUE. END DO END DO END DO @@ -2141,13 +2148,14 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, DO i = 1, SIZE(opts%current_profile_z) IF (ABS(-frame_cs(2)*opts%current_profile_velocity(1, i) + frame_cs(1)*opts%current_profile_velocity(2, i)) & > 64.0_wp*EPSILON(CD_ONE)*MAX(CD_ONE, NORM2(opts%current_profile_velocity(:, i)))) & - require_3d_static = .TRUE. + cross_current = .TRUE. END DO ELSE IF (ABS(-frame_cs(2)*opts%current_velocity(1) + frame_cs(1)*opts%current_velocity(2)) > & 64.0_wp*EPSILON(CD_ONE)*MAX(CD_ONE, NORM2(opts%current_velocity))) THEN - require_3d_static = .TRUE. + cross_current = .TRUE. END IF END IF + IF (cross_current) require_3d_static = .TRUE. ! A lay azimuth across the vertical plane of the chord puts the as-laid friction ! reference, and the friction it holds, out of that plane: solved in 3D as well. This is ! decided here, before the held (planar) DOFs below are chosen. @@ -2443,6 +2451,24 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, IF (ALLOCATED(cur_obj%profile_velocity)) DEALLOCATE (cur_obj%profile_velocity) IF (ALLOCATED(cur_obj%grid)) DEALLOCATE (cur_obj%grid) END IF + ! In a current across the vertical plane of the chord the smeared start carries the + ! attachments' drag as well as their weight, spread over the same elements + ! (smear_attachment_drag), and the polish moves it to the nodes with the weight. Without + ! it the polish applied the whole attachment drag to the smeared state, which that drag + ! pushes out of its plane: on a frictional seabed its damped Newton solves diverged as + ! the grounded run slid metres sideways (25 modules on a lazy wave in 0.3 m/s across its + ! plane). A current along the plane does not move the line out of it, and the polish + ! carries the attachment drag as before: carried by the smeared start instead, it stopped + ! the current stepping on a coarse deck mesh at the grounded-run compression check (a lazy + ! wave restrained in torsion in 0.5 m/s along its plane, whose discrete state the polish + ! reaches). + IF (has_att .AND. cur_obj%drag .AND. cross_current) THEN + IF (ANY(ln%att(4, :) > CD_ZERO .OR. ln%att(6, :) > CD_ZERO)) THEN + att_cdn_bare = cur_obj%cdn + att_cdt_bare = cur_obj%cdt + CALL smear_attachment_drag(cur_obj%cdn, cur_obj%cdt, cur_obj%diam, l0, att_node, ln%att) + END IF + END IF IF (as_laid) THEN CALL as_laid_reference(es, em) IF (es /= CD_DECKDRV_OK) THEN @@ -3279,7 +3305,12 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, CALL attachment_static_polish(l0, ea, ei, w_bare, w, diam, att_node, ln%att, q_static, fixed_dofs, & contact_enabled, contact_z, frame_cs, endconn_k, endconn_d0_local, & endconn_mode, opts, static_tol, ErrStat, ErrMsg, bathymetry, cur_obj, & - fell_back=att_fell_back) + fell_back=att_fell_back, cdn_bare=att_cdn_bare, cdt_bare=att_cdt_bare) + ! the discrete equilibrium carries the line's own drag (the attachments' at their nodes) + IF (ALLOCATED(att_cdn_bare)) THEN + cur_obj%cdn = att_cdn_bare + cur_obj%cdt = att_cdt_bare + END IF IF (ErrStat /= CD_DECKDRV_OK) RETURN ! A discrete state the damped Newton solves did not reach alone (pseudo-transient ! continuation, line search or smaller load steps) passes the acceptance checks the @@ -5470,15 +5501,51 @@ SUBROUTINE smear_attachments(w, l0, node, att, rho_w, g) END DO END SUBROUTINE smear_attachments + SUBROUTINE smear_attachment_drag(cdn, cdt, diam, l0, node, att) + !! Spread each attachment's drag areas over the elements next to its node, as + !! smear_attachments spreads its net weight: the normal drag area CdA and the axial drag area + !! CdAx become per-metre drag coefficients of those elements (Cd_n d per metre and + !! pi Cd_t d per metre, the line's own Morison law), so that the smeared line carries the + !! attachments' current drag as well as their weight. + REAL(wp), INTENT(INOUT) :: cdn(:), cdt(:) + REAL(wp), INTENT(IN) :: diam(:), l0(:), att(:, :) + INTEGER, INTENT(IN) :: node(:) + INTEGER :: k, j, ne + ne = SIZE(l0) + DO k = 1, SIZE(node) + j = node(k) + IF (j == 1) THEN + CALL add_share(1, CD_ONE) + ELSE IF (j == ne + 1) THEN + CALL add_share(ne, CD_ONE) + ELSE + CALL add_share(j - 1, 0.5_wp) + CALL add_share(j, 0.5_wp) + END IF + END DO + CONTAINS + SUBROUTINE add_share(e, share) + INTEGER, INTENT(IN) :: e + REAL(wp), INTENT(IN) :: share + cdn(e) = cdn(e) + share*att(4, k)/(diam(e)*l0(e)) + cdt(e) = cdt(e) + share*att(6, k)/(4.0_wp*QUARTER_PI*diam(e)*l0(e)) + END SUBROUTINE add_share + END SUBROUTINE smear_attachment_drag + SUBROUTINE attachment_static_polish(l0, ea, ei, w_bare, w_smear, diam, node, att, q, fixed_dofs, & contact_enabled, contact_z, frame_cs, endconn_k, endconn_d0, endconn_mode, & - opts, tol, ErrStat, ErrMsg, bathymetry, cur_obj, fell_back) + opts, tol, ErrStat, ErrMsg, bathymetry, cur_obj, fell_back, cdn_bare, & + cdt_bare) !! Homotopy from the smeared-attachment equilibrium q to the discrete one: in warm-started !! Newton solves the element weights move from w_smear to w_bare while the lumped nodal !! forces -(m - rho V) g e_z (-m g e_z for an attachment above the still-water level, !! attachment_static_weight) and, in a deck current, the attachment current drag grow from !! zero to full, with the contact, dry-buoyancy, end-connection and line current terms of - !! the builder's static problem. The drag depends on the node's position (profile current, + !! the builder's static problem. With cdn_bare and cdt_bare (the line's own drag + !! coefficients of cur_obj, whose cdn and cdt carry the attachments' drag smeared, + !! smear_attachment_drag) the element drag coefficients move from those of cur_obj to them + !! with the weights, so the smeared drag passes to the nodes as the smeared weight does. + !! The drag depends on the node's position (profile current, !! current grid) and line tangent, and the weight on the side of the surface the node is on, !! so each solve takes them at its incoming state and the full-load solve is repeated with !! those of the last solution until the solution stops moving and no attachment changes @@ -5494,12 +5561,12 @@ SUBROUTINE attachment_static_polish(l0, ea, ei, w_bare, w_smear, diam, node, att !! and still advances at the smaller size. At most MAX_FAILED_STEPS load steps may fail in !! all, a backstop that a steady homotopy does not reach (16 failures at worst, at !! MIN_STEP); with at most 1/MIN_STEP converged steps this bounds the polish. - !! The smeared state carries no attachment drag, and on a frictional seabed, whose - !! regularised friction force turns from its sticking stiffness to its sliding capacity - !! over a short slip, the Newton update for a cross-flow drag can overshoot far from the - !! equilibrium and diverge (even in load steps of 1/64), which the pseudo-transient - !! regularisation avoids. Once a damped Newton solve has failed, the - !! later ones get NEWTON_RETRY_ITER iterations before the fallbacks take over. A polish + !! On a frictional seabed, whose regularised friction force turns from its sticking + !! stiffness to its sliding capacity over a short slip, the Newton update for a cross-flow + !! drag can overshoot far from the equilibrium and diverge, which the pseudo-transient + !! regularisation avoids (from a smeared state without the attachment drag, which the polish + !! then applied whole, even load steps of 1/64 diverged). Once a damped Newton solve has + !! failed, the later ones get NEWTON_RETRY_ITER iterations before the fallbacks take over. A polish !! whose damped Newton solves all converge takes exactly the solves it took before. !! fell_back (optional out) reports that a damped Newton solve failed, so that the state !! was reached with the fallbacks (and the caller re-runs its acceptance checks on it). @@ -5516,6 +5583,7 @@ SUBROUTINE attachment_static_polish(l0, ea, ei, w_bare, w_smear, diam, node, att TYPE(CD_BathymetryType), INTENT(IN), OPTIONAL :: bathymetry TYPE(CD_HermiteStaticCurrentType), INTENT(IN), OPTIONAL :: cur_obj LOGICAL, INTENT(OUT), OPTIONAL :: fell_back + REAL(wp), INTENT(IN), OPTIONAL :: cdn_bare(:), cdt_bare(:) INTEGER, PARAMETER :: NSTEP = 8, MAX_FIXED_POINT = 60 ! Smallest load-fraction step: 1/NSTEP halved seven times. Newton budget once a damped ! Newton solve of this polish has failed (a converging one takes well under it). Budget @@ -5537,9 +5605,23 @@ SUBROUTINE attachment_static_polish(l0, ea, ei, w_bare, w_smear, diam, node, att ! The damped Newton failure of the first failed solve, and of the last one. CHARACTER(1024) :: first_msg, last_msg CHARACTER(48) :: step_buf, lam_buf + ! the current of the solves (cur_obj with the element drag coefficients of the load + ! fraction; unallocated without cur_obj) and cur_obj's smeared coefficients + TYPE(CD_HermiteStaticCurrentType), ALLOCATABLE :: cur_l + REAL(wp), ALLOCATABLE :: cdn_smear(:), cdt_smear(:) + LOGICAL :: blend_drag ErrStat = CD_DECKDRV_OK; ErrMsg = '' IF (PRESENT(fell_back)) fell_back = .FALSE. + blend_drag = .FALSE. + IF (PRESENT(cur_obj)) THEN + ALLOCATE (cur_l, SOURCE=cur_obj) + blend_drag = PRESENT(cdn_bare) .AND. PRESENT(cdt_bare) + IF (blend_drag) THEN + cdn_smear = cur_obj%cdn + cdt_smear = cur_obj%cdt + END IF + END IF first_msg = '' last_msg = '' newton_failed = .FALSE. @@ -5567,6 +5649,10 @@ SUBROUTINE attachment_static_polish(l0, ea, ei, w_bare, w_smear, diam, node, att DO WHILE (lam_done < CD_ONE) lam = MIN(CD_ONE, lam_done + dlam) wl = w_bare + (CD_ONE - lam)*(w_smear - w_bare) + IF (blend_drag) THEN + cur_l%cdn = cdn_bare + (CD_ONE - lam)*(cdn_smear - cdn_bare) + cur_l%cdt = cdt_bare + (CD_ONE - lam)*(cdt_smear - cdt_bare) + END IF CALL solve_with(lam, converged) IF (ErrStat /= CD_DECKDRV_OK) RETURN IF (converged) THEN @@ -5691,7 +5777,7 @@ SUBROUTINE solve_with(scale, ok, dq_max) bending_quadrature_order=opts%bending_quadrature_order, & endconn_stiffness=endconn_k, endconn_direction=endconn_d0, & endconn_mode=endconn_mode, waterline_z=CD_ZERO, dry_buoyancy=dry, & - water_weight=opts%rhoW*opts%g, current=cur_obj, & + water_weight=opts%rhoW*opts%g, current=cur_l, & backtracking=mode == MERIT, merit_line_search=mode == MERIT, & pseudo_transient=mode == PSEUDO_TRANSIENT) ELSE @@ -5702,7 +5788,7 @@ SUBROUTINE solve_with(scale, ok, dq_max) bending_quadrature_order=opts%bending_quadrature_order, & endconn_stiffness=endconn_k, endconn_direction=endconn_d0, & endconn_mode=endconn_mode, waterline_z=CD_ZERO, dry_buoyancy=dry, & - water_weight=opts%rhoW*opts%g, current=cur_obj, & + water_weight=opts%rhoW*opts%g, current=cur_l, & backtracking=mode == MERIT, merit_line_search=mode == MERIT, & pseudo_transient=mode == PSEUDO_TRANSIENT) END IF diff --git a/tests/test_attachment_cross_current.py b/tests/test_attachment_cross_current.py index 29305ee..5f6346b 100644 --- a/tests/test_attachment_cross_current.py +++ b/tests/test_attachment_cross_current.py @@ -6,14 +6,16 @@ The lazy-wave power cable of ``tests/data/lazy_wave_touchdown_planar_{refined,coarse}_att.dat`` (25 discrete buoyancy modules at a 4 m pitch, rigid end connections, seabed friction 0.35) is put in a uniform current across its vertical plane. The static solve reaches the discrete -equilibrium from the smeared one by a homotopy in the module loads and drag; on the refined -mesh in 0.3 m/s its damped Newton steps diverge on the frictional seabed, and the polish must -fall back to its regularised solves rather than fail (its note naming that path is required). +equilibrium from the smeared one by a homotopy in the module loads and drag. Every deck must solve (exit 0), and its line-end tensions must agree with the deck that replaces the modules by the equivalent smeared section (the same submerged weight, displaced volume and normal drag per metre over the stretch the modules cover): FairTen within 1 % and -AnchTen within 2 % of the smeared values. +AnchTen within 2 % of the smeared values. The static solve reaches the discrete state without a +fallback: in a current across the line's plane the smeared start carries the modules' drag as well +as their weight, so the polish only moves both to the nodes (with the drag left out of the smeared +start, the refined deck in 0.3 m/s reached it only through the pseudo-transient and line-search +repetitions). usage: test_attachment_cross_current.py --exe --data --output """ @@ -32,10 +34,8 @@ BARE_D, BARE_M, BARE_CDN, BARE_CDT = 0.16, 36.70, 1.2, 0.1 FAIR_TOL, ANCH_TOL = 0.01, 0.02 CASES = [("refined", 0.3), ("refined", 0.6), ("coarse", 0.3), ("coarse", 0.6)] -# The case whose polish takes the fallback path; its note says so (and that the state passed -# the acceptance checks of the smeared state). -FALLBACK_CASE = "refined_0.3" -FALLBACK_NOTE = "was reached after a failed Newton solve" +# Notes of a static solve that reached its state through a fallback. +FALLBACK_NOTES = ("reached after a failed Newton solve", "stalled", "looser tolerance") def with_current(text: str, speed: float) -> str: @@ -95,8 +95,9 @@ def main() -> int: rc, row, log = run(exe, d / "deck.dat") if rc != 0 or "FairTen4" not in row: failures.append("%s %s: exit %d\n%s" % (name, kind, rc, log[-2000:])) - if kind == "discrete" and name == FALLBACK_CASE and FALLBACK_NOTE not in log: - failures.append("%s: the polish did not report its fallback path" % name) + notes = [n for n in FALLBACK_NOTES if n in log] + if notes: + failures.append("%s %s: the static solve needed a fallback (%s)" % (name, kind, ", ".join(notes))) results[kind] = row if all("FairTen4" in r for r in results.values()): disc, smr = results["discrete"], results["smeared"] diff --git a/tests/test_attachment_polish_current.py b/tests/test_attachment_polish_current.py new file mode 100644 index 0000000..62fb512 --- /dev/null +++ b/tests/test_attachment_polish_current.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""Discrete buoyancy modules in an oblique or cross current: the attachment polish converges cleanly. + +The static solve of a line with ATTACHMENTS first finds the equilibrium with the modules smeared +over the elements beside their nodes, then carries it to the lumped loads by a homotopy (the +attachment polish). The smeared start carried the modules' weight but not their current drag, so +the polish applied the whole module drag to it: on the frictional seabed of +``tests/data/lazy_wave_touchdown_planar_coarse_att.dat`` (friction 0.35) in a current across the +line's plane, its damped Newton solves diverged as the grounded run slid metres sideways, and the +discrete state was reached only through the pseudo-transient and line-search fallbacks (25 +modules, 2 m elements, 0.3 m/s at 90 deg: the first load step of 1/8 failed). In a current +across the line's plane the smeared start now carries the module drag too, and the polish moves it +to the nodes with the weight (a current along the plane keeps the former path: the in-plane +control below). + +Gates: + +1. Robustness, on the frictional lazy wave with its 96 m buoyant stretch rebuilt from 10, 25 or 40 + modules (the same total mass, volume and drag area), on the deck's 4 m / 10 m grading with the + buoyant section at 1, 2 or 4 times its node density, or on 2 m elements throughout with that + section refined again, in a uniform current of 0.1 to 0.5 m/s at 45 or 90 deg to the line's + plane (and two controls: still water and an in-plane current): every deck solves (exit 0) + without a fallback note ("reached after a failed Newton solve", "stalled", "looser + tolerance"), and the line's mean out-of-plane offset has the sign of the current's cross + component. +2. Physics, on the lazy wave of ``examples/lazy_wave_buoyancy_modules.dat`` hung in mid-water (the + seabed lowered to 300 m, so no contact force enters the balance) with 25 or 40 modules in 0.3 or + 0.5 m/s at 45 or 90 deg: the end forces the line puts on its two points (C API, double + precision) balance its weight and buoyancy, the modules' net weight and the current drag of + cable (three-point Gauss on the Hermite centreline rebuilt from the node positions and + tangents) and modules (at their nodes) to 1e-6 of the largest load, and the line deflects + toward the current's cross component. The C API's declination and azimuth are a central + difference of the node positions, which is not the tangent at a module node (the curvature + jumps there), so a module's tangent is the mean of the one-sided fourth-order differences. +3. Torsion, on ``tests/data/lazy_wave_torsion_attachments.dat`` (the frictional lazy wave restrained + in torsion with one turn of pretwist) in 0.3 m/s at 45 deg: the deck solves without a fallback + note and its torsion stage reports the twist solved (it stopped before in the smeared stage). + +usage: test_attachment_polish_current.py --exe --library --source + --output +""" + +from __future__ import annotations + +import argparse +import math +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import numpy as np + +FALLBACK_NOTES = ("reached after a failed Newton solve", "stalled", "looser tolerance") +BALANCE_TOL = 1.0e-6 + +# (mesh, speed m/s, heading deg from the line's plane, modules, buoyant-section refinement). +# mesh "deck": the deck's 4 m / 10 m grading; "fine": 2 m elements throughout. Every case but the +# two controls reached its discrete state only through the polish's fallbacks before. +ROBUST_CASES = [ + ("fine", 0.3, 90, 25, 1), + ("fine", 0.3, 90, 25, 4), + ("fine", 0.3, 90, 40, 2), + ("fine", 0.3, 45, 40, 4), + ("fine", 0.1, 90, 10, 4), + ("fine", 0.1, 45, 25, 1), + ("fine", 0.2, 45, 10, 2), + ("fine", 0.2, 90, 40, 4), + ("fine", 0.5, 45, 40, 2), + ("fine", 0.5, 45, 25, 1), + ("deck", 0.2, 90, 25, 4), + ("deck", 0.5, 45, 10, 2), + ("deck", 0.5, 45, 40, 4), + ("deck", 0.5, 45, 25, 1), + ("fine", 0.0, 0, 25, 2), + ("fine", 0.3, 0, 25, 2), +] +# (speed, heading, modules, refinement) on the mid-water lazy wave +BALANCE_CASES = [(0.3, 90, 25, 2), (0.5, 90, 40, 4), (0.3, 45, 25, 4), (0.5, 90, 25, 4)] +# (speed, heading) on the lazy wave restrained in torsion +TORSION_CASES = [(0.3, 45)] + +RHO, G = 1025.0, 9.80665 +# examples/lazy_wave_buoyancy_modules.dat: the bare cable and its ten modules over 70.614-115.614 m +GOMEX_D, GOMEX_M, GOMEX_EA, GOMEX_CDN, GOMEX_CDT = 0.16, 36.70, 4.69e8, 1.2, 0.1 +GOMEX_MOD = (114.15, 0.2297, 0.78, 0.2042) + + +def current_row(speed: float, heading: float) -> str: + th = math.radians(heading) + ux, uy = round(speed * math.cos(th), 12) + 0.0, round(speed * math.sin(th), 12) + 0.0 + return "uniform %.12g %.12g 0.0 current" % (ux, uy) + + +def touchdown_deck(source: Path, mesh: str, speed: float, heading: float, n_mod: int, refine: int) -> str: + """The frictional lazy wave with n_mod modules over 80-176 m (25 at 4 m in the deck).""" + text = (source / "tests" / "data" / "lazy_wave_touchdown_planar_coarse_att.dat").read_text() + pitch = 96.0 / (n_mod - 1) + per_pitch = max(1, round(pitch / 4.0)) + g = 2 if mesh == "fine" else 1 + rows = "4 bare 80.0 %d\n4 bare 96.0 %d\n4 bare 424.0 %d\n" % ( + 20 * g, (n_mod - 1) * per_pitch * refine * g, 170 if mesh == "fine" else 42) + text = re.sub(r"(-+ SECTIONS -+\n.*\n.*\n)(?:4 .*\n)+", lambda m: m.group(1) + rows, text) + s = 25.0 / n_mod + att = "4 80.0:%.10g:176.0 %.6g %.6g %.6g 1.0 0.0\n" % (pitch, 91.32 * s, 0.18378 * s, 1.392 * s) + text = re.sub(r"(-+ ATTACHMENTS -+\n.*\n.*\n)(?:4 .*\n)+", lambda m: m.group(1) + att, text) + if speed: + text = re.sub(r"^(0\.0 TMax)$", lambda m: m.group(1) + "\n" + current_row(speed, heading), text, + count=1, flags=re.M) + return text + + +def torsion_deck(source: Path, speed: float, heading: float) -> str: + """tests/data/lazy_wave_torsion_attachments.dat, static only, in a uniform current.""" + text = (source / "tests" / "data" / "lazy_wave_torsion_attachments.dat").read_text() + text, n = re.subn(r"^\S+(\s+TMax)$", lambda m: "0.0" + m.group(1) + "\n" + current_row(speed, heading), text, + count=1, flags=re.M) + if n != 1: + raise RuntimeError("the torsion deck has no TMax row") + return text + + +def gomex_sections(n_mod: int, refine: int) -> list[tuple[float, int]]: + return [(70.614, 71), (45.0, (n_mod - 1) * max(1, round(45.0 / (n_mod - 1))) * refine), (54.601, 55)] + + +def midwater_deck(source: Path, speed: float, heading: float, n_mod: int, refine: int) -> str: + """examples/lazy_wave_buoyancy_modules.dat with n_mod modules (the same totals), seabed at 300 m.""" + text = (source / "examples" / "lazy_wave_buoyancy_modules.dat").read_text() + rows = "".join("1 bare %.3f %d\n" % sec for sec in gomex_sections(n_mod, refine)) + text = re.sub(r"(-+ SECTIONS -+\n.*\n.*\n)(?:1 .*\n)+", lambda m: m.group(1) + rows, text) + s = 10.0 / n_mod + m, v, cda, cdax = (x * s for x in GOMEX_MOD) + att = "1 70.614:%.10g:115.614 %.6g %.6g %.6g 1.0 %.6g\n" % (45.0 / (n_mod - 1), m, v, cda, cdax) + text = re.sub(r"(-+ ATTACHMENTS -+\n.*\n.*\n)(?:1 .*\n)+", lambda m_: m_.group(1) + att, text) + text = re.sub(r"^uniform 0\.5 0\.0 0\.0 .*$", current_row(speed, heading), text, count=1, flags=re.M) + text = re.sub(r"^80\.0(\s+WtrDpth)", r"300.0\1", text, count=1, flags=re.M) + text = re.sub(r'^"TDP1[sx]"\n', "", text, flags=re.M) + return text + + +def run(exe: str, d: Path, text: str) -> tuple[int, str]: + d.mkdir(parents=True, exist_ok=True) + (d / "deck.dat").write_text(text, newline="\n") + p = subprocess.run([exe, "deck.dat", "o"], cwd=d, capture_output=True, text=True, timeout=1200) + return p.returncode, p.stdout + p.stderr + + +def static_y(d: Path) -> np.ndarray: + lines = (d / "o.static.out").read_text().splitlines()[3:] + return np.array([float(ln.split()[4]) for ln in lines if ln.strip()]) + + +def morison(u: np.ndarray, t: np.ndarray, d: float, cdn: float, cdt: float) -> np.ndarray: + t = t / np.linalg.norm(t) + ut = np.dot(u, t) * t + un = u - ut + return 0.5 * RHO * d * (cdn * np.linalg.norm(un) * un + math.pi * cdt * np.linalg.norm(ut) * ut) + + +def balance(deck: Path, speed: float, heading: float, n_mod: int, refine: int) -> tuple[float, float]: + """(|end forces - loads| / largest load, mean y offset) of the static state through the C API.""" + from cabledyn import CableDyn + + with CableDyn(deck) as model: + line = model.line(1) + x = np.array(line.node_positions()) + ten = np.array(line.node_tensions()) + dec, azi = np.radians(line.declination()), np.radians(line.azimuth()) + ends = np.array([model.channel("Point1F" + c) + model.channel("Point2F" + c) for c in "xyz"]) + largest = max(abs(model.channel("FairTen1")), abs(model.channel("AnchTen1"))) + l0 = np.concatenate([np.full(n, length / n) for length, n in gomex_sections(n_mod, refine)]) + if len(x) != len(l0) + 1: + raise RuntimeError("the static solve refined the mesh (%d nodes)" % len(x)) + tan = np.stack([np.sin(dec) * np.cos(azi), np.sin(dec) * np.sin(azi), np.cos(dec)], 1) + m = tan * (1.0 + ten / GOMEX_EA)[:, None] # dr/ds, the stretch from the tension + th = math.radians(heading) + u = np.array([round(speed * math.cos(th), 12), round(speed * math.sin(th), 12), 0.0]) + w_bare = (GOMEX_M - RHO * math.pi / 4.0 * GOMEX_D**2) * G + load = np.array([0.0, 0.0, -w_bare * l0.sum()]) + gx = [0.5 - 0.5 * math.sqrt(0.6), 0.5, 0.5 + 0.5 * math.sqrt(0.6)] + gw = [5.0 / 18.0, 8.0 / 18.0, 5.0 / 18.0] + for e, le in enumerate(l0): + for xi, wgt in zip(gx, gw): + dh = [(-6 * xi + 6 * xi**2) / le, 1 - 4 * xi + 3 * xi**2, (6 * xi - 6 * xi**2) / le, -2 * xi + 3 * xi**2] + rp = dh[0] * x[e] + dh[1] * m[e] + dh[2] * x[e + 1] + dh[3] * m[e + 1] + load += wgt * le * np.linalg.norm(rp) * morison(u, rp, GOMEX_D, GOMEX_CDN, GOMEX_CDT) + s = 10.0 / n_mod + mass, vol, cda, cdax = (v * s for v in GOMEX_MOD) + cum = np.concatenate([[0.0], np.cumsum(l0)]) + for k in range(n_mod): + j = int(np.argmin(np.abs(cum - (70.614 + k * 45.0 / (n_mod - 1))))) + load[2] -= (mass - RHO * vol) * G + tl = 25 * x[j] - 48 * x[j - 1] + 36 * x[j - 2] - 16 * x[j - 3] + 3 * x[j - 4] + tr = -25 * x[j] + 48 * x[j + 1] - 36 * x[j + 2] + 16 * x[j + 3] - 3 * x[j + 4] + t = tl / np.linalg.norm(tl) + tr / np.linalg.norm(tr) + t /= np.linalg.norm(t) + ut = np.dot(u, t) * t + un = u - ut + load += 0.5 * RHO * (cda * np.linalg.norm(un) * un + cdax * np.linalg.norm(ut) * ut) + largest = max(largest, abs(load[2])) + return float(np.abs(ends - load).max() / largest), float(x[:, 1].mean()) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--exe", required=True) + ap.add_argument("--library", required=True) + ap.add_argument("--source", required=True) + ap.add_argument("--output", required=True) + ap.add_argument("--jobs", type=int, default=4) + a = ap.parse_args() + exe = str(Path(a.exe).resolve()) + source = Path(a.source) + out = Path(a.output) + failures: list[str] = [] + + def robust(case): + mesh, speed, heading, n_mod, refine = case + name = "%s_U%.1f_h%d_N%d_r%d" % case + rc, log = run(exe, out / name, touchdown_deck(source, mesh, speed, heading, n_mod, refine)) + return name, case, rc, log + + def solve_balance(case): + speed, heading, n_mod, refine = case + name = "midwater_U%.1f_h%d_N%d_r%d" % case + rc, log = run(exe, out / name, midwater_deck(source, speed, heading, n_mod, refine)) + return name, case, rc, log + + def torsion(case): + speed, heading = case + name = "torsion_U%.1f_h%d" % case + rc, log = run(exe, out / name, torsion_deck(source, speed, heading)) + return name, rc, log + + with ThreadPoolExecutor(a.jobs) as pool: + torsion_runs = pool.map(torsion, TORSION_CASES) + robust_runs = list(pool.map(robust, ROBUST_CASES)) + balance_runs = list(pool.map(solve_balance, BALANCE_CASES)) + torsion_runs = list(torsion_runs) + + for name, (mesh, speed, heading, n_mod, refine), rc, log in robust_runs: + notes = [n for n in FALLBACK_NOTES if n in log] + y = static_y(out / name).mean() if rc == 0 else float("nan") + uy = speed * math.sin(math.radians(heading)) + print("%-26s exit %d fallback notes %s mean y %+.4f m" % (name, rc, notes or "none", y)) + if rc != 0: + failures.append("%s: exit %d\n%s" % (name, rc, log[-1500:])) + elif notes: + failures.append("%s: the static solve needed a fallback (%s)" % (name, ", ".join(notes))) + elif (uy > 0.0 and not y > 0.0) or (uy == 0.0 and abs(y) > 1.0e-9): + failures.append("%s: mean out-of-plane offset %.3e m against a cross current %.3f m/s" % (name, y, uy)) + + for name, rc, log in torsion_runs: + notes = [n for n in FALLBACK_NOTES if n in log] + solved = "torsion solved" in log + print("%-26s exit %d fallback notes %s torsion solved %s" % (name, rc, notes or "none", solved)) + if rc != 0 or notes or not solved: + failures.append("%s: exit %d, fallback notes %s, torsion solved %s\n%s" % (name, rc, notes, solved, + log[-1500:])) + + os.environ["CABLEDYN_LIBRARY"] = str(Path(a.library).resolve()) + sys.path.insert(0, str(source / "python")) + for name, (speed, heading, n_mod, refine), rc, log in balance_runs: + notes = [n for n in FALLBACK_NOTES if n in log] + if rc != 0 or notes: + failures.append("%s: exit %d, fallback notes %s\n%s" % (name, rc, notes, log[-1500:])) + continue + err, y = balance(out / name / "deck.dat", speed, heading, n_mod, refine) + print("%-26s force balance %.2e of the largest load mean y %+.4f m" % (name, err, y)) + if not err <= BALANCE_TOL: + failures.append("%s: the end forces miss the line's loads by %.2e of the largest load" % (name, err)) + if not y > 0.0: + failures.append("%s: mean out-of-plane offset %.3e m against the current" % (name, y)) + + for f in failures: + print("FAIL: " + f) + if failures: + return 1 + print("PASS: the attachment polish converges cleanly in oblique and cross currents, in force balance") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 82cfa7aeabb6bac235fb95d98ce02d29b6a39fb9 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 00:20:28 +0900 Subject: [PATCH 11/40] perf(multibody): full Newton for cables in the monolithic step (#113) Signed-off-by: Jae Hoon Seo --- doc/options.rst | 12 ++++++++---- src/CableDyn_DeckDriver.f90 | 9 +++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/doc/options.rst b/doc/options.rst index b249223..31b32a5 100644 --- a/doc/options.rst +++ b/doc/options.rst @@ -355,10 +355,14 @@ rejected as unknown. The dynamic Newton controls are set with ``dynamic_solver`` - ``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. - 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; + Measured against full Newton on the example decks (one thread): 4 to 20 % faster on + finite-EI cables, free-hanging or with a touchdown (lazy waves in waves, current and vessel + motion, a buoyed cable, rods on a spar); 12 % slower on an ``EI = 0`` cable laid on a + frictional seabed in a current, whose stick-slip friction makes a reused tangent take more + iterations; within a few per cent elsewhere on ``EI = 0`` systems. A finite-EI cable in + the monolithic step of the multibody march keeps full Newton, as it is stepped again from + the same start at every coupling iteration (reuse there made ``mixed_body_rods_points.dat`` + 7.0 s against 5.8 s); results move at the solver tolerance (Bergdahl mean cycle maxima within 0.16 %) - both - ``False modified_newton`` diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index 41e822d..4a76573 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -22677,6 +22677,15 @@ SUBROUTINE write_multibody_dynamic_out(deck_path, out_root, channels, types, bod mb%cab_imp(c) = mb%cab_imp(c) .OR. attachment_moves(mb%cab_kind_b(c), mb%cab_obj_b(c)) IF (mb%cab_imp(c)) mb%cables(c)%tol = MIN(mb%cables(c)%tol, CLAMPED_CABLE_TOL) END IF + ! A cable in the monolithic step is restored and stepped again from the same start at every + ! coupling iteration; within-step tangent reuse (OPTIONS modified_newton) made each of those + ! steps dearer than a full Newton one (mixed_body_rods_points.dat: 7.0 s against 5.8 s), so + ! such a cable keeps full Newton; the other solvers of the deck keep the option. The + ! staggered march (bodyScheme staggered) steps each cable once per step and keeps it. + IF (mb%implicit .AND. mb%cab_imp(c) .AND. opts%dynamic_modified_newton) THEN + CALL CD_HFMF_Set_ModifiedNewton(mb%cables(c), .FALSE., ErrStat, ErrMsg) + IF (ErrStat /= CD_HFMF_OK) GOTO 900 + END IF END DO ALLOCATE (mb%cable_points(2, SIZE(cab_lines))) DO c = 1, SIZE(cab_lines) From 61e591c57c2137ca59f202d08c41e5f034677a25 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 01:36:26 +0900 Subject: [PATCH 12/40] fix(statics): a compression on unresolved elements names the mesh, not the current (#121) Signed-off-by: Jae Hoon Seo --- CMakeLists.txt | 10 ++++ src/CableDyn_HermiteCableStatic.f90 | 33 +++++++++-- tests/data/lazy_wave_graded_cross_current.dat | 57 +++++++++++++++++++ 3 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 tests/data/lazy_wave_graded_cross_current.dat diff --git a/CMakeLists.txt b/CMakeLists.txt index aa169d0..fa0133e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2859,6 +2859,16 @@ if(Python3_Interpreter_FOUND) 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. +# A lazy wave on a graded mesh in a cross current: the continuation route stops on a compression +# read on elements longer than half the bending length, which is no grounded-run limit, so the +# mesh-sequenced route still runs and solves the deck, with no fallback. +add_test(NAME driver_graded_cross_current + COMMAND ${CMAKE_COMMAND} -DDRIVER=$ -DEXPECT_RC=0 + "-DARGS=${CMAKE_CURRENT_SOURCE_DIR}/tests/data/lazy_wave_graded_cross_current.dat|graded" + "-DMUST_NOT=failed Newton;stalled;looser tolerance" + -DWORKDIR=${CMAKE_CURRENT_BINARY_DIR}/driver_graded_cross_current + -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_driver_output.cmake) +set_tests_properties(driver_graded_cross_current PROPERTIES LABELS "fortran;driver;integration;static" TIMEOUT 600) add_test(NAME driver_clamped_buoyant_held COMMAND ${CMAKE_COMMAND} -DDRIVER=$ -DEXPECT_RC=0 "-DARGS=${CMAKE_CURRENT_SOURCE_DIR}/tests/data/clamped_buoyant_cable_held.dat|held" diff --git a/src/CableDyn_HermiteCableStatic.f90 b/src/CableDyn_HermiteCableStatic.f90 index 4aa6374..1ab2f5e 100644 --- a/src/CableDyn_HermiteCableStatic.f90 +++ b/src/CableDyn_HermiteCableStatic.f90 @@ -3248,7 +3248,7 @@ PURE LOGICAL FUNCTION current_has_drag(cur) RESULT(has) has = cur%drag END FUNCTION current_has_drag - FUNCTION current_compression_note(l0, EA, qx, seabed_z, flat, frictional, frictionless_run) RESULT(note) + FUNCTION current_compression_note(l0, EA, qx, seabed_z, flat, frictional, frictionless_run, EI, w) RESULT(note) !! Diagnosis of a current that has no tension-only equilibrium on the traced branch: !! empty unless the state qx (the last one reached) carries an element-mean axial force !! (three-point Gauss mean of EA (|r'| - 1), free of the end-node boundary layer) below @@ -3263,8 +3263,13 @@ FUNCTION current_compression_note(l0, EA, qx, seabed_z, flat, frictional, fricti ! TRUE for the two frictionless grounded-run diagnoses (the run folds against its end or ! is pushed along the seabed in compression). LOGICAL, INTENT(OUT), OPTIONAL :: frictionless_run + ! The bending stiffness and weight per length of the elements (both or neither): on an + ! element longer than half its bending length (EI/|w|)^(1/3) the element-mean axial force of + ! a stiff line oscillates and can read a compression the line does not carry, so a + ! compression there names the mesh, not a limit of the current. + REAL(wp), INTENT(IN), OPTIONAL :: EI(:), w(:) CHARACTER(1024) :: note - REAL(wp) :: t_min, t_max, s_min, z_min, z_top + REAL(wp) :: t_min, t_max, s_min, z_min, z_top, need INTEGER :: k, k_min, wios, ne, es CHARACTER(1024) :: em note = '' @@ -3279,6 +3284,22 @@ FUNCTION current_compression_note(l0, EA, qx, seabed_z, flat, frictional, fricti IF (.NOT. (t_min < -1.0e-3_wp*t_max)) RETURN ! Height of the compressed element: the lower of its two nodes. z_min = MIN(qx(6*(k_min - 1) + 3), qx(6*k_min + 3)) + IF (PRESENT(EI) .AND. PRESENT(w)) THEN + ! the compressed element and its neighbours against half their bending length + need = CD_ZERO + DO k = MAX(1, k_min - 1), MIN(ne, k_min + 1) + IF (EI(k) > CD_ZERO .AND. ABS(w(k)) > CD_ZERO) & + need = MAX(need, l0(k)/(0.5_wp*(EI(k)/ABS(w(k)))**(1.0_wp/3.0_wp))) + END DO + IF (need > 1.0_wp) THEN + WRITE (note, '(A,ES10.3,A,F10.2,A,F6.2,A)', IOSTAT=wios) 'the line reads an element-mean axial '// & + 'compression (', t_min, ' N at unstretched arc length ', s_min, ' m) on elements ', need, & + ' times half the bending length (EI/|w|)^(1/3), where the element-mean axial force of a stiff '// & + 'line oscillates and can read a compression the line does not carry; refine those elements '// & + '(NumSegs) to half the bending length or less; ' + RETURN + END IF + END IF IF (flat .AND. z_min <= seabed_z + 0.01_wp*(z_top - seabed_z) .AND. frictional) THEN WRITE (note, '(A,ES10.3,A,F10.2,A,ES10.3,A)', IOSTAT=wios) 'the line is in axial compression at '// & 'the seabed (', t_min, ' N at unstretched arc length ', s_min, ' m, ', z_min - seabed_z, & @@ -3695,7 +3716,7 @@ LOGICAL FUNCTION frictionless_run_limit(qx) RESULT(run_limit) run_limit = .FALSE. IF (.NOT. PRESENT(current)) RETURN IF (.NOT. current_has_drag(current)) RETURN - note = current_compression_note(l0, EA, qx, seabed_z, flat, use_fric, run_limit) + note = current_compression_note(l0, EA, qx, seabed_z, flat, use_fric, run_limit, EI=EI, w=w) END FUNCTION frictionless_run_limit FUNCTION compression_note(qx) RESULT(note) @@ -3707,7 +3728,7 @@ FUNCTION compression_note(qx) RESULT(note) note = '' IF (.NOT. PRESENT(current)) RETURN IF (.NOT. current_has_drag(current)) RETURN - note = current_compression_note(l0, EA, qx, seabed_z, flat, use_fric) + note = current_compression_note(l0, EA, qx, seabed_z, flat, use_fric, EI=EI, w=w) END FUNCTION compression_note FUNCTION fixed_point_name() RESULT(name) @@ -6798,7 +6819,7 @@ SUBROUTINE CD_HermiteCable_Static_Solve_AutoMesh(l0, EA, EI, w, seed, fixed_dofs IF (PRESENT(current)) THEN IF (current_has_drag(current)) & emloc = TRIM(current_compression_note(l0_out, EA_out, q_out, seabed_z,.NOT. PRESENT(bathymetry), & - ALLOCATED(current%friction)))//TRIM(emloc) + ALLOCATED(current%friction), EI=EI_out, w=w_out))//TRIM(emloc) END IF ErrStat = es; ErrMsg = 'CD_HermiteCable_Static_Solve_AutoMesh: refined-mesh polish failed: '//TRIM(emloc) RETURN @@ -6930,7 +6951,7 @@ SUBROUTINE CD_HermiteCable_Static_Solve_AutoMesh(l0, EA, EI, w, seed, fixed_dofs IF (PRESENT(current)) THEN IF (current_has_drag(current)) & wbuf = TRIM(current_compression_note(l0_out, EA_out, q_out, seabed_z,.NOT. PRESENT(bathymetry), & - ALLOCATED(current%friction)))//TRIM(wbuf) + ALLOCATED(current%friction), EI=EI_out, w=w_out))//TRIM(wbuf) END IF ErrMsg = wbuf RETURN diff --git a/tests/data/lazy_wave_graded_cross_current.dat b/tests/data/lazy_wave_graded_cross_current.dat new file mode 100644 index 0000000..a871e62 --- /dev/null +++ b/tests/data/lazy_wave_graded_cross_current.dat @@ -0,0 +1,57 @@ +--------------------- CableDyn Input File ------------------------------------ +Lazy-wave cable on a graded mesh in a cross current: a mesh-scale compression is no grounded-run limit +-- The lazy wave of lazy_wave_touchdown_planar_coarse_att.dat (25 discrete buoyancy modules on a +-- frictional seabed) with its buoyant stretch refined fourfold, so 0.9 m elements meet 4 m ones at +-- the sag bend and 10 m ones along the grounded run, in a 0.2 m/s current across the line's plane. +-- The catenary-continuation route stops on an element-mean axial compression read on those long +-- elements, where the axial force of a stiff line oscillates: a mesh artefact, not the friction +-- holding the grounded run. The static solve names it as such and goes on to the mesh-sequenced +-- route, which solves the deck. +--------------------- 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 +--------------------- 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 bare 96.0 108 +4 bare 424.0 42 +--------------------- ATTACHMENTS ------------------------------------------- +LineID ArcLength Mass Volume CdA Ca CdAx +(-) (m) (kg) (m^3) (m^2) (-) (m^2) +4 80.0:10.66666667:176.0 228.3 0.45945 3.48 1.0 0.0 +--------------------- 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.025 dtM +0.0 TMax +uniform 0 0.2 0.0 current +--------------------- OUTPUTS ------------------------------------------------ +FairTen4 +AnchTen4 +Point7Fx +Point7Fy +Point7Fz +Point8Fx +Point8Fy +Point8Fz +--------------------- END ---------------------------------------------------- From 2f4d2f55c511a6ed56a85f4be823dde0602ab031 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 02:07:37 +0900 Subject: [PATCH 13/40] fix(statics): an EI = 0 residual cycling inside its round-off floor is converged (#122) Signed-off-by: Jae Hoon Seo --- CMakeLists.txt | 11 +++ doc/theory.rst | 3 +- src/CableDyn_Static.f90 | 9 +++ tests/test_ei0_static_floor_cycle.py | 102 +++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/test_ei0_static_floor_cycle.py diff --git a/CMakeLists.txt b/CMakeLists.txt index fa0133e..dc729c4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1827,6 +1827,17 @@ if(Python3_Interpreter_FOUND) TIMEOUT 600 ENVIRONMENT "${_cabledyn_python_test_environment}") + # EI = 0 statics: a residual that cycles inside its round-off floor is converged, so a line in + # a current solves the same under every small translation of its deck. + add_test(NAME ei0_static_floor_cycle + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ei0_static_floor_cycle.py + --exe $ + --output ${CMAKE_BINARY_DIR}/ei0_static_floor_cycle) + set_tests_properties(ei0_static_floor_cycle PROPERTIES + LABELS "python;static;robustness" + TIMEOUT 300 + ENVIRONMENT "${_cabledyn_python_test_environment}") + # The note on grounded lines that a current moves over a seabed without friction: once per run # on every standalone route, and never without a grounded node, a current, or a march, or # with friction. diff --git a/doc/theory.rst b/doc/theory.rst index 124c259..0e1e8f1 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -1652,7 +1652,8 @@ beyond a :math:`10^{-6}` relative round-off band; a slack element reports zero t :math:`\lVert\mathbf{R}\rVert_\infty < 10^{-14}`, where :math:`s` is the largest of the infinity norms of the external load, the seabed reaction, and any current drag; a residual that stops decreasing inside the round-off floor - :math:`16\,\epsilon\,\max(EA/L_0)\max(\lvert q\rvert, L_0)` of the nodal forces is converged. + :math:`16\,\epsilon\,\max(EA/L_0)\max(\lvert q\rvert, L_0)` of the nodal forces is converged, + whether it stays flat or cycles between values (three iterations without a new minimum). A line with no admissible equilibrium fails closed, naming the line, the seeds and stages tried, and the reason. diff --git a/src/CableDyn_Static.f90 b/src/CableDyn_Static.f90 index 5afd5b3..2ee5aa5 100644 --- a/src/CableDyn_Static.f90 +++ b/src/CableDyn_Static.f90 @@ -726,6 +726,15 @@ SUBROUTINE CD_Static_Cable_Solve(q0, elem_conn, l0, ea, tension_only, f_ext, fix RETURN END IF END IF + ! Inside the round-off floor a residual can also cycle between two or more values (the + ! rounding of the last digits alternating), which the flat test above never sees: when + ! the last stall_window samples set no new minimum, there is no digit left to gain either + IF (k > cfg%stall_window .AND. hist(k) <= r_floor) THEN + IF (lo >= MINVAL(hist(1:k - cfg%stall_window))) THEN + converged = .TRUE. + RETURN + END IF + END IF END IF END DO ! exhausted max_iter without converging or stalling diff --git a/tests/test_ei0_static_floor_cycle.py b/tests/test_ei0_static_floor_cycle.py new file mode 100644 index 0000000..f45c97d --- /dev/null +++ b/tests/test_ei0_static_floor_cycle.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""An EI = 0 static solve whose residual cycles inside its round-off floor is converged. + +A suspended three-section line (wire, then two polyester sections, 1151 m, 316 elements) hangs +between a fairlead and an anchor 693 m below it in a 0.67 m/s uniform current. The slack-capable +static stages bring the residual to about 1e-4 N, inside the round-off floor of its nodal forces +(3e-3 N) and above the relative tolerance (2.6e-5 N). Depending on the last digits of the +coordinates the iterates then settle on one value (flat, converged) or alternate between two +(9.49e-5 and 8.77e-5 N), which the flat-stall test never saw: every stage ran out of iterations +and no equilibrium was found. The deck is translated by 32 small offsets, every one of which must +solve and give the same fairlead and anchor tensions to 1e-7. + +usage: test_ei0_static_floor_cycle.py --exe --output +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +DECK = """--------------------- CableDyn Input File ------------------------------------ +Suspended three-section line in a uniform current +--------------------- LINE TYPES --------------------------------------- +TypeName Diam Mass EA BA EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (N-s) (N-m2) (-) (-) (-) (-) +t1 0.16489335960471715 135.94910020865282 2447083803.755751 -1.0 0.0 1.2 0.008 1.0 0.0 +t2 0.07370991400051977 7.052224668855406 11019101.045086572 -0.8 0.0 2.0 0.4 1.1 0.15 +t3 0.10037023217840212 13.076279295895747 20431686.399837103 -0.8 0.0 2.0 0.4 1.1 0.15 +--------------------- POINTS ------------------------------------------- +ID Type X Y Z +(-) (-) (m) (m) (m) +1 Coupled {xa!r} {ya!r} -67.45321271085669 +2 Fixed {xb!r} {yb!r} -761.0028936967904 +--------------------- LINES -------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +1 1 2 - +--------------------- SECTIONS ----------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +1 t1 296.3708154484327 127 +1 t2 120.1220550938935 55 +1 t3 734.2263766070876 134 +--------------------- OPTIONS ------------------------------------------ +uniform -0.6393706682921939 -0.20430951085352095 0.0 current +0.01 dtM +0.0 TMax +--------------------- OUTPUTS ------------------------------------------ +FairTen1 +AnchTen1 +--------------------- need this line ----------------------------------- +""" + +A = (-239.01858425733275, -260.87547371415656) +B = (-732.1643435012232, -617.3791587315022) +DX = [0.0, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0] +DY = [0.0, 0.3, 3.0, 30.0] + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--exe", required=True) + ap.add_argument("--output", required=True) + a = ap.parse_args() + out = Path(a.output) + out.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + env.setdefault("OPENBLAS_NUM_THREADS", "1") + failures = [] + ref = None + for dx in DX: + for dy in DY: + name = "shift_%g_%g" % (dx, dy) + deck = out / (name + ".dat") + deck.write_text(DECK.format(xa=A[0] + dx, ya=A[1] + dy, xb=B[0] + dx, yb=B[1] + dy), encoding="utf-8", + newline="\n") + root = out / name + p = subprocess.run([a.exe, str(deck), str(root)], capture_output=True, text=True, env=env, + cwd=str(out)) + if p.returncode != 0: + failures.append("shift (%g, %g): exit %d: %s" % (dx, dy, p.returncode, (p.stdout + p.stderr)[-300:])) + continue + rows = Path(str(root) + ".out").read_text(encoding="utf-8").splitlines() + vals = [float(v) for v in rows[2].split()[1:3]] + if ref is None: + ref = vals + for v, r, ch in zip(vals, ref, ("FairTen1", "AnchTen1")): + if abs(v - r) > 1.0e-7 * abs(ref[0]): + failures.append("shift (%g, %g): %s %.8e differs from %.8e" % (dx, dy, ch, v, r)) + print("translated line in a current: %d runs, %d failures" % (len(DX) * len(DY), len(failures))) + for f in failures: + print("FAIL:", f) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) From a0450750e71adce6b37aa73b28a6e96a04c62214 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 02:41:21 +0900 Subject: [PATCH 14/40] fix(statics): the attachment polish accepts the line's residual floor (#124) Signed-off-by: Jae Hoon Seo --- CMakeLists.txt | 11 +++ doc/driver_format.md | 4 +- doc/theory.rst | 8 +- src/CableDyn_DeckDriver.f90 | 110 ++++++++++++++++-------- tests/test_attachment_polish_floor.py | 116 ++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 36 deletions(-) create mode 100644 tests/test_attachment_polish_floor.py diff --git a/CMakeLists.txt b/CMakeLists.txt index dc729c4..4d2d71f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1770,6 +1770,17 @@ if(Python3_Interpreter_FOUND) TIMEOUT 600 ENVIRONMENT "${_cabledyn_python_test_environment}") + # A clump on a cable clamped in a tight bend at its anchor: the attachment polish accepts the + # residual floor of its solves at the coupled static tolerance, as the line's own solve does. + add_test(NAME attachment_polish_floor + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_attachment_polish_floor.py + --exe $ + --output ${CMAKE_BINARY_DIR}/attachment_polish_floor) + set_tests_properties(attachment_polish_floor PROPERTIES + LABELS "python;hermite;static;robustness;attachments" + TIMEOUT 900 + ENVIRONMENT "${_cabledyn_python_test_environment}") + # Finite-EI generalised-alpha blends: force-blend dtM convergence under prescribed surge. add_test(NAME hermite_force_blend_dt COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_hermite_force_blend_dt.py diff --git a/doc/driver_format.md b/doc/driver_format.md index eb2d010..61f1511 100644 --- a/doc/driver_format.md +++ b/doc/driver_format.md @@ -1046,7 +1046,9 @@ Behaviour: then carried to the discrete loads by continuation. The current drag depends on the node's position and line direction, and the weight on the side of the surface an attachment is on, so the final solve is repeated until the node positions change by less - than 1e-9 m and no attachment changes side. On a line restrained in torsion the torsion stage + than 1e-9 m and no attachment changes side. A residual floor above the tight static tolerance + is accepted at the coupled static tolerance 1e-6, as on a line without attachments, and noted + with the residual reached. On a line restrained in torsion the torsion stage (the imposed twist) follows on the discrete equilibrium, with the attachments' net weights and drag as nodal loads (the drag again to a fixed point), so a run starts in the twisted equilibrium. An attachment adds no polar inertia to the line's twist. diff --git a/doc/theory.rst b/doc/theory.rst index 0e1e8f1..61129c2 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -3016,8 +3016,12 @@ weight in water above it has no static position, and the solve fails closed, nam attachment that crossed the surface). Before, an attachment above the surface kept its buoyancy in the statics: a 500 kg, 0.2 m\ :sup:`3` module 3 m above it moved the fairlead load by up to 6.1 % in the first second of the march. The load moves -in eighths; a step whose damped Newton solve does not converge is repeated from the last -converged state with pseudo-transient continuation and then with a residual-merit line search, +in eighths; a solve that stalls on a residual floor above the tight static tolerance is +repeated from the same state at the coupled static tolerance :math:`10^{-6}`, as the line's own +static solve accepts such a floor, and the run notes the residual of the discrete equilibrium +when it lies above the tight tolerance. A step whose damped Newton solve does not converge is +repeated from the last converged state with pseudo-transient continuation and then with a +residual-merit line search, and is halved (down to 1/1024) when neither converges. A halved step doubles again (up to 1/8) after two consecutive converged steps of its size; each time a doubled step fails, the number of converged steps a doubling waits for doubles, so a homotopy that converges steadily at one diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index 4a76573..e830fe0 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -1874,6 +1874,9 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, LOGICAL :: polish_ready ! Set when the attachment polish needed its fallbacks (attachment_fallback_checks). LOGICAL :: att_fell_back + ! The relative residual of the discrete attachment equilibrium when a residual floor kept it + ! above the tight static tolerance (0 otherwise). + REAL(wp) :: att_floor_res LOGICAL :: bathymetry_flat, require_3d_static, sym_broken, planar_unstable, saddle_kept ! whether the line would be solved in 3D without its torsion (a twisted line always is) LOGICAL :: require_3d_untwisted @@ -3305,7 +3308,8 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, CALL attachment_static_polish(l0, ea, ei, w_bare, w, diam, att_node, ln%att, q_static, fixed_dofs, & contact_enabled, contact_z, frame_cs, endconn_k, endconn_d0_local, & endconn_mode, opts, static_tol, ErrStat, ErrMsg, bathymetry, cur_obj, & - fell_back=att_fell_back, cdn_bare=att_cdn_bare, cdt_bare=att_cdt_bare) + fell_back=att_fell_back, cdn_bare=att_cdn_bare, cdt_bare=att_cdt_bare, & + tol_floor=COUPLED_STATIC_TOL, floor_res=att_floor_res) ! the discrete equilibrium carries the line's own drag (the attachments' at their nodes) IF (ALLOCATED(att_cdn_bare)) THEN cur_obj%cdn = att_cdn_bare @@ -3326,6 +3330,12 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, 'after a failed Newton solve (pseudo-transient continuation, line search or smaller '// & 'load steps) and passed the acceptance checks of the smeared state') END IF + IF (att_floor_res > CD_ZERO) THEN + WRITE (note_text, '(A,I0,A,ES9.2,A,ES9.2)') 'line ', ln%id, ': static residual floor above the '// & + 'dynamic tolerance ', static_tol_target, '; discrete attachment equilibrium accepted at the relative '// & + 'residual ', att_floor_res + CALL report_note(note_text) + END IF ! On the final mesh, with the bare weights the polished state carries; the bending-length ! rule of the note uses the smeared section weights w. A line restrained in torsion starts ! from the twisted state, whose stability the torsion stage tests (with the same static @@ -5535,7 +5545,7 @@ END SUBROUTINE smear_attachment_drag SUBROUTINE attachment_static_polish(l0, ea, ei, w_bare, w_smear, diam, node, att, q, fixed_dofs, & contact_enabled, contact_z, frame_cs, endconn_k, endconn_d0, endconn_mode, & opts, tol, ErrStat, ErrMsg, bathymetry, cur_obj, fell_back, cdn_bare, & - cdt_bare) + cdt_bare, tol_floor, floor_res) !! Homotopy from the smeared-attachment equilibrium q to the discrete one: in warm-started !! Newton solves the element weights move from w_smear to w_bare while the lumped nodal !! forces -(m - rho V) g e_z (-m g e_z for an attachment above the still-water level, @@ -5572,6 +5582,11 @@ SUBROUTINE attachment_static_polish(l0, ea, ei, w_bare, w_smear, diam, node, att !! was reached with the fallbacks (and the caller re-runs its acceptance checks on it). !! A failure names the load fraction of the first failed solve and, when the homotopy !! stops, the load fraction it reached. + !! tol_floor (optional, > tol) is the tolerance of a residual floor: a solve whose Newton + !! iteration stalls above tol is repeated at tol_floor, from the same incoming state, before + !! the next update rule, as the line's own static solve accepts a floor at the coupled static + !! tolerance; floor_res (optional out) is the relative residual of the returned state when it + !! lies above tol, and 0 otherwise. REAL(wp), INTENT(IN) :: l0(:), ea(:), ei(:), w_bare(:), w_smear(:), diam(:), att(:, :) INTEGER, INTENT(IN) :: node(:), fixed_dofs(:), endconn_mode(2) REAL(wp), INTENT(INOUT) :: q(:) @@ -5583,8 +5598,11 @@ SUBROUTINE attachment_static_polish(l0, ea, ei, w_bare, w_smear, diam, node, att TYPE(CD_BathymetryType), INTENT(IN), OPTIONAL :: bathymetry TYPE(CD_HermiteStaticCurrentType), INTENT(IN), OPTIONAL :: cur_obj LOGICAL, INTENT(OUT), OPTIONAL :: fell_back - REAL(wp), INTENT(IN), OPTIONAL :: cdn_bare(:), cdt_bare(:) + REAL(wp), INTENT(IN), OPTIONAL :: cdn_bare(:), cdt_bare(:), tol_floor + REAL(wp), INTENT(OUT), OPTIONAL :: floor_res INTEGER, PARAMETER :: NSTEP = 8, MAX_FIXED_POINT = 60 + ! the update rules of a solve (solve_with, static_solve) + INTEGER, PARAMETER :: NEWTON = 1, PSEUDO_TRANSIENT = 2, MERIT = 3 ! Smallest load-fraction step: 1/NSTEP halved seven times. Newton budget once a damped ! Newton solve of this polish has failed (a converging one takes well under it). Budget ! of failed load steps: each takes at most NEWTON_RETRY_ITER + 400 Newton iterations @@ -5610,9 +5628,16 @@ SUBROUTINE attachment_static_polish(l0, ea, ei, w_bare, w_smear, diam, node, att TYPE(CD_HermiteStaticCurrentType), ALLOCATABLE :: cur_l REAL(wp), ALLOCATABLE :: cdn_smear(:), cdt_smear(:) LOGICAL :: blend_drag + ! the looser tolerance of a residual floor, and the number of tolerances a solve may take + REAL(wp) :: tol_loose + INTEGER :: n_tol ErrStat = CD_DECKDRV_OK; ErrMsg = '' IF (PRESENT(fell_back)) fell_back = .FALSE. + IF (PRESENT(floor_res)) floor_res = CD_ZERO + tol_loose = tol + IF (PRESENT(tol_floor)) tol_loose = MAX(tol, tol_floor) + n_tol = MERGE(2, 1, tol_loose > tol) blend_drag = .FALSE. IF (PRESENT(cur_obj)) THEN ALLOCATE (cur_l, SOURCE=cur_obj) @@ -5748,8 +5773,8 @@ SUBROUTINE solve_with(scale, ok, dq_max) REAL(wp), INTENT(IN) :: scale LOGICAL, INTENT(OUT) :: ok REAL(wp), INTENT(OUT), OPTIONAL :: dq_max - INTEGER, PARAMETER :: NEWTON = 1, PSEUDO_TRANSIENT = 2, MERIT = 3 - INTEGER :: it, es, mode, max_it(3) + INTEGER :: it, es, mode, max_it(3), k_tol + REAL(wp) :: tol_k CHARACTER(1024) :: em CHARACTER(48) :: num_buf ok = .FALSE. @@ -5768,34 +5793,18 @@ SUBROUTINE solve_with(scale, ok, dq_max) END IF END IF DO mode = NEWTON, MERIT - IF (contact_enabled) THEN - CALL CD_HermiteCable_Static_Solve(l0, ea, ei, wl, q, fixed_dofs, contact_z, CD_ZERO, 1, max_it(mode), & - tol, 0.7_wp, q_out, curv, res, it, es, em, & - f_nodal=scale*(f_att + f_drag), n_buoy_steps=1, contact_kn=knnode, & - bathymetry=bathymetry, contact_frame_cs=frame_cs, & - axial_quadrature_order=opts%axial_quadrature_order, & - bending_quadrature_order=opts%bending_quadrature_order, & - endconn_stiffness=endconn_k, endconn_direction=endconn_d0, & - endconn_mode=endconn_mode, waterline_z=CD_ZERO, dry_buoyancy=dry, & - water_weight=opts%rhoW*opts%g, current=cur_l, & - backtracking=mode == MERIT, merit_line_search=mode == MERIT, & - pseudo_transient=mode == PSEUDO_TRANSIENT) - ELSE - CALL CD_HermiteCable_Static_Solve(l0, ea, ei, wl, q, fixed_dofs, contact_z, CD_ZERO, 1, max_it(mode), & - tol, 0.7_wp, q_out, curv, res, it, es, em, & - f_nodal=scale*(f_att + f_drag), n_buoy_steps=1, & - axial_quadrature_order=opts%axial_quadrature_order, & - bending_quadrature_order=opts%bending_quadrature_order, & - endconn_stiffness=endconn_k, endconn_direction=endconn_d0, & - endconn_mode=endconn_mode, waterline_z=CD_ZERO, dry_buoyancy=dry, & - water_weight=opts%rhoW*opts%g, current=cur_l, & - backtracking=mode == MERIT, merit_line_search=mode == MERIT, & - pseudo_transient=mode == PSEUDO_TRANSIENT) - END IF - IF (es == CD_HCSTAT_OK .AND. res <= tol) THEN - ok = .TRUE. - EXIT - END IF + ! A solve that stalls on a residual floor above the tight tolerance is repeated at the + ! looser one from the same incoming state, as the line's own static solve is + DO k_tol = 1, n_tol + tol_k = MERGE(tol, tol_loose, k_tol == 1) + CALL static_solve(mode, tol_k, scale, max_it, it, es, em) + IF (es == CD_HCSTAT_OK .AND. res <= tol_k) THEN + ok = .TRUE. + EXIT + END IF + IF (es == CD_HCSTAT_BADINPUT .OR. INDEX(em, 'Newton did not converge') == 0) EXIT + END DO + IF (ok) EXIT IF (mode == NEWTON) THEN ! numbers into a local buffer: an internal WRITE into the caller's ErrMsg would abort ! on a short buffer @@ -5814,7 +5823,42 @@ SUBROUTINE solve_with(scale, ok, dq_max) IF (.NOT. ok) RETURN IF (PRESENT(dq_max)) dq_max = MAXVAL(ABS(q_out - q)) q = q_out + ! the residual of the state the polish returns, when it lies above the tight tolerance + IF (PRESENT(floor_res)) floor_res = MERGE(res, CD_ZERO, res > tol) END SUBROUTINE solve_with + + SUBROUTINE static_solve(mode_s, tol_s, scale, max_it, it, es, em) + !! One warm-started static solve of solve_with at load fraction scale with the update rule + !! mode_s (pseudo_transient 2, merit 3) and the tolerance tol_s, from q into q_out, curv, res. + INTEGER, INTENT(IN) :: mode_s, max_it(3) + REAL(wp), INTENT(IN) :: tol_s, scale + INTEGER, INTENT(OUT) :: it, es + CHARACTER(*), INTENT(OUT) :: em + IF (contact_enabled) THEN + CALL CD_HermiteCable_Static_Solve(l0, ea, ei, wl, q, fixed_dofs, contact_z, CD_ZERO, 1, max_it(mode_s), & + tol_s, 0.7_wp, q_out, curv, res, it, es, em, & + f_nodal=scale*(f_att + f_drag), n_buoy_steps=1, contact_kn=knnode, & + bathymetry=bathymetry, contact_frame_cs=frame_cs, & + axial_quadrature_order=opts%axial_quadrature_order, & + bending_quadrature_order=opts%bending_quadrature_order, & + endconn_stiffness=endconn_k, endconn_direction=endconn_d0, & + endconn_mode=endconn_mode, waterline_z=CD_ZERO, dry_buoyancy=dry, & + water_weight=opts%rhoW*opts%g, current=cur_l, & + backtracking=mode_s == MERIT, merit_line_search=mode_s == MERIT, & + pseudo_transient=mode_s == PSEUDO_TRANSIENT) + ELSE + CALL CD_HermiteCable_Static_Solve(l0, ea, ei, wl, q, fixed_dofs, contact_z, CD_ZERO, 1, max_it(mode_s), & + tol_s, 0.7_wp, q_out, curv, res, it, es, em, & + f_nodal=scale*(f_att + f_drag), n_buoy_steps=1, & + axial_quadrature_order=opts%axial_quadrature_order, & + bending_quadrature_order=opts%bending_quadrature_order, & + endconn_stiffness=endconn_k, endconn_direction=endconn_d0, & + endconn_mode=endconn_mode, waterline_z=CD_ZERO, dry_buoyancy=dry, & + water_weight=opts%rhoW*opts%g, current=cur_l, & + backtracking=mode_s == MERIT, merit_line_search=mode_s == MERIT, & + pseudo_transient=mode_s == PSEUDO_TRANSIENT) + END IF + END SUBROUTINE static_solve END SUBROUTINE attachment_static_polish SUBROUTINE attachment_static_drag(q, node, att, opts, frame_cs, f, ErrStat, ErrMsg) diff --git a/tests/test_attachment_polish_floor.py b/tests/test_attachment_polish_floor.py new file mode 100644 index 0000000..4a78888 --- /dev/null +++ b/tests/test_attachment_polish_floor.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""The discrete attachment polish accepts a residual floor as the line's own static solve does. + +A 920 m suspended finite-EI cable is clamped at its anchor along -x, so that it arrives in a +tight bend (the deck mesh is refined about fivefold there), and carries one clump weight. The +warm-started solves of the attachment polish stall on a residual floor at a few times the tight +static tolerance (the smeared equilibrium of some of these lines is itself accepted at its +floor), and the polish failed. The clump mass (400 and 1600 kg) and its arc position (450 and +760 m) are swept. Every run must reach the discrete equilibrium without a fallback note, and its +end forces must balance the line's submerged weight and the clump's weight to 1e-5 of the +fairlead tension. + +usage: test_attachment_polish_floor.py --exe --output +""" + +from __future__ import annotations + +import argparse +import math +import os +import subprocess +import sys +from pathlib import Path + +DECK = """--------------------- CableDyn Input File ------------------------------------ +Suspended finite-EI cable clamped at its anchor in a tight bend, with one clump weight +--------------------- LINE TYPES --------------------------------------- +TypeName Diam Mass EA BA EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (N-s) (N-m2) (-) (-) (-) (-) +cab 0.26 91.0 9.3e8 0.0 2.5e4 1.2 0.01 1.0 0.0 +--------------------- POINTS ------------------------------------------- +ID Type X Y Z +(-) (-) (m) (m) (m) +1 Coupled 0.0 0.0 -10.0 +2 Fixed 915.0 0.0 -171.0 +--------------------- LINES -------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +1 1 2 - +--------------------- SECTIONS ----------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +1 cab 920.0 127 +--------------------- END CONNECTIONS ------------------------------- +LineID End Stiffness EzX EzY EzZ +(-) (-) (N-m/rad) (-) (-) (-) +1 B Rigid -1.0 0.0 0.0 +--------------------- ATTACHMENTS ---------------------------------- +LineID ArcLength Mass Volume CdA Ca CdAx +(-) (m) (kg) (m^3) (m^2) (-) (m^2) +1 {arc} {mass} 0.0 0.44 1.0 0.25 +--------------------- OPTIONS ------------------------------------------ +0.01 dtM +0.0 TMax +--------------------- OUTPUTS ------------------------------------------ +FairTen1 +Point1Fx +Point1Fy +Point1Fz +Point2Fx +Point2Fy +Point2Fz +--------------------- need this line ----------------------------------- +""" + +G, RHO = 9.80665, 1025.0 +W_LINE = (91.0 - RHO * math.pi * 0.26**2 / 4.0) * G * 920.0 +MASS = ["400.0", "1600.0"] +ARC = ["450.0", "760.0"] + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--exe", required=True) + ap.add_argument("--output", required=True) + a = ap.parse_args() + out = Path(a.output) + out.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + env.setdefault("OPENBLAS_NUM_THREADS", "1") + failures = [] + for mass in MASS: + for arc in ARC: + name = "clump_%s_%s" % (mass, arc) + deck = out / (name + ".dat") + deck.write_text(DECK.format(mass=mass, arc=arc), encoding="utf-8", newline="\n") + root = out / name + p = subprocess.run([a.exe, str(deck), str(root)], capture_output=True, text=True, env=env, + cwd=str(out)) + log = p.stdout + p.stderr + if p.returncode != 0: + failures.append("mass %s arc %s: exit %d: %s" % (mass, arc, p.returncode, log[-400:])) + continue + if "after a failed Newton solve" in log: + failures.append("mass %s arc %s: the polish fell back after a failed Newton solve" % (mass, arc)) + rows = Path(str(root) + ".out").read_text(encoding="utf-8").splitlines() + names = rows[1].split() + vals = [float(v) for v in rows[2].split()] + f = {n: vals[names.index(n)] for n in names[1:]} + sx = f["Point1Fx"] + f["Point2Fx"] + sy = f["Point1Fy"] + f["Point2Fy"] + sz = f["Point1Fz"] + f["Point2Fz"] + W_LINE + float(mass) * G + err = math.sqrt(sx * sx + sy * sy + sz * sz) / f["FairTen1"] + if err > 1.0e-5: + failures.append("mass %s arc %s: end forces miss the weight by %.3e of the fairlead tension" + % (mass, arc, err)) + print("clamped cable with a clump: %d runs, %d failures" % (len(MASS) * len(ARC), len(failures))) + for f in failures: + print("FAIL:", f) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 58519a2faf378c25362707ebc64fc4c8f6779b40 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 02:44:54 +0900 Subject: [PATCH 15/40] fix(deck): named errors and validator parity for four deck rules (#127) Signed-off-by: Jae Hoon Seo --- python/cabledyn/deck_file.py | 68 +++++++++++- python/tests/test_deck_native_parity.py | 140 ++++++++++++++++++++++++ src/CableDyn_DeckDriver.f90 | 17 +++ tests/test_deck_driver.f90 | 31 ++++++ 4 files changed, 254 insertions(+), 2 deletions(-) diff --git a/python/cabledyn/deck_file.py b/python/cabledyn/deck_file.py index 0b77789..c9e957f 100644 --- a/python/cabledyn/deck_file.py +++ b/python/cabledyn/deck_file.py @@ -4792,6 +4792,17 @@ def is_body_kind(kind: str, pinned: bool) -> bool: f"{self._label}: a mixed-topology deck (bodies, rods, dynamic points, EI=0 and " "finite-EI lines together) does not support motionFile coupling" ) + if deck_driver_route and not needs_multibody: + # native CD_Run_Deck_Driver: the standalone finite-EI march builds every line as a + # finite-EI cable, so a line's sections all need a finite EI + for line_id, section_types in section_types_by_line.items(): + finite_sections = [line_type_meta[name][3] > 0.0 for name in section_types] + if any(finite_sections) and not all(finite_sections): + raise DeckFormatError( + f"{self._label}: LINE {line_id} combines EI=0 and finite-EI sections, " + "which the standalone finite-EI march does not take: give every section " + "of the line a finite EI" + ) if deck_driver_route and needs_multibody and (has_dtm or has_tmax): self._check_multibody_cable_ends( finite_line_ids, @@ -4928,6 +4939,13 @@ def is_body_kind(kind: str, pinned: bool) -> bool: raise DeckFormatError( f"{self._label}: used LINE TYPE {line_type!r} requires Diam > 0" ) + # native finalize_checks: a finite-EI line carries its mass in the bending dynamics + # and its weight in the statics + if ei > 0.0 and mass <= 0.0: + raise DeckFormatError( + f"{self._label}: used LINE TYPE {line_type!r}: MassDenInAir must be > 0 on a " + "finite-EI line" + ) damping, coefficient = line_type_scales[line_type] if ( 0.0 < ea_static < _NATIVE_MIN_EA @@ -5100,6 +5118,27 @@ def is_body_kind(kind: str, pinned: bool) -> bool: # Native check_channel: ids are digit strings compared by value, so leading zeros # are accepted (FairTen01 is FairTen1). stiffened_lines = {str(_native_int(row.tokens[0])) for row in self._rows("STIFFENERS")} + # Point

p channels are read from the EI=0 system's point store. The aggregate + # (a host route, or the standalone mixed deck) has no live entry for a Coupled/Vessel + # point that only finite-EI lines reach (native validate_aggregate_point_channels); + # the standalone multibody march has no system without an EI=0 line. + finite_line_set = set(finite_line_ids) + cable_points = { + end + for line_id, ends in normalized_line_endpoints.items() + if line_id in finite_line_set + for end in ends + } + chain_points = { + end + for line_id, ends in normalized_line_endpoints.items() + if line_id not in finite_line_set + for end in ends + } + aggregate_points = self.caller_driven or mixed_route + multibody_without_system = ( + deck_driver_route and needs_multibody and line_ids and all(finite_lines) + ) for channel in channels: lowered = channel.lower() endpoint_channel = re.fullmatch( @@ -5108,9 +5147,9 @@ def is_body_kind(kind: str, pinned: bool) -> bool: lowered, ) # Con

p{x,y,z} is the native alias of Point

p{x,y,z} (MoorDyn v1 spelling). - point_channel = re.fullmatch(r"(?:point|con)([0-9]+)p([xyz])", lowered) + position_channel = re.fullmatch(r"(?:point|con)([0-9]+)p([xyz])", lowered) # Point

F{x,y,z,H}: resultant of the line-end forces on the point. - point_channel = point_channel or re.fullmatch(r"point([0-9]+)f([xyzh])", lowered) + point_channel = position_channel or re.fullmatch(r"point([0-9]+)f([xyzh])", lowered) # MoorDyn-F body/rod channels (native parse_object_channel, OBJ_QTY) body_channel = re.fullmatch(r"body([0-9]+)(?:p|r|v|rv|a|ra|f|m)[xyz]", lowered) rod_channel = re.fullmatch( @@ -5179,6 +5218,23 @@ def is_body_kind(kind: str, pinned: bool) -> bool: raise DeckFormatError( f"{self._label}: OUTPUT {channel!r} references an unknown point" ) + position = position_channel is not None + if ( + position + and aggregate_points + and point_id in cable_points + and point_id not in chain_points + and point_types.get(point_id) in {"coupled", "vessel"} + ): + raise DeckFormatError( + f"{self._label}: aggregate OUTPUT {channel!r} targets a cable-only point; " + "use the finite-EI cable line-node position channel instead" + ) + if position and multibody_without_system: + raise DeckFormatError( + f"{self._label}: OUTPUT {channel!r}: Point channels need an EI=0 mooring " + "system (a cable-endpoint Point channel is not supported)" + ) continue # TDP{s,x,y,z,Lay,Exc,Grd}: the touchdown point of line L (native # CD_Parse_TDP_Channel) @@ -5188,6 +5244,14 @@ def is_body_kind(kind: str, pinned: bool) -> bool: raise DeckFormatError( f"{self._label}: OUTPUT {channel!r} references an unknown line" ) + # native CD_Range_Setup: the touchdown point lies on a seabed, which a standalone + # deck states itself (a host gives its own water depth) + if not self.caller_driven and not (has_depth or has_bathymetry): + raise DeckFormatError( + f"{self._label}: TDP channels of line " + f"{_channel_id(touchdown_channel.group(1))} need a seabed (WtrDpth or a " + "bathymetry file)" + ) continue if lowered.startswith(("torq", "twist")): # native: TorqN, TwistN and Twist on a torsional line diff --git a/python/tests/test_deck_native_parity.py b/python/tests/test_deck_native_parity.py index f968114..df4b136 100644 --- a/python/tests/test_deck_native_parity.py +++ b/python/tests/test_deck_native_parity.py @@ -2573,3 +2573,143 @@ def test_static_check_clock_gets_the_native_verdict(text, accepted, tmp_path): ) assert python_accepts == accepted assert (completed.returncode == 0) == accepted, completed.stdout + completed.stderr + + +# --------------------------------------------------------------- deck rules of the native routes + +_STIFF = "stiff 0.252 390.0 1.674e9 -1.0 1.0e4 1.37 0.64 1.0 0.0\n" +_CHAIN_TYPE = "chain 0.252 390.0 1.674e9 -1.0 0.0 1.37 0.64 1.0 0.0\n" +# _HELD with a second (finite-EI) line type and its line 1 split between both types +_SPLIT_LINE = _edit( + _HELD, + (_CHAIN_TYPE, _CHAIN_TYPE + _STIFF), + ("1 chain 410.0 41\n", "1 chain 200.0 20\n1 stiff 210.0 21\n"), +) +# ... and a second, EI=0 line beside it (the standalone mixed route) +_SPLIT_LINE_AND_CHAIN = _edit( + _SPLIT_LINE, + ( + "2 Coupled 0.0 0.0 0.0 0 0 0 0\n", + "2 Coupled 0.0 0.0 0.0 0 0 0 0\n3 Fixed -400.0 0.0 -50.0 0 0 0 0\n", + ), + ("1 2 1 -\n", "1 2 1 -\n2 2 3 -\n"), + ("1 stiff 210.0 21\n", "1 stiff 210.0 21\n2 chain 410.0 41\n"), +) +# a finite-EI line on its own +_FINITE = _edit(_HELD, (_CHAIN_TYPE, _CHAIN_TYPE.replace(" 0.0 1.37", " 1.0e4 1.37"))) +# a finite-EI line from Coupled point 2 beside an EI=0 line from Coupled point 4 (the mixed route) +_FINITE_AND_CHAIN = _edit( + _HELD, + (_CHAIN_TYPE, _CHAIN_TYPE + _STIFF), + ( + "2 Coupled 0.0 0.0 0.0 0 0 0 0\n", + "2 Coupled 0.0 0.0 0.0 0 0 0 0\n" + "3 Fixed -400.0 0.0 -50.0 0 0 0 0\n4 Coupled -10.0 0.0 0.0 0 0 0 0\n", + ), + ("1 2 1 -\n", "1 2 1 -\n2 4 3 -\n"), + ("1 chain 410.0 41\n", "1 stiff 410.0 41\n2 chain 410.0 41\n"), +) +# the submerged Rigid6 buoy of the examples with finite-EI legs (the standalone multibody march) +_BUOY_FINITE_LEGS = _example( + "rigid6_buoy.dat", + ("5.0e7 -1.0 0.0 ", "5.0e7 -1.0 1.0e3 "), + ("60.0 TMax", "0.04 TMax"), +) +_BUOY_FORCES = _edit( + _BUOY_FINITE_LEGS, + ('"Point1px"\n"Point1pz"\n"Point2pz"\n"Point3pz"\n', '"Point1Fx"\n"Point2Fz"\n'), +) + + +_NO_SEABED_TDP = _edit(_STATIC, ("50.0 WtrDpth\n", ""), ("FairTen1\n", "FairTen1\nTDP1s\n")) +_FINITE_POINT_POSITIONS = _edit(_FINITE, ("FairTen1\n", "FairTen1\nPoint2px\nPoint1pz\nPoint2Fz\n")) +_MIXED_CABLE_POINT = _edit(_FINITE_AND_CHAIN, ("FairTen1\n", "FairTen1\nPoint2px\n")) +_MIXED_CHAIN_POINT = _edit(_FINITE_AND_CHAIN, ("FairTen1\n", "FairTen1\nPoint4px\nPoint2Fz\n")) +# (name, deck, caller_driven, message): None accepts +_ROUTE_RULE_CASES = [ + # TDP channels need a seabed on the standalone routes (a host gives the depth) + ("tdp-no-seabed", _NO_SEABED_TDP, False, r"TDP channels of line 1 need a seabed"), + ("tdp-no-seabed-host", _NO_SEABED_TDP, True, None), + ("tdp-seabed", _edit(_STATIC, ("FairTen1\n", "FairTen1\nTDP1s\n")), False, None), + # a finite-EI line needs mass, on every route + ("finite-mass-0", _edit(_FINITE, (" 390.0 ", " 0 ")), False, r"MassDenInAir must be > 0"), + ("finite-mass-neg", _edit(_FINITE, (" 390.0 ", " -1 ")), False, r"MassDenInAir must be > 0"), + ( + "finite-mass-neg-host", + _edit(_FINITE, (" 390.0 ", " -1 ")), + True, + r"MassDenInAir must be > 0", + ), + ("finite-mass-small", _edit(_FINITE, (" 390.0 ", " 1e-3 ")), False, None), + # the standalone finite-EI march takes lines whose sections all have a finite EI + ("split-line", _SPLIT_LINE, False, r"LINE 1 combines EI=0 and finite-EI sections"), + ( + "split-line-static", + _static_check(_SPLIT_LINE, keep_dtm=True), + False, + r"LINE 1 combines EI=0 and finite-EI sections", + ), + ("split-line-host", _SPLIT_LINE, True, None), + ("split-line-mixed-route", _SPLIT_LINE_AND_CHAIN, False, None), + # Point

p channels are read from the EI=0 system: on the aggregate a Coupled point of + # finite-EI lines alone has no live entry there, and a multibody march with no EI=0 line + # has no system at all + ("finite-point-channels", _FINITE_POINT_POSITIONS, False, None), + ( + "finite-cable-point-host", + _edit(_FINITE, ("FairTen1\n", "FairTen1\nPoint2px\n")), + True, + r"targets a cable-only point", + ), + ( + "finite-fixed-point-host", + _edit(_FINITE, ("FairTen1\n", "FairTen1\nPoint1pz\nPoint2Fz\n")), + True, + None, + ), + ("mixed-cable-point", _MIXED_CABLE_POINT, False, r"targets a cable-only point"), + ("mixed-chain-point", _MIXED_CHAIN_POINT, False, None), + ("buoy-finite-legs-forces", _BUOY_FORCES, False, None), + ( + "buoy-finite-legs-positions", + _BUOY_FINITE_LEGS, + False, + r"Point channels need an EI=0 mooring system", + ), +] + + +@pytest.mark.parametrize( + ("text", "caller_driven", "message"), + [pytest.param(*case[1:], id=case[0]) for case in _ROUTE_RULE_CASES], +) +def test_route_rules_of_the_native_reader(text, caller_driven, message): + if message is None: + DeckFile.from_text(text, caller_driven=caller_driven) + else: + _rejects(text, message, caller_driven=caller_driven) + + +@pytest.mark.skipif( + not _PARITY_DRIVER or not Path(_PARITY_DRIVER).is_file(), + reason="set CABLEDYN_TEST_DRIVER to a built native driver to run the route-rule parity", +) +@pytest.mark.parametrize( + "text", + [pytest.param(case[1], id=case[0]) for case in _ROUTE_RULE_CASES if not case[2]], +) +def test_route_rules_match_the_driver(text, tmp_path): + deck = tmp_path / "rules.dat" + deck.write_text(text, encoding="utf-8") + completed = subprocess.run( + [_PARITY_DRIVER, str(deck), str(tmp_path / "rules")], + capture_output=True, + text=True, + errors="replace", + cwd=tmp_path, + timeout=600, + env={**os.environ, "OMP_NUM_THREADS": "1", "OPENBLAS_NUM_THREADS": "1"}, + check=False, + ) + assert completed.returncode in (0, 1), completed.stderr[-600:] + assert _accepted(deck) == (completed.returncode == 0), completed.stderr[-600:] diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index e830fe0..6aa49a4 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -9764,6 +9764,17 @@ SUBROUTINE run_deck_driver(deck_path, out_root, converged, ErrStat, ErrMsg) ! coerced through the all-lines Hermite builder. IF (.NOT. all_lines_have_finite_ei(types, lines, sections)) THEN IF (opts%has_bathymetry_file) CALL CD_End_Bathymetry(bathymetry) + ! name the line that has both kinds of section (the standalone executable sends a deck + ! of separate EI=0 and finite-EI lines to the aggregate, never here) + DO li = 1, SIZE(lines) + IF (line_is_finite_ei(lines(li), types, sections) .AND. & + .NOT. all_lines_have_finite_ei(types, lines(li:li), sections)) THEN + CALL fail(ErrStat, ErrMsg, 'LINE '//TRIM(int_to_str(lines(li)%id))//' combines EI=0 and '// & + 'finite-EI sections, which the standalone finite-EI march does not take: give every '// & + 'section of the line a finite EI') + RETURN + END IF + END DO CALL fail(ErrStat, ErrMsg, 'mixed EI=0 and finite-EI dynamic decks are not supported by this entry point') RETURN END IF @@ -19092,6 +19103,12 @@ SUBROUTINE finalize_checks(types, bodies, rod_types, rods, points, lines, sectio CALL fail(ErrStat, ErrMsg, 'LINE TYPE "'//TRIM(types(idx)%name)// & '": Diam and EA must be > 0'); RETURN END IF + ! A finite-EI line carries its mass in the bending dynamics and its weight in the statics + ! (an EI=0 line without mass is refused as net-buoyant below). + IF (types(idx)%ei > CD_ZERO .AND. .NOT. (types(idx)%mass > CD_ZERO)) THEN + CALL fail(ErrStat, ErrMsg, 'LINE TYPE "'//TRIM(types(idx)%name)// & + '": MassDenInAir must be > 0 on a finite-EI line'); RETURN + END IF ! Net-buoyant gate: EI=0 decks cannot build a lazy-wave arch. The finite-EI ! route has the cubic-Hermite arch seed and may proceed. A dynamic standalone ! run gets its clock from dtM; the executable's metadata scan also admits a diff --git a/tests/test_deck_driver.f90 b/tests/test_deck_driver.f90 index 224a106..78f0d38 100644 --- a/tests/test_deck_driver.f90 +++ b/tests/test_deck_driver.f90 @@ -2166,6 +2166,37 @@ SUBROUTINE check_parser_hardening() CALL require(es_l == CD_DECKDRV_OK, 'caller-driven current deck needs no dtM: '//TRIM(em_l)) IF (es_l == CD_DECKDRV_OK) CALL CD_End_Deck_Aggregate(cagg) END BLOCK + + ! A finite-EI line needs mass, and the standalone finite-EI march a line whose sections all + ! have a finite EI: both are named input errors, not a failure inside the model build. + BLOCK + CHARACTER(*), PARAMETER :: CHAIN_ROW = 'chain 0.252 390.0 1.674e9 -1.0 0.0 1.37 0.64 1.0 0.0' + CHARACTER(*), PARAMETER :: STIFF_ROW = 'stiff 0.252 390.0 1.674e9 -1.0 1.0e4 1.37 0.64 1.0 0.0' + CHARACTER(:), ALLOCATABLE :: body + CHARACTER(1) :: nl + CHARACTER(8), PARAMETER :: MASSES(2) = [CHARACTER(8) :: '0.0', '-1.0'] + INTEGER :: k, m + nl = NEW_LINE('a') + DO m = 1, SIZE(MASSES) + body = hardening_deck(ANCHOR, SECTION, '0.1 dtM'//nl//'0.2 TMax', 'FairTen1') + k = INDEX(body, CHAIN_ROW) + CALL require(k > 0, 'finite-EI mass fixture locates the LINE TYPES row') + IF (k == 0) CYCLE + body = body(1:k - 1)//'chain 0.252 '//TRIM(MASSES(m))//' 1.674e9 -1.0 1.0e4 1.37 0.64 1.0 0.0'// & + body(k + LEN(CHAIN_ROW):) + CALL expect_badinput('deck_hard_finite_mass.dat', body, 'a finite-EI line type of mass '//TRIM(MASSES(m)), & + 'LINE TYPE "chain": MassDenInAir must be > 0 on a finite-EI line') + END DO + body = hardening_deck(ANCHOR, '1 chain 200.0 20'//nl//'1 stiff 210.0 21', '0.1 dtM'//nl//'0.2 TMax', & + 'FairTen1') + k = INDEX(body, CHAIN_ROW) + CALL require(k > 0, 'split-line fixture locates the LINE TYPES row') + IF (k > 0) THEN + body = body(1:k - 1)//CHAIN_ROW//nl//STIFF_ROW//body(k + LEN(CHAIN_ROW):) + CALL expect_badinput('deck_hard_split_line.dat', body, 'a line of EI=0 and finite-EI sections', & + 'LINE 1 combines EI=0 and finite-EI sections') + END IF + END BLOCK END SUBROUTINE check_parser_hardening FUNCTION rigid6_failure_deck(failure_rows, extra_point) RESULT(body) From 451a4adf53896d5dc24263b0af1eadee14759af9 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 02:51:09 +0900 Subject: [PATCH 16/40] fix(hermite): the axial damping in the round-off floor of the dynamic residual (#123) Signed-off-by: Jae Hoon Seo --- CMakeLists.txt | 11 +++ doc/theory.rst | 6 +- src/CableDyn_HermiteCableDynamic.f90 | 24 ++++--- tests/test_hermite_damped_rest.py | 101 +++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 tests/test_hermite_damped_rest.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d2d71f..0317770 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1804,6 +1804,17 @@ if(Python3_Interpreter_FOUND) TIMEOUT 600 ENVIRONMENT "${_cabledyn_python_test_environment}") + # A heavily damped finite-EI line held at rest completes every step and stays at rest over + # a sweep of the axial damping coefficient and dtM (the damping's residual round-off floor). + add_test(NAME hermite_damped_rest + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_hermite_damped_rest.py + --exe $ + --output ${CMAKE_BINARY_DIR}/hermite_damped_rest) + set_tests_properties(hermite_damped_rest PROPERTIES + LABELS "python;hermite;dynamic;robustness" + TIMEOUT 300 + ENVIRONMENT "${_cabledyn_python_test_environment}") + # Range graphs (LINES flag r) and touchdown channels on every standalone route: envelopes # against the node channels, the analytic catenary TDP, quasi-static tracking under a # moving fairlead, and agreement with cabledyn.touchdown_history (needs numpy). diff --git a/doc/theory.rst b/doc/theory.rst index 61129c2..93ab04a 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -2336,8 +2336,10 @@ residual decreases; its convergence norm divides each translational residual by frozen at the predictor and each tangent-DOF residual additionally by the nodal tributary length. The force scale is the largest inertial or residual entry, bounded below by 1 N, by the peak element weight, and by the round-off floor -:math:`16\,\epsilon\,\max(EA/L_0)\,x_\text{ref}/\text{tol}` of the axial forces -(:math:`x_\text{ref}` the largest coordinate magnitude or element length). The +:math:`16\,\epsilon\,\max\big((EA + BA\,\gamma/(\beta\,\Delta t))/L_0\big)\,x_\text{ref}/\text{tol}` +of the axial forces (:math:`x_\text{ref}` the largest coordinate magnitude or element length; +the axial damping takes the strain rate from the Newmark velocity, whose rounding is +:math:`\gamma/(\beta\,\Delta t)` times that of the coordinates). The deck defaults are relative tolerance :math:`10^{-8}`, absolute tolerance :math:`10^{-14}`, 30 iterations, and 12 backtracks (option ``dynamic_solver``). Option ``modified_newton`` reuses the factorised tangent within a step while each iterate reduces the residual to at most a quarter diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index 2210e47..ff12e0c 100644 --- a/src/CableDyn_HermiteCableDynamic.f90 +++ b/src/CableDyn_HermiteCableDynamic.f90 @@ -4868,7 +4868,7 @@ SUBROUTINE eval_res(qcur, use_trial, rn, esx, emx, stash) REAL(wp), POINTER :: Gout(:) INTEGER :: ii, nd INTEGER(INT64) :: ec0, ec1 - REAL(wp) :: xref, xd, xc, ea_r, kt, tw_round + REAL(wp) :: xref, xd, xc, ea_r, ka, kt, tw_round REAL(wp), PARAMETER :: RESID_ROUNDOFF = 16.0_wp REAL(wp), PARAMETER :: STATE_DIVERGED = 1.0e12_wp ec0 = 0_INT64 ! defined even with profiling off (see the step-entry note) @@ -4989,18 +4989,24 @@ SUBROUTINE eval_res(qcur, use_trial, rn, esx, emx, stash) ! whose rounding is (K^2/GJ) eps |r|/l0, so the floor takes the whole axial stiffness ! EA - K^2/GJ + K^2/GJ of the line (without it the floor fell by 1 - K^2/(EA GJ): at ! K = 0.99 sqrt(EA GJ) to 2 % of the rounding it bounds, and a held line failed its step). + ! The axial damping force BA d(|r'|)/dt takes the strain rate from the Newmark velocity, + ! whose rounding is gamma/(beta dt) times that of the coordinates, so the floor adds + ! BA gamma/(beta dt) to the axial stiffness, as the twist floor below adds Bt: without it a + ! held line damped near its first axial mode (BA = 2 zeta EA/omega_1, which at small dt + ! puts the damping term well above EA) stagnated at a residual of a few times the + ! tolerance and failed its step. xref = MAXVAL(model%l0) DO ii = 1, ndof IF (MOD(ii - 1, 6) < 3) xref = MAX(xref, ABS(qcur(ii))) END DO - IF (model%torsion%active .AND. ALLOCATED(model%torsion%couple)) THEN - ea_r = CD_ZERO - DO ii = 1, SIZE(model%l0) - ea_r = MAX(ea_r, (ABS(model%EA(ii)) + model%torsion%couple(ii)**2*model%torsion%gj(ii))/model%l0(ii)) - END DO - ELSE - ea_r = MAXVAL(model%EA/model%l0) - END IF + ea_r = CD_ZERO + DO ii = 1, SIZE(model%l0) + ka = ABS(model%EA(ii)) + IF (model%torsion%active .AND. ALLOCATED(model%torsion%couple)) & + ka = ka + model%torsion%couple(ii)**2*model%torsion%gj(ii) + IF (model%has_axial_damping) ka = ka + ABS(model%BA(ii))*gm/(bt*dt) + ea_r = MAX(ea_r, ka/model%l0(ii)) + END DO fscale = MAX(fscale, RESID_ROUNDOFF*EPSILON(CD_ONE)*ea_r*xref/tol) ! Nodal twist: the torque scale of the angle rows, frozen with fscale and formed like it ! from the nodal loads (a node's torque imbalance, not the torque the line carries, diff --git a/tests/test_hermite_damped_rest.py b/tests/test_hermite_damped_rest.py new file mode 100644 index 0000000..3cd972c --- /dev/null +++ b/tests/test_hermite_damped_rest.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""A heavily damped finite-EI line held at rest stays at rest on every time step. + +A taut 400 m wire (EA = 2.19e9 N, 111 elements) is held at both ends and started from its +static equilibrium. The axial damping coefficient BA is swept over 5e7 to 8e8 N s (2 zeta +EA/omega_1 with zeta from about 0.4 to 6 for its first axial mode, the damping that +``mode1 axialDampingRatio`` gives) and dtM over 0.002, 0.005 and 0.01 s. The damping force +takes the strain rate from the Newmark velocity, whose rounding is gamma/(beta dt) times +that of the coordinates, so at these BA and dtM the residual round-off of a step at rest +lies well above the tolerance on the undamped force scale. Every run must complete (exit +code 0) and keep its end tensions at their static values to 1e-6. + +usage: test_hermite_damped_rest.py --exe --output

+""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +DECK = """--------------------- CableDyn Input File ------------------------------------ +Taut finite-EI line held at rest with a large axial damping coefficient +--------------------- LINE TYPES --------------------------------------- +TypeName Diam Mass EA BA EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (N-s) (N-m2) (-) (-) (-) (-) +wire 0.156 121.7 2.19e9 {ba} 963.0 1.2 0.008 1.0 0.0 +--------------------- POINTS ------------------------------------------- +ID Type X Y Z +(-) (-) (m) (m) (m) +1 Coupled 82.52 80.42 -8.60 +2 Fixed -117.94 395.14 -139.38 +--------------------- LINES -------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +1 1 2 - +--------------------- SECTIONS ----------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +1 wire 400.52 111 +--------------------- OPTIONS ------------------------------------------ +139.38 WtrDpth +2.9e6 kBot +{dt} dtM +{tmax} TMax +--------------------- OUTPUTS ------------------------------------------ +FairTen1 +AnchTen1 +--------------------- need this line ----------------------------------- +""" + +BA = ["5.0e7", "7.0e7", "1.0e8", "1.3e8", "1.6e8", "2.0e8", "3.0e8", "4.0e8", "6.0e8", "8.0e8"] +DT = ["0.002", "0.005", "0.01"] + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--exe", required=True) + ap.add_argument("--output", required=True) + a = ap.parse_args() + out = Path(a.output) + out.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + env.setdefault("OPENBLAS_NUM_THREADS", "1") + failures = [] + for ba in BA: + for dt in DT: + name = "held_%s_%s" % (ba, dt) + deck = out / (name + ".dat") + deck.write_text(DECK.format(ba=ba, dt=dt, tmax=repr(round(40 * float(dt), 6))), encoding="utf-8", + newline="\n") + root = out / name + p = subprocess.run([a.exe, str(deck), str(root)], capture_output=True, text=True, env=env, + cwd=str(out)) + if p.returncode != 0: + failures.append("BA=%s dtM=%s: exit %d: %s" % (ba, dt, p.returncode, (p.stdout + p.stderr)[-300:])) + continue + rows = Path(str(root) + ".out").read_text(encoding="utf-8").splitlines() + names = rows[1].split() + vals = [[float(v) for v in r.split()] for r in rows[2:] if r.strip()] + if len(vals) != 41: + failures.append("BA=%s dtM=%s: %d output rows, expected 41" % (ba, dt, len(vals))) + continue + for ch in ("FairTen1", "AnchTen1"): + k = names.index(ch) + t0 = vals[0][k] + drift = max(abs(v[k] - t0) for v in vals) / abs(t0) + if drift > 1.0e-6: + failures.append("BA=%s dtM=%s: %s drifts by %.3e of its static value" % (ba, dt, ch, drift)) + print("held damped line: %d runs, %d failures" % (len(BA) * len(DT), len(failures))) + for f in failures: + print("FAIL:", f) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 30a4d0a6548099e636e30b78040a6393a075c13c Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 04:33:05 +0900 Subject: [PATCH 17/40] feat(damping): mesh-independent axial damping ratio; static balance and as-laid fixes (#112) Signed-off-by: Jae Hoon Seo --- CMakeLists.txt | 16 ++ doc/driver_format.md | 3 +- doc/options.rst | 16 ++ doc/theory.rst | 52 ++++- examples/cabledyn_options_reference.dat | 2 + python/cabledyn/deck_file.py | 9 + python/tests/test_deck_file.py | 5 + python/tests/test_init_notes.py | 16 ++ src/CableDyn_Damping.f90 | 200 ++++++++++++++++++- src/CableDyn_DeckDriver.f90 | 239 +++++++++++++++++++++-- src/CableDyn_HermiteCableStatic.f90 | 184 ++++++++++++++++- tests/check_axial_damping_ratio.py | 227 +++++++++++++++++++++ tests/test_flagship_compression_note.f90 | 20 +- tests/test_static_current_balance.f90 | 230 ++++++++++++++++++++++ tests/test_static_friction_as_laid.py | 74 +++++++ 15 files changed, 1270 insertions(+), 23 deletions(-) create mode 100644 tests/check_axial_damping_ratio.py create mode 100644 tests/test_static_current_balance.f90 diff --git a/CMakeLists.txt b/CMakeLists.txt index 0317770..918a8f6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1290,6 +1290,13 @@ add_executable(test_end_tangent_channels tests/test_end_tangent_channels.f90) target_link_libraries(test_end_tangent_channels PRIVATE cabledyn_core) add_test(NAME end_tangent_channels COMMAND test_end_tangent_channels WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set_tests_properties(end_tangent_channels PROPERTIES LABELS "fortran;integration;static;outputs" TIMEOUT 600) +# The end reactions of a finite-EI cable at rest in a uniform current, pinned or clamped, +# suspended or touching down, balance its weight and its Morison drag integrated independently. +add_executable(test_static_current_balance tests/test_static_current_balance.f90) +target_link_libraries(test_static_current_balance PRIVATE cabledyn_core) +add_test(NAME static_current_balance COMMAND test_static_current_balance + WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +set_tests_properties(static_current_balance PROPERTIES LABELS "fortran;integration;static;hydro" TIMEOUT 600) add_executable(test_viv_kernels tests/test_viv_kernels.f90) target_link_libraries(test_viv_kernels PRIVATE cabledyn_core) @@ -2878,6 +2885,15 @@ if(Python3_Interpreter_FOUND) LABELS "python;driver;integration;static;outputs" TIMEOUT 600 ENVIRONMENT "${_cabledyn_python_test_environment}") + # A negative BA damps the first axial mode of a line at zeta pi/(2N) per segment (the + # default), and at zeta on every mesh under axialDampingRatio mode1, uniform and composite. + add_test(NAME driver_axial_damping_ratio + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/check_axial_damping_ratio.py + $) + set_tests_properties(driver_axial_damping_ratio PROPERTIES + LABELS "python;driver;integration;dynamic;validation" + TIMEOUT 600 + ENVIRONMENT "${_cabledyn_python_test_environment}") endif() # The scan of the round-off CI job: it must flag a fallback note, a failed run and a forced # BLAS kernel that did not take effect, or a clean scan would prove nothing. diff --git a/doc/driver_format.md b/doc/driver_format.md index 61f1511..cc093f0 100644 --- a/doc/driver_format.md +++ b/doc/driver_format.md @@ -237,7 +237,7 @@ Inside OUTPUTS, a row whose first token is `END` also closes the channel list. | `Diam` | hydrodynamic / volume-equivalent diameter [m] | used for submerged weight, drag, and added mass; at least 1e-6 and at most 1e3 where used | | `MassDenInAir` | dry mass per unstretched metre [kg/m] | submerged weight = (m − ρ_w·πd²/4)·g; its magnitude at most 1e6 where used | | `EA` | axial stiffness [N] | linear (T = EA·ε); at least 1e-3 and at most 1e15 where used; see the viscoelastic and Syrope forms below | -| `BA/-zeta` | axial damping: ≥ 0 is BA [N·s], < 0 is −ζ (damping ratio) | active in EI = 0 and finite-EI dynamics; ignored in a static-only run | +| `BA/-zeta` | axial damping: ≥ 0 is BA [N·s], < 0 is −ζ (damping ratio): by default (MoorDyn's convention) of each segment, BA = ζ L0 √(EA m) per element, which damps the line's first axial mode at about ζ π/(2N) on N elements and so depends on the mesh (a run notes the ratio); under `mode1 axialDampingRatio` of the line's first axial mode, on any mesh (see the OPTIONS table) | active in EI = 0 and finite-EI dynamics; ignored in a static-only run | | `EI` | bending stiffness [N·m²] | finite and ≥ 0; see [EI routing](#ei-routing) | | `Cd_n`, `Cd_t` | normal / tangential drag coefficients | used by EI = 0 dynamic current and Airy-wave runs | | `Ca_n`, `Ca_t` | normal / tangential added-mass coefficients | used by EI = 0 dynamic Froude–Krylov and added-mass runs | @@ -1275,6 +1275,7 @@ syntax form. The most frequently edited keywords are: | `cable_load_feedback` | coupled finite-EI cable reaction switch. `True` returns cable force and moment to the host. `False` marches the cable with the same moving boundary and host fluid field and keeps its output channels, but returns zero force and moment; use it for a one-way comparison, not as a physical model | `True` | | `adaptive_mesh` | `True`/`False`; allow finite-EI mesh refinement when the static resolution check is fragile | `False` | | `cable_statics` | `continuation` or `sequenced`: which finite-EI static route runs first (the other is its fallback); both results are audited | `continuation` | +| `axialDampingRatio` / `axial_damping_ratio` | `segment` or `mode1`: what a negative `BA` is the damping ratio ζ of. `segment` (MoorDyn's convention): of each segment, BA = ζ L0 √(EA m) per element, so the line's first axial mode is damped at about ζ π/(2N) on N elements and less on a finer mesh; a dynamic run notes that ratio. `mode1`: of the line's first axial mode (both ends held, dry mass, from its sections, so the same on any mesh): BA = 2 ζ EA / ω1 on every element, a damping proportional to the stiffness, so the axial mode n is damped at ζ ωn/ω1. A line with a negative `BA` and a viscoelastic or Syrope section is refused under `mode1` | `segment` | | `OutputRecovery` / `output_recovery` | `equilibrium` or `element`: definition of the finite-EI node tension, curvature, bend moment and segment tension. `equilibrium` recovers them from nodal equilibrium (the effective tension F·t, the moment the neighbouring elements carry, curvature = moment / EI); `element` reports the length-weighted element-mean axial force EA(\|r'\| − 1), the larger one-sided exact curvature, and the element-midpoint axial resultant as segment tension. End forces, positions and TDP channels are the same under both | `equilibrium` | | `alpha_force_blend` | `True`/`False`; generalised-α blend of the finite-EI and `EI = 0` dynamics: forces (`True`) or the configuration `q_αf` (`False`), which biases the mean tension of a rotating finite-EI line at large `dtM` and lets `EI = 0` segments that go slack report spurious tension spikes | `True` | | `tensile_safety` | `False`, `warn`, or `True`; audits the element-mean axial force (three-point Gauss mean of the signed axial resultant, averaged over an element and its two neighbours). `True` rejects. `warn` commits the state, counts accepted-step events, and reports the worst force, threshold, element and time (the printed `xi` is always 0.5, the element centre) | `False` | diff --git a/doc/options.rst b/doc/options.rst index 31b32a5..42fe703 100644 --- a/doc/options.rst +++ b/doc/options.rst @@ -506,6 +506,22 @@ Finite-EI cable controls same under both - both (finite-EI lines) - ``element OutputRecovery`` + * - ``axialDampingRatio`` / ``axial_damping_ratio`` + - mode + - ``segment`` + - what a negative ``LINE TYPES`` ``BA`` is the damping ratio :math:`\zeta` of. + ``segment`` (MoorDyn's convention): of each segment, + :math:`BA = \zeta L_0 \sqrt{EA\,m}` per element, which damps the line's first axial mode + at about :math:`\zeta\pi/(2N)` on :math:`N` elements, so the axial damping depends on + the mesh; a dynamic run notes the ratio for each such line. ``mode1``: of the line's + first axial mode, both ends held, from its sections' lengths, ``EA`` and dry mass, so + the same on any mesh: :math:`BA = 2\zeta EA/\omega_1` on every element, a damping + proportional to the stiffness, under which the axial mode :math:`n` is damped at + :math:`\zeta\,\omega_n/\omega_1` (see :doc:`theory`). A positive ``BA`` is used as given + under both. Under ``mode1`` a line with a negative ``BA`` and a viscoelastic + (``ElasticMod`` 2 or 3) or Syrope section is refused by name + - both (``EI = 0`` and finite-EI lines) + - ``mode1 axialDampingRatio`` * - ``axial_quadrature_order`` / ``axialquadratureorder`` / ``axial_quadrature`` - integer, – - ``4`` diff --git a/doc/theory.rst b/doc/theory.rst index 93ab04a..42fddbf 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -811,9 +811,31 @@ so the element-scale modes (:math:`n \approx N`) are damped at up to :math:`(\pi ``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}`. +oscillations) therefore change with the mesh. The initialisation of a dynamic run notes, for every +line with a negative ``BA``, the damping ratio this gives its first axial mode on its final mesh: +the Rayleigh quotient :math:`\zeta_1 = \mathbf u^\mathsf T\mathbf C\mathbf u / +(2\,\omega_1\,\mathbf u^\mathsf T\mathbf M\mathbf u)` of the first mode :math:`\mathbf u` of the +line's axial chain with both ends held (element stiffness :math:`EA/L_0`, damping +:math:`BA/L_0`, the dry mass lumped at the nodes), which is :math:`\zeta\sin(\pi/(2N))` on a +uniform line and holds for a composite one. + +**First-mode damping ratio** (``mode1 axialDampingRatio``, :doc:`options`). A negative ``BA`` is +then the damping ratio of the line's first axial mode, the same on any mesh: + +.. math:: + + BA = \frac{2\,\zeta\,EA}{\omega_1} + +on every element, with :math:`\omega_1` the first axial natural frequency of the line with both +ends held, from its sections' unstretched lengths, ``EA`` and dry mass per length (the first mode +of the axial chain on a fixed internal mesh of 2048 elements, so independent of the line's mesh; +:math:`\omega_1 = (\pi/L)\sqrt{EA/\rho_A}` on a uniform line, to 1e-7). The damping is then +proportional to the stiffness, :math:`\mathbf C = (2\zeta/\omega_1)\,\mathbf K`, so the first +axial mode of a composite line is damped at :math:`\zeta` too and the mode :math:`n` at +:math:`\zeta\,\omega_n/\omega_1`. Added mass and the tension are left out of :math:`\omega_1`. A +viscoelastic or Syrope section has no single ``EA`` that sets the first mode, so a line with one +and a negative ``BA`` is refused under this option; ``BA`` in N·s is used as given under both +conventions. Viscoelastic rope (MoorDyn ``ElasticMod`` 2 and 3) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -988,7 +1010,17 @@ With the relative velocity :math:`\mathbf{u}_r = \mathbf{u}_f - \mathbf{v}` spli + \tfrac{1}{2}\rho\,\pi d\,C_{d,t}\,\lvert\mathbf{u}_{r,t}\rvert\,\mathbf{u}_{r,t}. Its derivatives with respect to position (through :math:`\mathbf{t}`) and velocity enter the -implicit tangent exactly. +implicit tangent exactly. The length is the element's unstretched length :math:`L_0` on the +``EI = 0`` path (with the chord tangent and the wet-weighted relative velocity) and +the deformed length :math:`\lvert\mathbf r_{,\xi}\rvert\,\mathrm{d}\xi` of the cubic centreline +on the Hermite path (three Gauss points per element). At rest in a current the end reactions +(``FairTen``/``AnchTen``, ``Point

F``) balance the weight and this drag to the static +tolerance, about :math:`10^{-8}` of the end force, pinned or clamped ends alike. A check of the +balance must integrate the drag the same way: on straight chords between the nodes of a +finite-EI line it misses by a share that falls with the square of the element length +(:math:`3\times10^{-5}` of the end force on 1.25 m elements of a cable in a 1 m/s current, +:math:`4.5\times10^{-4}` on 5 m elements), and per unstretched length by about the axial strain +times the drag. Added mass and fluid inertia ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1093,6 +1125,8 @@ phases, the running mean squares and the averaging time are part of the checkpoi restart continues the uninterrupted run bit for bit. The OpenFAST correction iterations rewind them with the rest of the state. +.. _theory-dry-buoyancy: + Buoyancy and wetting ~~~~~~~~~~~~~~~~~~~~ @@ -1420,7 +1454,15 @@ laid node sinks into the penalty seabed along its normal by its static penetrati slope moves it horizontally by :math:`w_i\,(z_{,x}, z_{,y})/k_i`, with :math:`w_i` its nodal load: its share of the line's submerged weight, plus the net weight of a discrete attachment lumped at it (the finite-EI solve first smears an attachment over the two elements at its -node, then rebuilds the reference for the discrete loads before it polishes to them). Where +node, then rebuilds the reference for the discrete loads before it polishes to them). It is the +normal load the solver applies to the node resting at its laid position: a finite-EI section +that lies within its radius of the still-water level there (a seabed shallower than the cable +radius) carries the buoyancy of its dry part, as in the static solve and the dynamics +(:ref:`buoyancy recovery `), at the height the node sinks to under that +load, :math:`z_f - w_i s^2/k_i`, and an attachment above the level its dry weight. The seabed +lies below the still-water level everywhere (a positive ``WtrDpth`` and positive bathymetry +depths), so a grounded node with its attachment stays submerged; an ``EI = 0`` line, whose +submergence follows its node heights, is wholly submerged where it is grounded. Where the slope varies, the laid tension turns at every node and adds :math:`-T_\text{laid}\,(\mathbf{t}_{i}^{+} - \mathbf{t}_{i}^{-})\cdot\mathbf{n}_i` to the normal load (it presses the line into a crest and lifts it from a hollow), with diff --git a/examples/cabledyn_options_reference.dat b/examples/cabledyn_options_reference.dat index b9fc312..a9f693e 100644 --- a/examples/cabledyn_options_reference.dat +++ b/examples/cabledyn_options_reference.dat @@ -82,6 +82,7 @@ none waves - Wave model and parameters (m, s, deg) {none; airy; -- 0.5 frictionMuAxial - Seabed friction along the line axis {>=0} [default: frictionMu] -- 0.5 frictionMuLateral - Seabed friction across the line axis {>=0} [default: frictionMu] -- asLaid staticsFriction - Statics seabed friction policy {none; asLaid} [default: none] +-- mode1 axialDampingRatio - A negative BA is the damping ratio of {segment=each segment; mode1=the first axial mode} [default: segment] -- 0.0 layAzimuth - Lay azimuth of every line from +x toward +y (deg) [default: anchor -> fairlead] -- 225.0 layAzimuth2 - Lay azimuth of line 2 alone (deg) [default: layAzimuth] -- 0.0 asLaidTension - As-laid effective tension of every line (N) [default: 0] @@ -108,6 +109,7 @@ none waves - Wave model and parameters (m, s, deg) {none; airy; -- layAzimuth = lay_azimuth; asLaidTension = as_laid_tension -- dtM = dt; rhoInf = rho_inf; modified_newton = modifiedNewton; adaptive_mesh = adaptiveMesh -- cable_statics = cableStatics; alpha_force_blend = alphaForceBlend; OutputRecovery = output_recovery +-- axialDampingRatio = axial_damping_ratio -- maxStrain = max_strain; WaveSeed = wave_seed; rampTime = ramp_time = tRamp -- RangeStart = range_start -- cable_load_feedback = cableLoadFeedback; tensile_safety = tensileSafety diff --git a/python/cabledyn/deck_file.py b/python/cabledyn/deck_file.py index c9e957f..221ffa6 100644 --- a/python/cabledyn/deck_file.py +++ b/python/cabledyn/deck_file.py @@ -268,6 +268,8 @@ "as_laid_tension": "as_laid_tension", "outputrecovery": "output_recovery", "output_recovery": "output_recovery", + "axialdampingratio": "axial_damping_ratio", + "axial_damping_ratio": "axial_damping_ratio", } # Per-line static friction rows (native apply_option): ``layAzimuth`` and ``asLaidTension`` # with the LINE id appended; the id is a plain decimal suffix, and leading zeros name the same line. @@ -1342,6 +1344,13 @@ def _scalar_option_value_error(keyword: str, value: str) -> str | None: if value.lower() in {"continuation", "sequenced"} else "must be continuation or sequenced" ) + if key == "axial_damping_ratio": + # native: the value is dequoted and compared case-insensitively + return ( + None + if _strip_quotes(value).lower() in {"segment", "mode1"} + else "must be segment or mode1" + ) if key == "output_recovery": return ( None diff --git a/python/tests/test_deck_file.py b/python/tests/test_deck_file.py index a56793f..bd6e119 100644 --- a/python/tests/test_deck_file.py +++ b/python/tests/test_deck_file.py @@ -675,6 +675,8 @@ def test_unknown_scalar_option_keyword_fails_closed(tmp_path): ("random", "VIVphaseSeed", "spread or zero"), ("0", "viv_phase_seed", "spread or zero"), ("nodal", "OutputRecovery", "equilibrium or element"), + ("mode2", "axialDampingRatio", "segment or mode1"), + ("element", "axial_damping_ratio", "segment or mode1"), ("on", "ei0_substeps", "auto or off"), ("0", "ei0_substep_angle", r"\(0, 90\] deg"), ("91", "ei0SubstepAngle", r"\(0, 90\] deg"), @@ -762,6 +764,9 @@ def test_zero_e_exponent_waterkin_mode_is_accepted(tmp_path): "ZERO viv_phase_seed", "equilibrium OutputRecovery", "element output_recovery", + "segment axialDampingRatio", + "mode1 axial_damping_ratio", + "MODE1 AXIALDAMPINGRATIO", "auto ei0_substeps", "off ei0Substeps", "0.5 ei0_substep_angle", diff --git a/python/tests/test_init_notes.py b/python/tests/test_init_notes.py index 645e347..426e630 100644 --- a/python/tests/test_init_notes.py +++ b/python/tests/test_init_notes.py @@ -4,6 +4,7 @@ from __future__ import annotations +import math import os import re import subprocess @@ -119,6 +120,21 @@ def test_the_notes_of_a_deck_on_both_routes(tmp_path): assert "LINE 1: dtM = 0.1000 s resolves its axial period" in notes +@pytest.mark.parametrize("ei", [0.0, 1.0e6], ids=["point", "aggregate"]) +def test_the_axial_damping_note_reaches_an_in_process_host(tmp_path, ei): + # BA = -1 is a damping ratio per segment: the note gives the first-mode ratio of the mesh + with CableDyn(_deck(tmp_path, ei=ei)) as model: + notes = [note for note in model.init_notes if "damping ratio per segment" in note] + assert len(notes) == 1 + match = re.search(r"at ([0-9.]+) % of critical on its ([0-9]+) elements", notes[0]) + assert match is not None, notes[0] + # zeta pi/(2N) on the uniform chain, N its final mesh + expected = 100.0 * math.pi / (2 * int(match.group(2))) + assert float(match.group(1)) == pytest.approx(expected, rel=1e-3) + with CableDyn(_deck(tmp_path, "mode1 axialDampingRatio", ei=ei, name="mode1.dat")) as model: + assert not [note for note in model.init_notes if "damping ratio per segment" in note] + + def test_a_note_of_the_parse_is_kept_once(tmp_path): # the deck is read twice, to find its route and to build it (tmp_path / "current_profile_dynamic.txt").write_text(CONSTANT_GRID, encoding="utf-8") diff --git a/src/CableDyn_Damping.f90 b/src/CableDyn_Damping.f90 index 03e4bdf..0470824 100644 --- a/src/CableDyn_Damping.f90 +++ b/src/CableDyn_Damping.f90 @@ -21,7 +21,13 @@ MODULE CableDyn_Damping !! -- a non-negative value IS the coefficient (N.s); a NEGATIVE !! value is the desired damping ratio -zeta, giving BA = zeta * L_seg * sqrt(EA * w) !! (w = mass per unstretched metre), so each segment's axial mode carries that - !! fraction of critical damping. + !! fraction of critical damping. That ratio belongs to the segment, so the line's own axial + !! modes are damped less on a finer mesh (zeta_1 = zeta pi/(2N) on a uniform line of N + !! elements, CD_Axial_Mode1_Damping). The mesh-independent alternative + !! (CD_Resolve_Mode1_BA) makes -zeta the damping ratio of the line's first axial mode: + !! BA = 2 zeta EA / omega_1, a damping proportional to the stiffness, with omega_1 the + !! line's first axial frequency (CD_Axial_Mode1_Omega), so the first mode of a composite + !! line is damped at zeta too. !! !! Load convention (matches CD_Cable_Dynamic_Load_Proc / CableDyn_Hydro): the !! returned `force` is the external load to SUBTRACT from the residual, and @@ -33,6 +39,9 @@ MODULE CableDyn_Damping PRIVATE PUBLIC :: CD_Resolve_Legacy_BA + PUBLIC :: CD_Resolve_Mode1_BA + PUBLIC :: CD_Axial_Mode1_Omega + PUBLIC :: CD_Axial_Mode1_Damping PUBLIC :: CD_Cable_Axial_Damping_Force PUBLIC :: CD_Cable_Axial_Damping_Load PUBLIC :: CD_Axial_Damping_Element_Force @@ -78,6 +87,195 @@ SUBROUTINE CD_Resolve_Legacy_BA(ba_input, segment_length, ea, mass_per_length, b ba = (-ba_input)*segment_length*SQRT(ea*mass_per_length) END SUBROUTINE CD_Resolve_Legacy_BA + SUBROUTINE CD_Resolve_Mode1_BA(ba_input, ea, omega1, ba, ErrStat, ErrMsg) + !! Resolve a BA/-zeta line-type value with -zeta the damping ratio of the line's first axial + !! mode: BA = 2 zeta EA / omega_1 [N.s] on every element, the same for any mesh. The damping + !! is then proportional to the stiffness, so the axial mode n of a uniform line has the + !! ratio zeta omega_n / omega_1. ba_input >= 0 is the coefficient directly. + REAL(wp), INTENT(IN) :: ba_input !! BA value (negative => -zeta ratio of the first axial mode) + REAL(wp), INTENT(IN) :: ea !! axial stiffness EA of the element [N] (> 0 if -zeta) + REAL(wp), INTENT(IN) :: omega1 !! first axial frequency of the line [rad/s] (> 0 if -zeta) + REAL(wp), INTENT(OUT) :: ba !! resolved damping coefficient [N.s] + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + + ba = CD_ZERO + ErrStat = CD_DAMP_OK + ErrMsg = '' + IF (.NOT. CD_Is_Finite(ba_input)) THEN + CALL fail(ErrStat, ErrMsg, 'BA must be finite'); RETURN + END IF + IF (ba_input >= CD_ZERO) THEN + ba = ba_input + RETURN + END IF + IF (.NOT. (CD_Is_Finite(ea) .AND. ea > CD_ZERO) .OR. .NOT. (CD_Is_Finite(omega1) .AND. omega1 > CD_ZERO)) THEN + CALL fail(ErrStat, ErrMsg, 'resolving a -zeta first-mode damping ratio needs a positive EA and '// & + 'first axial frequency') + RETURN + END IF + ba = 2.0_wp*(-ba_input)*ea/omega1 + END SUBROUTINE CD_Resolve_Mode1_BA + + SUBROUTINE CD_Axial_Mode1_Omega(length, ea, mass_per_length, omega1, ErrStat, ErrMsg) + !! First axial natural frequency [rad/s] of a line of sections (unstretched length, EA and + !! dry mass per length each) fixed at both ends: the first mode of the axial chain of a + !! fixed mesh of the sections (CD_Axial_Mode1_Damping on MODE1_ELEMENTS elements shared by + !! the sections in proportion to their axial-wave transit times, at least + !! MODE1_SECTION_ELEMENTS each), so it does not depend on the line's own mesh. It is + !! (pi/L) sqrt(EA/m) on a uniform line, low by (pi/(2 MODE1_ELEMENTS))^2/6 = 1e-7. Added + !! mass is left out. + REAL(wp), INTENT(IN) :: length(:) !! section unstretched lengths [m] (> 0) + REAL(wp), INTENT(IN) :: ea(:) !! section EA [N] (> 0) + REAL(wp), INTENT(IN) :: mass_per_length(:) !! section dry mass per length [kg/m] (> 0) + REAL(wp), INTENT(OUT) :: omega1 + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + INTEGER, PARAMETER :: MODE1_ELEMENTS = 2048, MODE1_SECTION_ELEMENTS = 16 + REAL(wp), ALLOCATABLE :: l0(:), ea_e(:), m_e(:), ba_e(:) + REAL(wp) :: transit, zeta1 + INTEGER :: i, j, e, ns + INTEGER, ALLOCATABLE :: n_sec(:) + + omega1 = CD_ZERO + ErrStat = CD_DAMP_OK + ErrMsg = '' + ns = SIZE(length) + IF (ns < 1 .OR. SIZE(ea) /= ns .OR. SIZE(mass_per_length) /= ns) THEN + CALL fail(ErrStat, ErrMsg, 'the first axial frequency needs one length, EA and mass per section'); RETURN + END IF + DO i = 1, ns + IF (.NOT. (CD_Is_Finite(length(i)) .AND. length(i) > CD_ZERO .AND. CD_Is_Finite(ea(i)) .AND. & + ea(i) > CD_ZERO .AND. CD_Is_Finite(mass_per_length(i)) .AND. mass_per_length(i) > CD_ZERO)) THEN + CALL fail(ErrStat, ErrMsg, 'the first axial frequency needs a positive length, EA and mass per length'); RETURN + END IF + END DO + transit = SUM(length*SQRT(mass_per_length/ea)) + IF (.NOT. (CD_Is_Finite(transit) .AND. transit > CD_ZERO)) THEN + CALL fail(ErrStat, ErrMsg, 'the first axial frequency is not finite'); RETURN + END IF + ALLOCATE (n_sec(ns)) + DO i = 1, ns + n_sec(i) = MAX(MODE1_SECTION_ELEMENTS, NINT(REAL(MODE1_ELEMENTS, wp)*length(i)*SQRT(mass_per_length(i)/ea(i))/ & + transit)) + END DO + ALLOCATE (l0(SUM(n_sec)), ea_e(SUM(n_sec)), m_e(SUM(n_sec)), ba_e(SUM(n_sec))) + e = 0 + DO i = 1, ns + DO j = 1, n_sec(i) + e = e + 1 + l0(e) = length(i)/REAL(n_sec(i), wp) + ea_e(e) = ea(i) + m_e(e) = mass_per_length(i) + END DO + END DO + ba_e = CD_ZERO + CALL CD_Axial_Mode1_Damping(l0, ea_e, m_e, ba_e, zeta1, omega1, ErrStat, ErrMsg) + IF (ErrStat /= CD_DAMP_OK) RETURN + IF (.NOT. (CD_Is_Finite(omega1) .AND. omega1 > CD_ZERO)) THEN + omega1 = CD_ZERO + CALL fail(ErrStat, ErrMsg, 'the first axial frequency is not finite'); RETURN + END IF + END SUBROUTINE CD_Axial_Mode1_Omega + + SUBROUTINE CD_Axial_Mode1_Damping(l0, ea, mass_per_length, ba, zeta1, omega1, ErrStat, ErrMsg) + !! Damping ratio zeta_1 and frequency omega_1 [rad/s] of the first axial mode of a line of + !! elements (unstretched length, EA, dry mass per length and resolved BA each) fixed at both + !! ends: the axial chain of the mesh, element stiffness EA/L0 and damping BA/L0, the mass + !! lumped at the nodes. The undamped first mode u (inverse iteration) gives + !! omega_1^2 = u'Ku / u'Mu and zeta_1 = u'Cu / (2 omega_1 u'Mu). On a uniform line with + !! BA = zeta L0 sqrt(EA m), zeta_1 = zeta sin(pi/(2N)) ~ zeta pi/(2N). A one-element line, + !! which has no free node, takes the continuous bar: omega_1 = (pi/L0) sqrt(EA/m). + REAL(wp), INTENT(IN) :: l0(:), ea(:), mass_per_length(:), ba(:) + REAL(wp), INTENT(OUT) :: zeta1, omega1 + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + REAL(wp), PARAMETER :: PI = 3.14159265358979323846_wp + INTEGER, PARAMETER :: MAX_ITER = 2000 + REAL(wp), PARAMETER :: RAYLEIGH_TOL = 1.0e-14_wp + REAL(wp), ALLOCATABLE :: k(:), c(:), m(:), u(:), rhs(:), cp(:), dp(:) + REAL(wp) :: ukk, umm, ucc, rq, rq_old, denom + INTEGER :: n, i, it + + zeta1 = CD_ZERO + omega1 = CD_ZERO + ErrStat = CD_DAMP_OK + ErrMsg = '' + n = SIZE(l0) + IF (n < 1 .OR. SIZE(ea) /= n .OR. SIZE(mass_per_length) /= n .OR. SIZE(ba) /= n) THEN + CALL fail(ErrStat, ErrMsg, 'the first axial mode needs one length, EA, mass and BA per element'); RETURN + END IF + DO i = 1, n + IF (.NOT. (CD_Is_Finite(l0(i)) .AND. l0(i) > CD_ZERO .AND. CD_Is_Finite(ea(i)) .AND. ea(i) > CD_ZERO .AND. & + CD_Is_Finite(mass_per_length(i)) .AND. mass_per_length(i) > CD_ZERO .AND. & + CD_Is_Finite(ba(i)) .AND. ba(i) >= CD_ZERO)) THEN + CALL fail(ErrStat, ErrMsg, 'the first axial mode needs a positive length, EA and mass and a '// & + 'non-negative BA on every element'); RETURN + END IF + END DO + IF (n == 1) THEN + omega1 = (PI/l0(1))*SQRT(ea(1)/mass_per_length(1)) + zeta1 = ba(1)*omega1/(2.0_wp*ea(1)) + RETURN + END IF + ! element stiffness and damping, node masses of the n - 1 free nodes (node i joins + ! elements i and i + 1) + ALLOCATE (k(n), c(n), m(n - 1), u(n - 1), rhs(n - 1), cp(n - 1), dp(n - 1)) + k = ea/l0 + c = ba/l0 + DO i = 1, n - 1 + m(i) = 0.5_wp*(mass_per_length(i)*l0(i) + mass_per_length(i + 1)*l0(i + 1)) + END DO + u = CD_ONE + rq_old = -CD_ONE + DO it = 1, MAX_ITER + ! solve K y = M u (K tridiagonal, symmetric positive definite: Thomas without pivoting) + rhs = m*u + cp(1) = -k(2)/(k(1) + k(2)) + dp(1) = rhs(1)/(k(1) + k(2)) + DO i = 2, n - 1 + denom = (k(i) + k(i + 1)) + k(i)*cp(i - 1) + IF (i < n - 1) cp(i) = -k(i + 1)/denom + dp(i) = (rhs(i) + k(i)*dp(i - 1))/denom + END DO + u(n - 1) = dp(n - 1) + DO i = n - 2, 1, -1 + u(i) = dp(i) - cp(i)*u(i + 1) + END DO + u = u/MAXVAL(ABS(u)) + CALL forms(u, ukk, umm, ucc) + rq = ukk/umm + IF (ABS(rq - rq_old) <= RAYLEIGH_TOL*rq) EXIT + rq_old = rq + END DO + omega1 = SQRT(rq) + zeta1 = ucc/(2.0_wp*omega1*umm) + IF (.NOT. (CD_Is_Finite(omega1) .AND. CD_Is_Finite(zeta1))) THEN + zeta1 = CD_ZERO + omega1 = CD_ZERO + CALL fail(ErrStat, ErrMsg, 'the first axial mode is not finite'); RETURN + END IF + + CONTAINS + + SUBROUTINE forms(x, xkx, xmx, xcx) + !! The quadratic forms of K, M and C at the free-node displacements x (both ends held). + REAL(wp), INTENT(IN) :: x(:) + REAL(wp), INTENT(OUT) :: xkx, xmx, xcx + REAL(wp) :: dx + INTEGER :: e + ! the end elements stretch by the displacement of their free node alone + xkx = k(1)*x(1)**2 + k(n)*x(n - 1)**2 + xcx = c(1)*x(1)**2 + c(n)*x(n - 1)**2 + DO e = 2, n - 1 + dx = x(e) - x(e - 1) + xkx = xkx + k(e)*dx*dx + xcx = xcx + c(e)*dx*dx + END DO + xmx = SUM(m*x*x) + END SUBROUTINE forms + END SUBROUTINE CD_Axial_Mode1_Damping + SUBROUTINE CD_Cable_Element_Damping_Tension(q, v, elem_conn, l0, ba, td, ErrStat, ErrMsg, l0_dot) !! Signed per-element axial damping tension Td = (BA/L0)(t . (v_b - v_a)). !! Positive when the element is stretching, negative when shortening; zero at rest. diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index 6aa49a4..76d4079 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -47,7 +47,8 @@ MODULE CableDyn_DeckDriver CD_Nodal_Seabed_Stiffness, CD_LINE_OK USE CableDyn_Catenary, ONLY: CD_Catenary_Seed, CD_CAT_OK, CD_CAT_NOSEED USE CableDyn_Linalg, ONLY: CD_Solve_Dense_As_Banded, CD_LINALG_OK - USE CableDyn_Damping, ONLY: CD_Resolve_Legacy_BA, CD_DAMP_OK + USE CableDyn_Damping, ONLY: CD_Resolve_Legacy_BA, CD_Resolve_Mode1_BA, CD_Axial_Mode1_Omega, & + CD_Axial_Mode1_Damping, CD_DAMP_OK USE CableDyn_Syrope, ONLY: CD_SYROPE_WC_LINEAR, CD_SYROPE_WC_QUADRATIC, CD_SYROPE_WC_EXP, & CD_SyropeType, CD_Syrope_Init, CD_Syrope_End, & CD_Syrope_Static_Tension, CD_Syrope_Slow_For_Tension, CD_SYROPE_OK, & @@ -788,6 +789,10 @@ MODULE CableDyn_DeckDriver ! effective tension, curvature and bend moment from nodal equilibrium; 'element' reports the ! element-mean axial force EA (|r'| - 1) and the exact one-sided curvature traces. LOGICAL :: output_element_recovery = .FALSE. + ! A negative LINE TYPES BA (axialDampingRatio): 'segment' (default, MoorDyn's convention) + ! is the damping ratio of each segment, BA = zeta L0 sqrt(EA m) per element; 'mode1' is the + ! damping ratio of the line's first axial mode, BA = 2 zeta EA / omega_1 (CD_Resolve_Mode1_BA). + LOGICAL :: axial_damping_mode1 = .FALSE. INTEGER :: tensile_mode = CD_HCDYN_TENSILE_OFF REAL(wp) :: tensile_strain_tolerance = 2.0e-6_wp ! HOST-DRIVEN AMBIENT FLUID (not a deck keyword): set by CD_Init_Deck_Aggregate when @@ -1182,6 +1187,10 @@ MODULE CableDyn_DeckDriver ! seabed_drift: a current moves the line along a seabed without friction (seabed_drift_note); ! friction_refused: the line's route rejects frictionMu (FOLD_FRICTION_NONE). LOGICAL :: seabed_drift = .FALSE., friction_refused = .FALSE. + ! axial_zeta1: the damping ratio of the first axial mode of the built line, with both ends + ! held, on its axial_elements elements (summary_axial_damping); -1 when not worked out. + REAL(wp) :: axial_zeta1 = -1.0_wp + INTEGER :: axial_elements = 0 END TYPE CD_LineInitSummary TYPE :: CD_DeckAggregateType @@ -1622,6 +1631,9 @@ SUBROUTINE capture_system_notes(lines, points, sections, types, opts, system, dt CALL axial_period_note(summaries(i)%line_id, dt, sections, types, note) CALL capture_note(note) END IF + ! the caller marches the system, with the deck's step or its own + CALL axial_damping_note(summaries(i), sections, types, opts, note) + CALL capture_note(note) END DO IF (ANY(summaries%seabed_drift)) THEN CALL seabed_drift_note(PACK(summaries%line_id, summaries%seabed_drift), & @@ -1847,6 +1859,7 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, REAL(wp), ALLOCATABLE :: curv(:), cdn(:), cdt(:), can(:), cat(:), catenary_pos(:) REAL(wp), ALLOCATABLE :: viv_cl(:), viv_cf(:), viv_df(:) REAL(wp), ALLOCATABLE :: ba_raw(:), ba(:), dry_b(:) + REAL(wp) :: mode1_omega REAL(wp), ALLOCATABLE :: contact_kn(:), contact_cn(:) REAL(wp) :: anchor(3), fairlead(3), res, tvec(3), tnrm, contact_z, seed_z, seed_floor_a, seed_floor_b REAL(wp) :: dxh, dyh, hspan, frame_cs(2), horizontal_tension, grounded_length @@ -3253,11 +3266,24 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, CALL MOVE_ALLOC(w_am, w) CALL MOVE_ALLOC(fixed_am, fixed_dofs) ALLOCATE (ba(SIZE(l0))) + ! OPTION axialDampingRatio mode1: a negative BA is the damping ratio of the line's first + ! axial mode, set by the deck sections whatever the mesh + mode1_omega = CD_ZERO + IF (opts%axial_damping_mode1 .AND. ANY(ba_raw < CD_ZERO)) THEN + CALL line_mode1_omega(ln%id, sections, types, mode1_omega, es, em) + IF (es /= CD_DECKDRV_OK) THEN + CALL fail(ErrStat, ErrMsg, TRIM(em)); RETURN + END IF + END IF DO i = 1, SIZE(l0) ! Resolve negative damping-ratio inputs only after adaptive remeshing because ! BA = zeta*l0*sqrt(EA*mass_per_length) is defined for the final element length. ! A direct positive BA remains a mesh-independent constitutive coefficient. - CALL CD_Resolve_Legacy_BA(ba_raw(i), l0(i), ea(i), rho_a(i), ba(i), es, em) + IF (opts%axial_damping_mode1) THEN + CALL CD_Resolve_Mode1_BA(ba_raw(i), ea(i), mode1_omega, ba(i), es, em) + ELSE + CALL CD_Resolve_Legacy_BA(ba_raw(i), l0(i), ea(i), rho_a(i), ba(i), es, em) + END IF IF (es /= CD_DAMP_OK) THEN CALL fail(ErrStat, ErrMsg, 'finite-EI BA resolution failed: '//TRIM(em)); RETURN END IF @@ -3289,16 +3315,20 @@ RECURSIVE SUBROUTINE build_one_hermite_cable(ln, sections, types, points, opts, ! friction deflection along a slope, beyond the slope load its spring carries at rest. IF (as_laid .AND. PRESENT(bathymetry)) THEN BLOCK - REAL(wp), ALLOCATABLE :: node_load(:) - ALLOCATE (node_load(SIZE(l0) + 1)) + REAL(wp), ALLOCATABLE :: node_load(:), node_dry(:) + ALLOCATE (node_load(SIZE(l0) + 1), node_dry(SIZE(l0) + 1)) node_load = CD_ZERO + node_dry = CD_ZERO DO i = 1, SIZE(l0) node_load(i:i + 1) = node_load(i:i + 1) + 0.5_wp*w_bare(i)*l0(i) END DO + ! the net weight of each attachment, and the buoyancy it has not above the surface + ! (attachment_static_weight) DO i = 1, SIZE(att_node) node_load(att_node(i)) = node_load(att_node(i)) + (ln%att(2, i) - opts%rhoW*ln%att(3, i))*opts%g + node_dry(att_node(i)) = node_dry(att_node(i)) + opts%rhoW*ln%att(3, i)*opts%g END DO - CALL as_laid_reference(es, em, node_load) + CALL as_laid_reference(es, em, node_load, node_dry) END BLOCK IF (es /= CD_DECKDRV_OK) THEN CALL fail_solve(ErrStat, ErrMsg, 'finite-EI as-laid reference for the seabed friction with discrete '// & @@ -5178,7 +5208,7 @@ LOGICAL FUNCTION as_laid_cross_slope() RESULT(cross) END DO END FUNCTION as_laid_cross_slope - SUBROUTINE as_laid_reference(esr, emr, node_load) + SUBROUTINE as_laid_reference(esr, emr, node_load, node_dry_load) !! Friction reference of the static solve (CD_HermiteCable_As_Laid_Offsets): the line laid !! on the seabed from its lower end (the anchor, node 1, unless the fairlead is lower, as in !! the EI = 0 line model) along the lay azimuth at the as-laid tension, as (x, y) offsets @@ -5192,9 +5222,13 @@ SUBROUTINE as_laid_reference(esr, emr, node_load) !! dynamic hand-off does on the final mesh. node_load [N, downward positive, one entry per !! node]: the discrete loads of an attachment polish on the final mesh l0, which hold on !! that mesh alone; the reference is then this profile (default: the lumped weights w). + !! node_dry_load (with node_load): the attachments' buoyancy at each node, which a node + !! laid above the still-water level does not have. On a bathymetry a node sinks under the + !! normal load the solver applies at its laid position, the dry part of a section laid + !! within its radius of the surface included (the friction's laid_* dry-part data). INTEGER, INTENT(OUT) :: esr CHARACTER(*), INTENT(OUT) :: emr - REAL(wp), INTENT(IN), OPTIONAL :: node_load(:) + REAL(wp), INTENT(IN), OPTIONAL :: node_load(:), node_dry_load(:) REAL(wp), ALLOCATABLE :: wn(:), kn_r(:), offsets(:, :) REAL(wp) :: laid_t, az INTEGER :: k, ne_r, es_r @@ -5220,6 +5254,15 @@ SUBROUTINE as_laid_reference(esr, emr, node_load) cur_obj%friction%laid_azimuth = az cur_obj%friction%laid_tension = laid_t cur_obj%friction%laid_frame_cs = frame_cs + ! the dry part of the line at the still-water level z = 0, the diameter by reference arc + cur_obj%friction%laid_water_weight = opts%rhoW*opts%g + cur_obj%friction%laid_waterline_z = CD_ZERO + ALLOCATE (cur_obj%friction%laid_arc_end(ne_r), cur_obj%friction%laid_diam(ne_r)) + cur_obj%friction%laid_arc_end(1) = l0(1) + DO k = 2, ne_r + cur_obj%friction%laid_arc_end(k) = cur_obj%friction%laid_arc_end(k - 1) + l0(k) + END DO + cur_obj%friction%laid_diam = diam(1:ne_r) ! the nodal loads and seabed stiffnesses the nodes of mesh l0 sink under ALLOCATE (wn(ne_r + 1), kn_r(ne_r + 1), offsets(2, ne_r + 1)) wn = CD_ZERO @@ -5234,7 +5277,10 @@ SUBROUTINE as_laid_reference(esr, emr, node_load) wn(k:k + 1) = wn(k:k + 1) + 0.5_wp*w(k)*l0(k) END DO END IF - IF (PRESENT(bathymetry)) THEN + IF (PRESENT(bathymetry) .AND. PRESENT(node_dry_load)) THEN + CALL CD_HermiteCable_As_Laid_Offsets(cur_obj%friction, l0, ea, wn, kn_r, offsets, es_r, emr, bathymetry, & + node_dry_load=node_dry_load) + ELSE IF (PRESENT(bathymetry)) THEN CALL CD_HermiteCable_As_Laid_Offsets(cur_obj%friction, l0, ea, wn, kn_r, offsets, es_r, emr, bathymetry) ELSE CALL CD_HermiteCable_As_Laid_Offsets(cur_obj%friction, l0, ea, wn, kn_r, offsets, es_r, emr) @@ -8700,6 +8746,8 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi IF (static_only) CYCLE CALL axial_period_note(agg%init_lines(il)%line_id, dt, sections, types, strain_note) IF (LEN_TRIM(strain_note) > 0) CALL append_note_line(agg%init_lines(il)%note, strain_note) + CALL axial_damping_note(agg%init_lines(il), sections, types, opts, strain_note) + IF (LEN_TRIM(strain_note) > 0) CALL append_note_line(agg%init_lines(il)%note, strain_note) END DO IF (own_current) CALL warn_coarse_sections(lines, sections) END BLOCK @@ -10148,9 +10196,9 @@ RECURSIVE SUBROUTINE init_one_model(ln, sections, types, points, opts, model, Er REAL(wp), ALLOCATABLE :: ve_ead(:), ve_bas(:), ve_bad(:), ve_ambl(:), ve_vbet(:) REAL(wp), ALLOCATABLE :: viv_cl(:), viv_cf(:), viv_df(:) LOGICAL, ALLOCATABLE :: syr_el(:) - REAL(wp) :: anchor(3), fairlead(3) + REAL(wp) :: anchor(3), fairlead(3), mode1_omega REAL(wp), PARAMETER :: factors(4) = [0.25_wp, 0.5_wp, 0.75_wp, CD_ONE] - INTEGER :: j, i, e, es + INTEGER :: j, i, e, er, es LOGICAL :: want_wave_cfg CHARACTER(1024) :: em ! static seabed friction of this line: lay azimuth override [rad] (unallocated: the @@ -10194,12 +10242,31 @@ RECURSIVE SUBROUTINE init_one_model(ln, sections, types, points, opts, model, Er ve_bad = CD_ZERO ve_ambl = CD_ZERO ve_vbet = CD_ZERO + ! OPTION axialDampingRatio mode1: a negative BA is the damping ratio of the line's first + ! axial mode, set by the deck sections whatever the mesh + mode1_omega = CD_ZERO + IF (opts%axial_damping_mode1) THEN + IF (ANY([(types(line_section_type(sections, ln%id, i))%ba < CD_ZERO, i=1, SIZE(secs))])) THEN + CALL line_mode1_omega(ln%id, sections, types, mode1_omega, es, em) + IF (es /= CD_DECKDRV_OK) THEN + CALL fail(ErrStat, ErrMsg, TRIM(em)); RETURN + END IF + END IF + END IF e = 0 DO i = 1, SIZE(secs) DO j = 1, secs(i)%n_segments e = e + 1 - CALL CD_Resolve_Legacy_BA(types(line_section_type(sections, ln%id, i))%ba, l0(e), ea(e), mpl(e), & - ba(e), es, em) + ! e counts the elements from End A, as the per-element arrays filled here (reversed + ! below); the mesh l0, ea, mpl runs from the anchor, End B: element e is er there + er = SIZE(l0) + 1 - e + IF (opts%axial_damping_mode1) THEN + CALL CD_Resolve_Mode1_BA(types(line_section_type(sections, ln%id, i))%ba, ea(er), mode1_omega, ba(e), & + es, em) + ELSE + CALL CD_Resolve_Legacy_BA(types(line_section_type(sections, ln%id, i))%ba, l0(er), ea(er), mpl(er), & + ba(e), es, em) + END IF IF (es /= CD_DAMP_OK) THEN CALL fail(ErrStat, ErrMsg, 'BA resolution for model init: '//TRIM(em)); RETURN END IF @@ -12508,6 +12575,117 @@ SUBROUTINE axial_period_note(line_id, dt, sections, types, note) 'can be wrong. A dtM of at most '//TRIM(ADJUSTL(b4))//' s resolves them' END SUBROUTINE axial_period_note + SUBROUTINE line_mode1_omega(line_id, sections, types, omega1, ErrStat, ErrMsg) + !! The first axial frequency [rad/s] that OPTION axialDampingRatio mode1 sets a negative BA + !! of the line by: the first axial mode of the line fixed at both ends, from its deck + !! sections (CD_Axial_Mode1_Omega: unstretched lengths, EA, dry mass per length, on a fixed + !! internal mesh), so it does not depend on the line's mesh. A section whose axial stiffness is not one EA + !! (viscoelastic ElasticMod 2 or 3, Syrope) fails by name: its first axial mode is not set + !! by one BA. + INTEGER, INTENT(IN) :: line_id + TYPE(DeckSection), INTENT(IN) :: sections(:) + TYPE(DeckLineType), INTENT(IN) :: types(:) + REAL(wp), INTENT(OUT) :: omega1 + INTEGER, INTENT(OUT) :: ErrStat + CHARACTER(*), INTENT(OUT) :: ErrMsg + REAL(wp), ALLOCATABLE :: len_s(:), ea_s(:), m_s(:) + INTEGER :: j, k, es + CHARACTER(1024) :: em + + ErrStat = CD_DECKDRV_OK + ErrMsg = '' + omega1 = CD_ZERO + ALLOCATE (len_s(0), ea_s(0), m_s(0)) + DO j = 1, SIZE(sections) + IF (sections(j)%line_id /= line_id) CYCLE + k = find_type(types, sections(j)%ltype_name) + IF (k < 1) THEN + CALL fail(ErrStat, ErrMsg, 'internal: LINE '//TRIM(int_to_str(line_id))//' has a section of an unknown type') + RETURN + END IF + IF (types(k)%is_syrope .OR. types(k)%elastic_mod > 1) THEN + CALL fail(ErrStat, ErrMsg, 'OPTION axialDampingRatio mode1: LINE '//TRIM(int_to_str(line_id))// & + ' has a negative BA and the section type '//TRIM(types(k)%name)//', whose axial stiffness is '// & + 'viscoelastic or a Syrope working curve: the damping ratio of its first axial mode is not set by '// & + 'one BA. Give BA in N s or use axialDampingRatio segment') + RETURN + END IF + len_s = [len_s, sections(j)%length] + ea_s = [ea_s, types(k)%ea] + m_s = [m_s, types(k)%mass] + END DO + CALL CD_Axial_Mode1_Omega(len_s, ea_s, m_s, omega1, es, em) + IF (es /= CD_DAMP_OK) THEN + CALL fail(ErrStat, ErrMsg, 'OPTION axialDampingRatio mode1: LINE '//TRIM(int_to_str(line_id))//': '//TRIM(em)) + RETURN + END IF + END SUBROUTINE line_mode1_omega + + SUBROUTINE axial_damping_note(summary, sections, types, opts, note) + !! A line whose negative BA is the damping ratio of each segment (MoorDyn's convention, the + !! default of OPTION axialDampingRatio): the damping ratio this gives the line's first axial + !! mode, from the line's final mesh and resolved BA (summary%axial_zeta1, + !! CD_Axial_Mode1_Damping: the line fixed at both ends), with the number of elements, since + !! it falls as the mesh is refined (zeta pi/(2N) on a uniform line). A line with a + !! viscoelastic or Syrope section is noted without the ratio. Only a note: nothing is changed. + TYPE(CD_LineInitSummary), INTENT(IN) :: summary + TYPE(DeckSection), INTENT(IN) :: sections(:) + TYPE(DeckLineType), INTENT(IN) :: types(:) + TYPE(DeckOptions), INTENT(IN) :: opts + CHARACTER(*), INTENT(OUT) :: note + INTEGER :: j, k + LOGICAL :: negative, simple + CHARACTER(32) :: b1 + + note = '' + IF (opts%axial_damping_mode1) RETURN + negative = .FALSE. + simple = .TRUE. + DO j = 1, SIZE(sections) + IF (sections(j)%line_id /= summary%line_id) CYCLE + k = find_type(types, sections(j)%ltype_name) + IF (k < 1) RETURN + IF (types(k)%ba < CD_ZERO) negative = .TRUE. + IF (types(k)%is_syrope .OR. types(k)%elastic_mod > 1) simple = .FALSE. + END DO + IF (.NOT. negative) RETURN + note = 'LINE '//TRIM(int_to_str(summary%line_id))//': its negative BA is a damping ratio per segment, BA = '// & + 'zeta L0 sqrt(EA m) on each element' + IF (simple .AND. summary%axial_zeta1 >= CD_ZERO .AND. summary%axial_elements > 0) THEN + IF (100.0_wp*summary%axial_zeta1 >= 0.01_wp) THEN + WRITE (b1, '(F12.3)') 100.0_wp*summary%axial_zeta1 + ELSE + WRITE (b1, '(ES10.3)') 100.0_wp*summary%axial_zeta1 + END IF + note = TRIM(note)//', which damps the first axial mode of the line (both ends held) at '// & + TRIM(ADJUSTL(b1))//' % of critical on its '//TRIM(int_to_str(summary%axial_elements))// & + ' elements (zeta pi/(2N) on a uniform line of N elements): the axial damping depends on the mesh' + ELSE + note = TRIM(note)//': the axial damping of the line depends on the mesh' + END IF + note = TRIM(note)//'. With OPTION mode1 axialDampingRatio a negative BA is the damping ratio of the '// & + 'first axial mode, the same on any mesh' + END SUBROUTINE axial_damping_note + + SUBROUTINE summary_axial_damping(summary, l0, ea, mass_per_length, ba) + !! The damping ratio of the first axial mode of a built line (CD_Axial_Mode1_Damping), for its + !! initialisation note (axial_damping_note); left unset (-1) when it cannot be worked out. + TYPE(CD_LineInitSummary), INTENT(INOUT) :: summary + REAL(wp), INTENT(IN) :: l0(:), ea(:), mass_per_length(:), ba(:) + REAL(wp) :: zeta1, omega1 + INTEGER :: es + CHARACTER(256) :: em + + summary%axial_zeta1 = -CD_ONE + summary%axial_elements = 0 + IF (SIZE(l0) < 1 .OR. SIZE(ea) /= SIZE(l0) .OR. SIZE(mass_per_length) /= SIZE(l0) .OR. & + SIZE(ba) /= SIZE(l0)) RETURN + CALL CD_Axial_Mode1_Damping(l0, ea, mass_per_length, ba, zeta1, omega1, es, em) + IF (es /= CD_DAMP_OK) RETURN + summary%axial_zeta1 = zeta1 + summary%axial_elements = SIZE(l0) + END SUBROUTINE summary_axial_damping + 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 @@ -17409,6 +17587,17 @@ SUBROUTINE apply_option(line, opts, ErrStat, ErrMsg) CASE DEFAULT CALL fail(ErrStat, ErrMsg, 'OPTION OutputRecovery "'//TRIM(val)//'" must be equilibrium or element') END SELECT + CASE ('axialdampingratio', 'axial_damping_ratio') + ! what a negative LINE TYPES BA is the damping ratio of: each segment (MoorDyn) or the + ! line's first axial mode + SELECT CASE (to_lower(TRIM(strip_quotes(val)))) + CASE ('segment') + opts%axial_damping_mode1 = .FALSE. + CASE ('mode1') + opts%axial_damping_mode1 = .TRUE. + CASE DEFAULT + CALL fail(ErrStat, ErrMsg, 'OPTION axialDampingRatio "'//TRIM(val)//'" must be segment or mode1') + END SELECT CASE ('cable_load_feedback', 'cableloadfeedback') CALL rd_bool(val, opts%cable_load_feedback, ErrStat, ErrMsg) CASE ('predict_loads', 'predictloads') @@ -36971,6 +37160,7 @@ SUBROUTINE collect_aggregate_init_summaries(lines, agg, ErrStat, ErrMsg) RETURN END IF CALL set_init_summary(lines(i)%id, tension, declination, azimuth, agg%init_lines(i), fair_force=fair_force) + CALL cable_axial_damping(agg%init_lines(i), agg%cables(agg%line_obj_index(lines(i)%id))) IF (ALLOCATED(agg%cable_note)) agg%init_lines(i)%note = agg%cable_note(agg%line_obj_index(lines(i)%id)) ELSE IF (agg%has_sys) THEN ! The force the EI=0 line exerts on its fairlead (internal last node), as the @@ -36983,6 +37173,7 @@ SUBROUTINE collect_aggregate_init_summaries(lines, agg, ErrStat, ErrMsg) RETURN END IF CALL set_init_summary(lines(i)%id, tension, declination, azimuth, agg%init_lines(i), fair_force=fair_force) + CALL model_axial_damping(agg%init_lines(i), agg%sys%lines(agg%line_obj_index(lines(i)%id))) ELSE CALL set_init_summary(lines(i)%id, tension, declination, azimuth, agg%init_lines(i)) END IF @@ -37050,6 +37241,7 @@ SUBROUTINE collect_model_init_summaries(lines, points, models, summaries, ErrSta RETURN END IF CALL set_init_summary(lines(i)%id, tension, declination, azimuth, summaries(i), fair_force=fair_force) + CALL model_axial_damping(summaries(i), models(i)) END DO IF (PRESENT(opts)) CALL add_model_fold_notes(lines, points, opts, models, summaries, FOLD_FRICTION_FULL) END SUBROUTINE collect_model_init_summaries @@ -37090,6 +37282,7 @@ SUBROUTINE collect_system_init_summaries(lines, points, system, summaries, ErrSt RETURN END IF CALL set_init_summary(lines(i)%id, tension, declination, azimuth, summaries(i), fair_force=fair_force) + CALL model_axial_damping(summaries(i), system%lines(i)) END DO IF (PRESENT(opts)) THEN IF (PRESENT(friction_support)) THEN @@ -37125,9 +37318,29 @@ SUBROUTINE collect_hermite_init_summaries(lines, cables, summaries, opts, fricti CALL set_init_summary(lines(i)%id, tension, declination, azimuth, summaries(i), fair_force=fair_force) summaries(i)%seabed_drift = cable_seabed_drift(opts, cables(i)) summaries(i)%friction_refused = friction_support == FOLD_FRICTION_NONE + CALL cable_axial_damping(summaries(i), cables(i)) END DO END SUBROUTINE collect_hermite_init_summaries + SUBROUTINE model_axial_damping(summary, model) + !! summary_axial_damping of an EI = 0 line: its plain axial damping (zero on viscoelastic and + !! Syrope elements, whose own laws carry theirs). + TYPE(CD_LineInitSummary), INTENT(INOUT) :: summary + TYPE(CD_ModelType), INTENT(IN) :: model + IF (.NOT. (ALLOCATED(model%ba_dyn) .AND. ALLOCATED(model%l0) .AND. ALLOCATED(model%ea) .AND. & + ALLOCATED(model%rho_a))) RETURN + CALL summary_axial_damping(summary, model%l0, model%ea, model%rho_a, model%ba_dyn) + END SUBROUTINE model_axial_damping + + SUBROUTINE cable_axial_damping(summary, cable) + !! summary_axial_damping of a finite-EI cable, on its final mesh. + TYPE(CD_LineInitSummary), INTENT(INOUT) :: summary + TYPE(CD_HFMF_ModuleType), INTENT(IN) :: cable + IF (.NOT. (ALLOCATED(cable%line%BA) .AND. ALLOCATED(cable%line%l0) .AND. ALLOCATED(cable%line%EA) .AND. & + ALLOCATED(cable%line%rho_a))) RETURN + CALL summary_axial_damping(summary, cable%line%l0, cable%line%EA, cable%line%rho_a, cable%line%BA) + END SUBROUTINE cable_axial_damping + SUBROUTINE print_deck_init_report(deck_path, lines, points, sections, n_finite, summaries, types, opts) !! The initialization report goes to stderr (see doc/cli.rst). CHARACTER(*), INTENT(IN) :: deck_path @@ -37186,6 +37399,8 @@ SUBROUTINE print_deck_init_report(deck_path, lines, points, sections, n_finite, 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)//'.' + CALL axial_damping_note(summaries(i), sections, types, opts, strain_note) + IF (LEN_TRIM(strain_note) > 0) WRITE (error_unit, '(A)') ' Note: '//TRIM(strain_note)//'.' END IF END IF END DO diff --git a/src/CableDyn_HermiteCableStatic.f90 b/src/CableDyn_HermiteCableStatic.f90 index 1ab2f5e..60f09d2 100644 --- a/src/CableDyn_HermiteCableStatic.f90 +++ b/src/CableDyn_HermiteCableStatic.f90 @@ -206,6 +206,13 @@ MODULE CableDyn_HermiteCableStatic REAL(wp) :: laid_origin(2) = 0.0_wp, laid_node1(2) = 0.0_wp REAL(wp) :: laid_azimuth = 0.0_wp, laid_tension = 0.0_wp REAL(wp) :: laid_frame_cs(2) = [1.0_wp, 0.0_wp] + ! The dry part of a laid line (laid_water_weight > 0, on a bathymetry): the nodes of a line + ! laid within its section radius of the still-water level laid_waterline_z sink under the + ! buoyancy the solver gives back there (CD_HermiteCable_As_Laid_Offsets). laid_water_weight + ! is rho g [N/m^3]; the section diameter is laid_diam on the reference-arc intervals ending + ! at laid_arc_end (cumulative rest length from node 1), so any mesh of the line looks it up. + REAL(wp) :: laid_water_weight = 0.0_wp, laid_waterline_z = 0.0_wp + REAL(wp), ALLOCATABLE :: laid_arc_end(:), laid_diam(:) END TYPE CD_HermiteStaticFrictionType TYPE, PUBLIC :: CD_HermiteStaticCurrentType @@ -2786,7 +2793,8 @@ END SUBROUTINE fail END SUBROUTINE CD_HermiteCable_Static_Solve - SUBROUTINE CD_HermiteCable_As_Laid_Offsets(fr, l0, EA, node_load, node_kn, offsets, ErrStat, ErrMsg, bathymetry) + SUBROUTINE CD_HermiteCable_As_Laid_Offsets(fr, l0, EA, node_load, node_kn, offsets, ErrStat, ErrMsg, bathymetry, & + node_dry_load) !! As-laid friction reference of the nodes of mesh l0 (the lay of fr, laid_* above; !! CD_Static_As_Laid_Reference): node i at the stretched arc length of the elements !! l0 (1 + laid_tension/EA) along the seabed from the lower end, and on a bathymetry @@ -2795,13 +2803,20 @@ SUBROUTINE CD_HermiteCable_As_Laid_Offsets(fr, l0, EA, node_load, node_kn, offse !! turning force of the laid tension). offsets(1:2, i) is the reference of node i less !! the global position laid_node1 of node 1, in the solve frame laid_frame_cs; with !! laid_in_plane on a bathymetry only the sink offset across the chord leaves its plane. + !! node_load holds the submerged weights. On a bathymetry with fr%laid_water_weight > 0 a + !! node sinks under the normal load the solver applies at its laid position instead + !! (laid_dry_loads): with the buoyancy the line gives back on its part above the still-water + !! level fr%laid_waterline_z (CD_HermiteCable_Dry_Buoyancy of the laid centreline: a section + !! within its radius of the surface is partly dry), and node_dry_load(i) [N, downward, the + !! buoyancy of the attachments on node i] where node i is laid above that level. TYPE(CD_HermiteStaticFrictionType), INTENT(IN) :: fr REAL(wp), INTENT(IN) :: l0(:), EA(:), node_load(:), node_kn(:) REAL(wp), INTENT(OUT) :: offsets(:, :) INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg TYPE(CD_BathymetryType), INTENT(IN), OPTIONAL :: bathymetry - REAL(wp), ALLOCATABLE :: ref_g(:, :), ref_0(:, :), wn(:), sn(:), kn_o(:), l0_r(:), ea_r(:) + REAL(wp), INTENT(IN), OPTIONAL :: node_dry_load(:) + REAL(wp), ALLOCATABLE :: ref_g(:, :), ref_0(:, :), wn(:), sn(:), kn_o(:), l0_r(:), ea_r(:), load(:) REAL(wp) :: dg(2), dg0(2), cs(2) INTEGER :: k, ne, es CHARACTER(1024) :: em @@ -2816,13 +2831,26 @@ SUBROUTINE CD_HermiteCable_As_Laid_Offsets(fr, l0, EA, node_load, node_kn, offse ErrStat = CD_HCSTAT_BADINPUT; ErrMsg = 'as-laid friction reference: nodal loads and stiffnesses must be finite' RETURN END IF + IF (PRESENT(node_dry_load)) THEN + IF (SIZE(node_dry_load) /= ne + 1 .OR. .NOT. CD_All_Finite(node_dry_load)) THEN + ErrStat = CD_HCSTAT_BADINPUT + ErrMsg = 'as-laid friction reference: node_dry_load must be finite, one entry per node'; RETURN + END IF + END IF offsets = CD_ZERO ALLOCATE (ref_g(2, ne + 1)) IF (PRESENT(bathymetry)) THEN + load = node_load + IF (fr%laid_water_weight > CD_ZERO) THEN + CALL laid_dry_loads(load, es, em) + IF (es /= CD_HCSTAT_OK) THEN + ErrStat = CD_HCSTAT_BADINPUT; ErrMsg = 'as-laid friction reference: '//TRIM(em); RETURN + END IF + END IF ! the static penalty penetration factor w_i/k_i of every node ALLOCATE (wn(ne + 1)) WHERE (node_kn > CD_ZERO) - wn = MAX(node_load, CD_ZERO)/node_kn + wn = MAX(load, CD_ZERO)/node_kn ELSEWHERE wn = CD_ZERO END WHERE @@ -2870,6 +2898,156 @@ SUBROUTINE CD_HermiteCable_As_Laid_Offsets(fr, l0, EA, node_load, node_kn, offse offsets(2, k) = (-cs(2)*dg(1) + cs(1)*dg(2)) - (-cs(2)*dg0(1) + cs(1)*dg0(2)) END IF END DO + + CONTAINS + + SUBROUTINE laid_dry_loads(nl, esd, emd) + !! The nodal loads nl (the submerged weights on entry) as the static solve and the dynamics + !! apply them to a node resting at its laid position: the line laid without its sink, the + !! seabed under every node, the tangent along the laid path stretched by the laid tension. + !! There a node of load W stands at the height z = z_floor - W (1 + |grad z_floor|^2)/k of + !! the penalty seabed's static penetration, and the line gives back the buoyancy + !! laid_water_weight pi d^2/4 of its part above laid_waterline_z + !! (CD_HermiteCable_Dry_Buoyancy with the section radius d/2, d the laid_diam of the + !! element's midpoint arc), a node above that level also node_dry_load: W = nl + the dry + !! load at z(W), solved by Newton's method with the dry load's exact height derivative (its + !! residual decreases monotonically in W), each step halved until it reduces the residual's + !! 2-norm: a full step can jump between a node held off the floor (wholly dry) and one sunk + !! below the surface (wholly wet) and cycle there. The turning force of the laid tension is left + !! out of the height. nl is unchanged where the line lies below the surface by more than + !! its radius. + REAL(wp), INTENT(INOUT) :: nl(:) + INTEGER, INTENT(OUT) :: esd + CHARACTER(*), INTENT(OUT) :: emd + INTEGER, PARAMETER :: MAX_NEWTON = 200 + REAL(wp), PARAMETER :: LOAD_RTOL = 1.0e-12_wp + ! the Armijo slope of the step acceptance and the shortest step tried + REAL(wp), PARAMETER :: ARMIJO = 1.0e-4_wp, MIN_STEP = 2.0_wp**(-30) + REAL(wp), ALLOCATABLE :: ref_u(:, :), zf(:), dzds(:), sfac2(:), de(:), base(:), wl(:), z(:), fd(:), & + gres(:), dg(:), lo(:), up(:), y(:), wl_acc(:) + REAL(wp) :: dir(2), gx, gy, ga, qz(4), fdry(4), kdry(4, 4), s_mid, stretch, scale_w, piv, gnorm, & + gnorm_acc, lam + INTEGER :: j, e, ix, esq, it + LOGICAL :: dry, any_dry + esd = CD_HCSTAT_OK + emd = '' + IF (.NOT. (ALLOCATED(fr%laid_arc_end) .AND. ALLOCATED(fr%laid_diam))) RETURN + IF (SIZE(fr%laid_arc_end) < 1 .OR. SIZE(fr%laid_diam) /= SIZE(fr%laid_arc_end)) THEN + esd = CD_HCSTAT_BADINPUT; emd = 'the dry-part diameter profile is inconsistent'; RETURN + END IF + IF (ANY(node_kn <= CD_ZERO)) RETURN + ! the laid path without the sink, from the lower end, in node order + ALLOCATE (ref_u(2, ne + 1), zf(ne + 1), dzds(ne + 1), sfac2(ne + 1), de(ne)) + IF (fr%laid_from_top) THEN + CALL CD_Static_As_Laid_Reference(fr%laid_origin, fr%laid_azimuth, l0(ne:1:-1), EA(ne:1:-1), & + fr%laid_tension, ref_u, esq, emd, bathymetry=bathymetry) + ref_u = ref_u(:, ne + 1:1:-1) + ELSE + CALL CD_Static_As_Laid_Reference(fr%laid_origin, fr%laid_azimuth, l0, EA, fr%laid_tension, ref_u, esq, & + emd, bathymetry=bathymetry) + END IF + IF (esq /= CD_STATIC_OK) THEN + esd = CD_HCSTAT_BADINPUT; RETURN + END IF + dir = [COS(fr%laid_azimuth), SIN(fr%laid_azimuth)] + DO j = 1, ne + 1 + CALL CD_Bathymetry_Floor_Gradient(bathymetry, ref_u(1, j), ref_u(2, j), zf(j), gx, gy, esq, emd) + IF (esq /= CD_BATHY_OK) THEN + esd = CD_HCSTAT_BADINPUT; RETURN + END IF + ! the rise of the laid path per unit length along it, in node order (laid from the last + ! node, the node order runs against the lay) + ga = gx*dir(1) + gy*dir(2) + dzds(j) = MERGE(-CD_ONE, CD_ONE, fr%laid_from_top)*ga/SQRT(CD_ONE + ga*ga) + sfac2(j) = CD_ONE + gx*gx + gy*gy + END DO + s_mid = CD_ZERO + ix = 1 + DO e = 1, ne + s_mid = s_mid + 0.5_wp*l0(e) + DO WHILE (ix < SIZE(fr%laid_arc_end)) + IF (fr%laid_arc_end(ix) >= s_mid) EXIT + ix = ix + 1 + END DO + s_mid = s_mid + 0.5_wp*l0(e) + de(e) = fr%laid_diam(ix) + END DO + base = nl + wl = nl + scale_w = MAX(CD_ONE, MAXVAL(ABS(base))) + ALLOCATE (z(ne + 1), fd(ne + 1), gres(ne + 1), dg(ne + 1), lo(ne + 1), up(ne + 1), y(ne + 1), & + wl_acc(ne + 1)) + wl_acc = wl + gnorm_acc = HUGE(CD_ONE) + lam = CD_ONE + DO it = 1, MAX_NEWTON + ! the dry load at the heights of the loads wl, and its derivative with respect to them + z = zf - MAX(wl, CD_ZERO)*sfac2/node_kn + fd = CD_ZERO + dg = CD_ONE + lo = CD_ZERO + up = CD_ZERO + any_dry = .FALSE. + DO e = 1, ne + IF (.NOT. (de(e) > CD_ZERO)) CYCLE + stretch = CD_ONE + fr%laid_tension/EA(e) + qz = [z(e), stretch*dzds(e), z(e + 1), stretch*dzds(e + 1)] + CALL CD_HermiteCable_Dry_Buoyancy(qz, l0(e), fr%laid_water_weight*0.25_wp*PI_W*de(e)**2, & + fr%laid_waterline_z, fdry, kdry, dry, radius=0.5_wp*de(e)) + IF (.NOT. dry) CYCLE + any_dry = .TRUE. + fd(e) = fd(e) + fdry(1) + fd(e + 1) = fd(e + 1) + fdry(3) + ! d(fd)/d(wl) = -kdry dz/dwl, dz/dwl = -sfac2/kn where the load presses the node in + IF (wl(e) > CD_ZERO) THEN + dg(e) = dg(e) + kdry(1, 1)*sfac2(e)/node_kn(e) + lo(e + 1) = lo(e + 1) + kdry(3, 1)*sfac2(e)/node_kn(e) + END IF + IF (wl(e + 1) > CD_ZERO) THEN + dg(e + 1) = dg(e + 1) + kdry(3, 3)*sfac2(e + 1)/node_kn(e + 1) + up(e) = up(e) + kdry(1, 3)*sfac2(e + 1)/node_kn(e + 1) + END IF + END DO + IF (PRESENT(node_dry_load)) THEN + WHERE (z > fr%laid_waterline_z) fd = fd + node_dry_load + any_dry = any_dry .OR. ANY(z > fr%laid_waterline_z .AND. ABS(node_dry_load) > CD_ZERO) + END IF + IF (it == 1 .AND. .NOT. any_dry) RETURN + gres = wl - base - fd + IF (MAXVAL(ABS(gres)) <= LOAD_RTOL*scale_w) EXIT + ! a step that does not reduce the residual is halved from the last accepted loads (the + ! Newton step is a descent direction of its 2-norm); the shortest is taken as it is + gnorm = SQRT(SUM(gres*gres)) + IF (.NOT. (gnorm <= (CD_ONE - ARMIJO*lam)*gnorm_acc) .AND. lam > MIN_STEP) THEN + lam = 0.5_wp*lam + wl = wl_acc + lam*y + CYCLE + END IF + wl_acc = wl + gnorm_acc = gnorm + lam = CD_ONE + ! (I + K D) dw = -gres, tridiagonal (diagonal dg, sub lo, super up): Thomas elimination; + ! the diagonal is at least 1 and grows with the dry stiffness + y = -gres + DO j = 2, ne + 1 + piv = lo(j)/dg(j - 1) + dg(j) = dg(j) - piv*up(j - 1) + y(j) = y(j) - piv*y(j - 1) + END DO + y(ne + 1) = y(ne + 1)/dg(ne + 1) + DO j = ne, 1, -1 + y(j) = (y(j) - up(j)*y(j + 1))/dg(j) + END DO + wl = wl + y + IF (.NOT. CD_All_Finite(wl)) THEN + esd = CD_HCSTAT_BADINPUT; emd = 'the dry-part load is not finite'; RETURN + END IF + END DO + IF (MAXVAL(ABS(gres)) > LOAD_RTOL*scale_w) THEN + esd = CD_HCSTAT_BADINPUT; emd = 'the dry-part load of the laid line did not converge'; RETURN + END IF + nl = wl + END SUBROUTINE laid_dry_loads END SUBROUTINE CD_HermiteCable_As_Laid_Offsets SUBROUTINE CD_HermiteCable_Friction_Reference(fr, l0, origin, ref, ErrStat, ErrMsg, EA, w, node_kn, bathymetry) diff --git a/tests/check_axial_damping_ratio.py b/tests/check_axial_damping_ratio.py new file mode 100644 index 0000000..d8da219 --- /dev/null +++ b/tests/check_axial_damping_ratio.py @@ -0,0 +1,227 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Axial damping of a negative BA under OPTION axialDampingRatio (driver_axial_damping_ratio in +CMakeLists.txt). + +A vertical taut line (99.5 m between points 100 m apart, no axial drag or added mass) is excited in +its first axial mode by a smooth half-cosine lift of its top end, held afterwards, and the free +decay of the anchor tension is fitted with one damped sinusoid. On the uniform line (EA = 5e7 N, +50 kg/m) the lift lasts 3/4 of the mode-1 period, which leaves the second mode at rest; the third, +damped three times as much, has died out before the fitted window. The composite line puts a +stiffer, heavier lower half (EA = 2e8 N, 100 kg/m) under the same upper half. + +* Default ``segment`` form, BA = -1 (BA = zeta L0 sqrt(EA m) of each element): the first axial + mode of the uniform line is damped at zeta pi/(2N), halving as the mesh doubles (N = 50, 100, + 200), and the initialisation note gives that ratio for the line's mesh. On the composite line + the ratio is the Rayleigh quotient of the axial chain with every element damped by its own EA + and mass, worked out here independently. +* ``mode1 axialDampingRatio``, BA = -0.02: the first axial mode is damped at 2 % on every mesh, + uniform and composite, on the EI = 0 route and on the finite-EI route, and no note is made. + The finite-EI route also checks the segment form and its note on 100 elements. A viscoelastic + section, whose first axial mode one BA does not set, is refused by name under mode1. + +Every fitted ratio must lie within 3 % of its target. + +Usage: check_axial_damping_ratio.py +""" + +import math +import pathlib +import re +import subprocess +import sys +import tempfile + +import numpy as np + +UPPER = ("rope", 5.0e7, 50.0) +LOWER = ("wire", 2.0e8, 100.0) +LENGTH = 99.5 +SPAN = 100.0 +TOP_Z = -10.0 +LIFT = 0.05 +OMEGA1 = math.pi / LENGTH * math.sqrt(UPPER[1] / UPPER[2]) +PERIOD = 2.0 * math.pi / OMEGA1 +DT = 0.002 +RAMP = 0.75 * PERIOD +WINDOW = (1.5, 7.5) +TOLERANCE = 0.03 +MESHES = (50, 100, 200) +ZETA_MODE1 = 0.02 + + +def deck(n_elem: int, ba: float, ei: float, mode1: bool, composite: bool = False) -> str: + option = "mode1 axialDampingRatio\n" if mode1 else "" + types = "".join(f"{name} 0.1 {mass} {ea} {ba} {ei} 1.2 0.0 1.0 0.0\n" for name, ea, mass in (UPPER, LOWER)) + if composite: + sections = f"1 rope {LENGTH / 2} {n_elem // 2}\n1 wire {LENGTH / 2} {n_elem // 2}\n" + else: + sections = f"1 rope {LENGTH} {n_elem}\n" + return f"""--------------------- CableDyn Input File ------------------------------------ +Free axial decay of a vertical taut line +--------------------- LINE TYPES --------------------------------------------- +TypeName Diam MassDenInAir EA BA/-zeta EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (N-s/-) (N-m^2) (-) (-) (-) (-) +{types}--------------------- POINTS ------------------------------------------------- +ID Type X Y Z Mass Vol CdA Ca +(-) (-) (m) (m) (m) (kg) (m^3) (m^2) (-) +1 Coupled 0.0 0.0 {TOP_Z} 0 0 0 0 +2 Fixed 0.0 0.0 {TOP_Z - SPAN} 0 0 0 0 +--------------------- LINES -------------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +1 1 2 - +--------------------- SECTIONS ----------------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +{sections}--------------------- OPTIONS ------------------------------------------------ +{option}9.80665 g +1025.0 rhoW +1.0 rhoInf +{DT} dtM +{WINDOW[1]} TMax +motion.txt motionFile +--------------------- OUTPUTS ------------------------------------------------ +"AnchTen1" +END +""" + + +def motion() -> str: + rows = ["# time point x y z vx vy vz ax ay az"] + for k in range(int(round(WINDOW[1] / DT)) + 2): + t = k * DT + if t < RAMP: + arg = math.pi * t / RAMP + z = 0.5 * LIFT * (1.0 - math.cos(arg)) + vz = 0.5 * LIFT * math.pi / RAMP * math.sin(arg) + az = 0.5 * LIFT * (math.pi / RAMP) ** 2 * math.cos(arg) + else: + z, vz, az = LIFT, 0.0, 0.0 + rows.append(f"{t:.6f} 1 0 0 {TOP_Z + z:.12g} 0 0 {vz:.12g} 0 0 {az:.12g}") + return "\n".join(rows) + "\n" + + +def run(exe: str, tmp: pathlib.Path, name: str, text: str) -> tuple[np.ndarray, np.ndarray, str]: + folder = tmp / name + folder.mkdir() + (folder / "motion.txt").write_text(motion()) + (folder / "deck.dat").write_text(text) + proc = subprocess.run([exe, "deck.dat", "run"], cwd=folder, capture_output=True, text=True) + if proc.returncode != 0: + raise SystemExit(f"FAIL: the driver did not run {name}:\n{(proc.stdout + proc.stderr)[-3000:]}") + lines = (folder / "run.out").read_text().splitlines() + names = [name.split("(")[0] for name in lines[1].split()] + data = np.array([[float(v) for v in row.split()] for row in lines[2:] if row.strip()]) + return data[:, names.index("Time")], data[:, names.index("AnchTen1")], proc.stdout + proc.stderr + + +def fit_decay(t: np.ndarray, y: np.ndarray) -> float: + """The damping ratio of y = c + exp(-s t) (a cos(w t) + b sin(w t)), by Gauss-Newton from a + zero-crossing frequency and a log-decrement start.""" + keep = (t >= WINDOW[0]) & (t <= WINDOW[1]) + t, y = t[keep] - WINDOW[0], y[keep] + yc = y - y.mean() + crossings = t[np.nonzero(np.diff(np.signbit(yc)))[0]] + w = math.pi * (len(crossings) - 1) / (crossings[-1] - crossings[0]) + half = len(t) // 2 + s = math.log(np.abs(yc[:half]).max() / np.abs(yc[half:]).max()) / (t[half] - t[0]) + p = np.array([y.mean(), yc[0], 0.0, s, w]) + for _ in range(100): + c, a, b, s, w = p + e = np.exp(-s * t) + cs, sn = np.cos(w * t), np.sin(w * t) + model = c + e * (a * cs + b * sn) + jac = np.column_stack( + [np.ones_like(t), e * cs, e * sn, -t * e * (a * cs + b * sn), t * e * (-a * sn + b * cs)] + ) + step = np.linalg.lstsq(jac, y - model, rcond=None)[0] + p = p + step + if abs(step[3]) <= 1.0e-12 * abs(p[3]) and abs(step[4]) <= 1.0e-12 * abs(p[4]): + break + s, w = p[3], p[4] + return s / math.hypot(s, w) + + +def chain_ratio(n_elem: int, zeta: float) -> float: + """The first-mode damping ratio of the composite line's axial chain (both ends held, element + stiffness EA/L0, damping zeta sqrt(EA m) = BA/L0 of each element itself, lumped masses).""" + half = n_elem // 2 + h = LENGTH / n_elem + ea = np.array([UPPER[1]] * half + [LOWER[1]] * half) + mass = np.array([UPPER[2]] * half + [LOWER[2]] * half) + k, c = ea / h, zeta * np.sqrt(ea * mass) + stiff, damp = np.zeros((n_elem - 1, n_elem - 1)), np.zeros((n_elem - 1, n_elem - 1)) + for e in range(n_elem): + for p, q, sign in ((e - 1, e - 1, 1), (e, e, 1), (e - 1, e, -1), (e, e - 1, -1)): + if 0 <= p < n_elem - 1 and 0 <= q < n_elem - 1: + stiff[p, q] += sign * k[e] + damp[p, q] += sign * c[e] + lumped = 0.5 * (mass[:-1] + mass[1:]) * h + scale = 1.0 / np.sqrt(lumped) + w2, vec = np.linalg.eigh(scale[:, None] * stiff * scale[None, :]) + u = scale * vec[:, 0] + return float(u @ damp @ u / (2.0 * math.sqrt(w2[0]) * (u @ (lumped * u)))) + + +def note_ratio(log: str) -> float | None: + match = re.search(r"first axial mode of the line \(both ends held\) at ([0-9.Ee+-]+) % of critical", log) + return None if match is None else float(match.group(1)) / 100.0 + + +def check(label: str, zeta: float, target: float) -> bool: + err = zeta / target - 1.0 + print(f"{label}: zeta_1 = {zeta:.5f}, target {target:.5f} ({err:+.2%})") + if abs(err) > TOLERANCE: + print(f"FAIL: {label} is not damped at its target ratio") + return False + return True + + +def main() -> int: + exe = sys.argv[1] + ok = True + with tempfile.TemporaryDirectory() as folder: + tmp = pathlib.Path(folder) + print(f"first axial mode of the uniform line: {OMEGA1:.4f} rad/s, period {PERIOD:.4f} s") + for n_elem, ei in [(n, 0.0) for n in MESHES] + [(100, 1.0)]: + target = math.pi / (2.0 * n_elem) + route = "finite-EI " if ei > 0.0 else "" + t, y, log = run(exe, tmp, f"segment_{n_elem}_{ei}", deck(n_elem, -1.0, ei, False)) + ok = check(f"segment {route}N={n_elem}", fit_decay(t, y), target) and ok + noted = note_ratio(log) + print(f" note: {noted}") + if noted is None or abs(noted / target - 1.0) > TOLERANCE: + print("FAIL: the note does not give the first-mode ratio of the segment form") + ok = False + if f"on its {n_elem} elements" not in log or "the axial damping depends on the mesh" not in log: + print("FAIL: the note does not name the mesh") + ok = False + t, y, log = run(exe, tmp, "segment_composite", deck(50, -1.0, 0.0, False, composite=True)) + ok = check("segment composite N=50", fit_decay(t, y), chain_ratio(50, 1.0)) and ok + cases = [(n, 0.0, False) for n in MESHES] + [(50, 0.0, True), (200, 0.0, True), (100, 1.0, False), (100, 1.0, True)] + for n_elem, ei, composite in cases: + label = f"mode1 {'finite-EI' if ei > 0.0 else 'EI=0'}{' composite' if composite else ''} N={n_elem}" + t, y, log = run(exe, tmp, label.replace(" ", "_").replace("=", ""), + deck(n_elem, -ZETA_MODE1, ei, True, composite=composite)) + ok = check(label, fit_decay(t, y), ZETA_MODE1) and ok + if "damping ratio per segment" in log: + print("FAIL: a mode1 line is noted as damped per segment") + ok = False + # a viscoelastic section has no one EA to set its first mode by: refused by name + text = deck(50, -ZETA_MODE1, 0.0, True).replace(f" {UPPER[1]} {-ZETA_MODE1} ", f" {UPPER[1]}|1.0e8 {-ZETA_MODE1}|0 ") + folder_ve = tmp / "mode1_viscoelastic" + folder_ve.mkdir() + (folder_ve / "motion.txt").write_text(motion()) + (folder_ve / "deck.dat").write_text(text) + proc = subprocess.run([exe, "deck.dat", "run"], cwd=folder_ve, capture_output=True, text=True) + refused = "OPTION axialDampingRatio mode1: LINE 1 has a negative BA and the section type rope" + print(f"mode1 viscoelastic: exit {proc.returncode}, refused by name: {refused in proc.stdout + proc.stderr}") + if proc.returncode == 0 or refused not in proc.stdout + proc.stderr: + print("FAIL: mode1 on a viscoelastic section is not refused by name") + ok = False + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_flagship_compression_note.f90 b/tests/test_flagship_compression_note.f90 index 3b53933..309cd90 100644 --- a/tests/test_flagship_compression_note.f90 +++ b/tests/test_flagship_compression_note.f90 @@ -16,6 +16,8 @@ PROGRAM test_flagship_compression_note !! projected on the nodal tangent. It stays within the end tensions instead of the pointwise !! spikes at the touchdown. !! + !! The chains' negative BA, a damping ratio per segment, carries the axial-damping note only. + !! !! A deck whose finite-EI elements are shorter than sqrt(EI/EA) (the conditioning limit !! of the static Newton system) carries the fine-mesh note; the flagship does not. !! @@ -57,7 +59,8 @@ PROGRAM test_flagship_compression_note DO i = 1, SIZE(agg%init_lines) IF (LEN_TRIM(agg%init_lines(i)%note) > 0) WRITE (*, '(A,I0,2A)') ' line ', agg%init_lines(i)%line_id, & ' note: ', TRIM(agg%init_lines(i)%note) - CALL check(LEN_TRIM(agg%init_lines(i)%note) == 0, 'no static-initialisation note on any line') + ! the chains' negative BA is a damping ratio per segment: only that note of the run + CALL check(only_damping_note(agg%init_lines(i)%note), 'no static-initialisation note on any line') END DO END IF @@ -234,6 +237,21 @@ SUBROUTINE case_weightless_bow_note() CALL CD_AGG_End(light, es_l, em_l) END SUBROUTINE case_weightless_bow_note + LOGICAL FUNCTION only_damping_note(note) RESULT(ok) + !! Every line of note is the axial-damping note of a negative BA (axial_damping_note). + CHARACTER(*), INTENT(IN) :: note + INTEGER :: a, b, n + ok = .TRUE. + n = LEN_TRIM(note) + a = 1 + DO WHILE (a <= n) + b = INDEX(note(a:n), NEW_LINE('a')) + IF (b == 0) b = n - a + 2 + IF (INDEX(note(a:a + b - 2), 'its negative BA is a damping ratio per segment') == 0) ok = .FALSE. + a = a + b + END DO + END FUNCTION only_damping_note + SUBROUTINE check(ok, label) LOGICAL, INTENT(IN) :: ok CHARACTER(*), INTENT(IN) :: label diff --git a/tests/test_static_current_balance.f90 b/tests/test_static_current_balance.f90 new file mode 100644 index 0000000..b5e7b44 --- /dev/null +++ b/tests/test_static_current_balance.f90 @@ -0,0 +1,230 @@ +! File: tests/test_static_current_balance.f90 +! SPDX-License-Identifier: Apache-2.0 +! Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +PROGRAM test_static_current_balance + !! Global force balance of the static equilibrium of a finite-EI cable in a uniform current: + !! the end reactions (the force the line exerts on each end point, CD_HermiteCable_Dyn_End_Force, + !! the FairTen/AnchTen and Point

F quantities) balance the submerged weight and the Morison + !! drag of the converged shape, -F_A - F_B + W + D = 0. D is integrated here independently: + !! the documented law f = rho d Cdn |u_n| u_n / 2 + rho pi d Cdt |u_t| u_t / 2 per unit + !! deformed length on the converged cubic-Hermite centreline (its nodal positions and + !! tangents), by the solver's 3-point Gauss rule and by a 10-point one. The cables are pinned + !! (bending-free) or clamped at End A, in currents of 0.5, 1 and 2 m/s at headings 0, 45, 90 + !! and 180 deg across the vertical plane of the ends, suspended (every component checked) and + !! with a touchdown on a flat seabed (the horizontal components: the floor carries the vertical + !! load), on 100 and 25 elements. With the 3-point rule the balance closes to 1e-7 of the end + !! force on every case (the reactions are those of the equilibrium); with the 10-point rule to + !! 2e-7 on 100 elements and 5e-6 on 25 (the drag quadrature). A current that leaves the line + !! no tension-only equilibrium is refused by name, as the deck is. + USE CableDyn_Precision, ONLY: wp + USE CableDyn_OpenFAST_Aggregate, ONLY: CD_AGG_ModuleType, CD_AGG_Init_From_Deck, CD_AGG_End, CD_AGG_OK + USE CableDyn_HermiteCableDynamic, ONLY: CD_HermiteCable_Dyn_End_Force, CD_HCDYN_OK + IMPLICIT NONE + + REAL(wp), PARAMETER :: PI = 3.14159265358979323846_wp + REAL(wp), PARAMETER :: TOL_SOLVER_RULE = 1.0e-7_wp + INTEGER, PARAMETER :: MESHES(2) = [100, 25] + REAL(wp), PARAMETER :: TOL_EXACT(2) = [2.0e-7_wp, 5.0e-6_wp] + REAL(wp), PARAMETER :: SPEEDS(3) = [0.5_wp, 1.0_wp, 2.0_wp] + REAL(wp), PARAMETER :: HEADINGS(4) = [0.0_wp, 45.0_wp, 90.0_wp, 180.0_wp] + INTEGER :: nfail, n_run, iu, ih, iclamp, itd, n_elem, im + REAL(wp) :: worst3, worst10, tol10 + + nfail = 0 + n_run = 0 + DO im = 1, SIZE(MESHES) + n_elem = MESHES(im) + tol10 = TOL_EXACT(im) + worst3 = 0.0_wp + worst10 = 0.0_wp + DO itd = 0, 1 + DO iclamp = 0, 1 + DO iu = 1, SIZE(SPEEDS) + DO ih = 1, SIZE(HEADINGS) + CALL run_case(iclamp == 1, itd == 1, SPEEDS(iu), HEADINGS(ih)) + END DO + END DO + END DO + END DO + WRITE (*, '(A,I0,A,ES10.3,A,ES10.3,A)') 'on ', n_elem, ' elements, largest relative imbalance ', worst3, & + ' (3-point drag), ', worst10, ' (10-point drag)' + END DO + WRITE (*, '(A,I0)') 'cases solved: ', n_run + IF (nfail > 0) THEN + WRITE (*, '(A,I0,A)') 'FAIL: ', nfail, ' check(s) failed' + ERROR STOP 1 + END IF + WRITE (*, '(A)') 'PASS' + +CONTAINS + + SUBROUTINE run_case(clamp, touchdown, speed, heading) + LOGICAL, INTENT(IN) :: clamp, touchdown + REAL(wp), INTENT(IN) :: speed, heading + TYPE(CD_AGG_ModuleType) :: agg + CHARACTER(512) :: em + CHARACTER(64) :: path + CHARACTER(160) :: label + REAL(wp) :: fa(3), fb(3), weight(3), drag3(3), drag10(3), imb3(3), imb10(3), scale, rel3, rel10 + REAL(wp), ALLOCATABLE :: u_elem(:, :) + INTEGER :: es, e + + path = 'static_current_balance.dat' + CALL write_deck(path, clamp, touchdown, speed, heading) + WRITE (label, '(A,I0,A,L1,A,L1,A,F4.1,A,F6.1)') 'N=', n_elem, ' clamped=', clamp, ' touchdown=', touchdown, & + ' U=', speed, ' heading=', heading + CALL CD_AGG_Init_From_Deck(agg, TRIM(path), 0.05_wp, es, em, deck_current=.TRUE.) + IF (es /= CD_AGG_OK) THEN + IF (INDEX(em, 'no tension-only static equilibrium') > 0 .OR. INDEX(em, 'no physical equilibrium') > 0) THEN + WRITE (*, '(2A)') ' skip ', TRIM(label)//': no tension-only equilibrium in this current' + CALL CD_AGG_End(agg, es, em) + RETURN + END IF + CALL check(.FALSE., TRIM(label)//': init: '//TRIM(em)) + CALL CD_AGG_End(agg, es, em) + RETURN + END IF + ASSOCIATE (ln => agg%cables(1)%line) + CALL CD_HermiteCable_Dyn_End_Force(ln, 1, fa, es, em) + CALL check(es == CD_HCDYN_OK, TRIM(label)//': end force 1: '//TRIM(em)) + CALL CD_HermiteCable_Dyn_End_Force(ln, ln%nn, fb, es, em) + CALL check(es == CD_HCDYN_OK, TRIM(label)//': end force 2: '//TRIM(em)) + weight = [0.0_wp, 0.0_wp, -SUM(ln%w*ln%l0)] + ! the element fluid velocity: the model's uniform current and, with a held field, the mean + ! of the held field at the element's nodes (the deck current of the aggregate) + ALLOCATE (u_elem(3, ln%ne)) + DO e = 1, ln%ne + u_elem(:, e) = ln%current + IF (ln%has_held_field) u_elem(:, e) = u_elem(:, e) + 0.5_wp*(ln%hf_u(:, e) + ln%hf_u(:, e + 1)) + END DO + ! the deck's uniform current, in the cable's frame (turned about z) + CALL check(MAXVAL(ABS(NORM2(u_elem(1:2, :), DIM=1) - speed)) <= 1.0e-12_wp*speed .AND. & + MAXVAL(ABS(u_elem(3, :))) <= 1.0e-12_wp*speed, TRIM(label)//': the cable sees the deck current') + CALL line_drag(ln%q, ln%l0, ln%diam, ln%cdn, ln%cdt, ln%rho_w, u_elem, 3, drag3) + CALL line_drag(ln%q, ln%l0, ln%diam, ln%cdn, ln%cdt, ln%rho_w, u_elem, 10, drag10) + END ASSOCIATE + imb3 = -fa - fb + weight + drag3 + imb10 = -fa - fb + weight + drag10 + IF (touchdown) THEN + imb3(3) = 0.0_wp + imb10(3) = 0.0_wp + END IF + scale = MAX(NORM2(fa), NORM2(fb)) + rel3 = NORM2(imb3)/scale + rel10 = NORM2(imb10)/scale + worst3 = MAX(worst3, rel3) + worst10 = MAX(worst10, rel10) + n_run = n_run + 1 + WRITE (*, '(2A,ES10.3,A,ES10.3,A,ES10.3,A)') ' ', TRIM(label)//': end force ', scale, & + ' N, relative imbalance ', rel3, ' (3-point drag), ', rel10, ' (10-point)' + CALL check(rel3 <= TOL_SOLVER_RULE, TRIM(label)//': the end reactions balance the weight and the drag') + CALL check(rel10 <= tol10, TRIM(label)//': the drag quadrature is converged') + CALL CD_AGG_End(agg, es, em) + END SUBROUTINE run_case + + SUBROUTINE line_drag(q, l0, diam, cdn, cdt, rho, u, order, drag) + !! The documented Morison drag of the still line in the current u (per element), integrated + !! on the cubic-Hermite centreline of q ([r, m] per node) per deformed length by the + !! order-point Gauss rule (3 or 10). + REAL(wp), INTENT(IN) :: q(:), l0(:), diam(:), cdn(:), cdt(:), rho, u(:, :) + INTEGER, INTENT(IN) :: order + REAL(wp), INTENT(OUT) :: drag(3) + REAL(wp), PARAMETER :: X10(5) = [0.1488743389816312_wp, 0.4333953941292472_wp, 0.6794095682990244_wp, & + 0.8650633666889845_wp, 0.9739065285171717_wp] + REAL(wp), PARAMETER :: W10(5) = [0.2955242247147529_wp, 0.2692667193099963_wp, 0.2190863625159820_wp, & + 0.1494513491505806_wp, 0.0666713443086881_wp] + REAL(wp), PARAMETER :: X3 = 0.7745966692414834_wp + REAL(wp) :: xg(10), wg(10), xi, rp(3), sp, t(3), ut(3), un(3), dh(4) + INTEGER :: e, g, s, b, ng + IF (order == 3) THEN + ng = 3 + xg(1:3) = [0.5_wp*(1.0_wp - X3), 0.5_wp, 0.5_wp*(1.0_wp + X3)] + wg(1:3) = [5.0_wp, 8.0_wp, 5.0_wp]/18.0_wp + ELSE + ng = 10 + xg = [0.5_wp*(1.0_wp - X10(5:1:-1)), 0.5_wp*(1.0_wp + X10)] + wg = [0.5_wp*W10(5:1:-1), 0.5_wp*W10] + END IF + drag = 0.0_wp + DO e = 1, SIZE(l0) + b = 6*(e - 1) + DO g = 1, ng + xi = xg(g) + dh = [6.0_wp*xi*xi - 6.0_wp*xi, l0(e)*(3.0_wp*xi*xi - 4.0_wp*xi + 1.0_wp), -6.0_wp*xi*xi + 6.0_wp*xi, & + l0(e)*(3.0_wp*xi*xi - 2.0_wp*xi)] + rp = 0.0_wp + DO s = 1, 2 + rp = rp + dh(2*s - 1)*q(b + 6*(s - 1) + 1:b + 6*(s - 1) + 3) + dh(2*s)*q(b + 6*(s - 1) + 4:b + 6*(s - 1) + 6) + END DO + sp = NORM2(rp) + t = rp/sp + ut = DOT_PRODUCT(u(:, e), t)*t + un = u(:, e) - ut + drag = drag + wg(g)*sp*(0.5_wp*rho*diam(e)*cdn(e)*NORM2(un)*un + 0.5_wp*rho*PI*diam(e)*cdt(e)*NORM2(ut)*ut) + END DO + END DO + END SUBROUTINE line_drag + + SUBROUTINE write_deck(path, clamp, touchdown, speed, heading) + CHARACTER(*), INTENT(IN) :: path + LOGICAL, INTENT(IN) :: clamp, touchdown + REAL(wp), INTENT(IN) :: speed, heading + INTEGER :: u + REAL(wp) :: ux, uy + ux = speed*COS(heading*PI/180.0_wp) + uy = speed*SIN(heading*PI/180.0_wp) + OPEN (NEWUNIT=u, FILE=path, STATUS='REPLACE', ACTION='WRITE') + WRITE (u, '(A)') '--------------------- CableDyn Input File ------------------------------------' + WRITE (u, '(A)') 'static force balance of a cable in a current' + WRITE (u, '(A)') '--------------------- LINE TYPES ---------------------------------------------' + WRITE (u, '(A)') 'TypeName Diam MassDenInAir EA BA/-zeta EI Cd_n Cd_t Ca_n Ca_t' + WRITE (u, '(A)') '(-) (m) (kg/m) (N) (N-s/-) (N-m^2) (-) (-) (-) (-)' + WRITE (u, '(A)') 'cable 0.2 60.0 1.0e8 -1.0 1.0e4 1.2 0.008 1.0 0.0' + WRITE (u, '(A)') '--------------------- POINTS -------------------------------------------------' + WRITE (u, '(A)') 'ID Type X Y Z Mass Vol CdA Ca' + WRITE (u, '(A)') '(-) (-) (m) (m) (m) (kg) (m^3) (m^2) (-)' + WRITE (u, '(A)') '1 Coupled 0.0 0.0 -10.0 0 0 0 0' + IF (touchdown) THEN + WRITE (u, '(A)') '2 Fixed 150.0 0.0 -100.0 0 0 0 0' + ELSE + WRITE (u, '(A)') '2 Fixed 80.0 0.0 -80.0 0 0 0 0' + END IF + WRITE (u, '(A)') '--------------------- LINES --------------------------------------------------' + WRITE (u, '(A)') 'ID NodeA NodeB Outputs' + WRITE (u, '(A)') '(-) (-) (-) (-)' + WRITE (u, '(A)') '1 1 2 -' + WRITE (u, '(A)') '--------------------- SECTIONS -----------------------------------------------' + WRITE (u, '(A)') 'LineID LineType Length NumSegs' + WRITE (u, '(A)') '(-) (-) (m) (-)' + IF (touchdown) THEN + WRITE (u, '(A,I0)') '1 cable 190.0 ', n_elem + ELSE + WRITE (u, '(A,I0)') '1 cable 125.0 ', n_elem + END IF + IF (clamp) THEN + WRITE (u, '(A)') '--------------------- END CONNECTIONS ----------------------------------------' + WRITE (u, '(A)') 'LineID End Stiffness EzX EzY EzZ' + WRITE (u, '(A)') '(-) (-) (N-m/rad) (-) (-) (-)' + WRITE (u, '(A)') '1 A Rigid 0.0 0.0 -1.0' + END IF + WRITE (u, '(A)') '--------------------- OPTIONS ------------------------------------------------' + WRITE (u, '(A)') '9.80665 g' + WRITE (u, '(A)') '1025.0 rhoW' + IF (touchdown) WRITE (u, '(A)') '100.0 WtrDpth' + WRITE (u, '(A)') '0.05 dtM' + WRITE (u, '(A)') '0.0 TMax' + WRITE (u, '(A,2(1X,ES23.16),A)') 'uniform', ux, uy, ' 0.0 current' + WRITE (u, '(A)') '--------------------- OUTPUTS ------------------------------------------------' + WRITE (u, '(A)') 'END' + CLOSE (u) + END SUBROUTINE write_deck + + SUBROUTINE check(ok, label) + LOGICAL, INTENT(IN) :: ok + CHARACTER(*), INTENT(IN) :: label + IF (ok) RETURN + nfail = nfail + 1 + WRITE (*, '(2A)') ' FAIL ', TRIM(label) + END SUBROUTINE check + +END PROGRAM test_static_current_balance diff --git a/tests/test_static_friction_as_laid.py b/tests/test_static_friction_as_laid.py index 2235fa5..fe743bb 100644 --- a/tests/test_static_friction_as_laid.py +++ b/tests/test_static_friction_as_laid.py @@ -36,6 +36,13 @@ * ``att_cross`` (a finite-EI line with discrete buoyancy modules on a cross slope): the reference sinks each node under its discrete load, so the module nodes rest on the laid path (no lateral kink, as in the smeared twin) and a march from the static state stays at rest; +* ``shallow_cross`` (a 0.5 m finite-EI cable on a seabed 0.1 m deep, sloping across it): the + reference sinks each node under the load the solver applies there, the dry part of the section + that pierces the surface included, so the run rests on its laid path and a march from the + static state stays at rest; +* ``buoyant_shallow`` (a net-buoyant 0.5 m cable on a flat seabed 0.01 m deep, kBot = 1000): the + reference's dry load, whose full Newton step cycles between a wholly dry and a wholly wet node, + converges with halved steps, and a march from the static state stays at rest; * ``pl_*``: per-line rows win over the global ones (``layAzimuth`` over ``layAzimuth``, ``asLaidTension`` over ``asLaidTension``, in either order), and a row for line 2 leaves line 1 alone; ``aniso_*``: the axial coefficient rules the friction along a laid run; @@ -509,6 +516,73 @@ def kink(y: list) -> float: "att_cross: the module nodes rest on the laid path as closely as in the smeared twin (%.2e <= %.2e m)" % (kd, ks)) + # --- finite-EI cable laid on a shelf shallower than its radius, sloping across it --- + # A light 0.5 m cable rests on a seabed 0.1 m deep (5 degrees across the line): its section + # pierces the surface, and the static solve and the dynamics give back the buoyancy of the dry + # part, about 2.3 times its submerged weight. The friction reference sinks every node under + # that same normal load (the dry part at the node's sunk height), so the laid run rests on its + # laid path, y = 0. A reference sunk under the submerged weight alone would hold the run down + # the slope by the difference of the two sinks times tan(slope), about 0.8 mm, with a spurious + # friction force. Checked: the run's nodes stay within a tenth of that of the laid path + # (about 4e-6 m), and a march from the static state stays at rest. + cdir = root / "shallow_cross" + cdir.mkdir(parents=True, exist_ok=True) + rows = [] + for x in range(-300, 1000, 100): + for y in (-1.0, 1.0): + rows.append("%.1f %.1f %.12g" % (x, y, 0.1 + math.tan(math.radians(5.0)) * y)) + (cdir / "bathy.txt").write_text("\n".join(rows) + "\n", encoding="utf-8", newline="\n") + d_s, m_s = 0.5, 220.0 + w_s = (m_s - RHO * math.pi / 4.0 * d_s * d_s) * G + sc = dict(ei=1.0e4, length=100.0, nseg=50, fair=(100.0, 0.0, -0.095), anch=(0.0, 0.0, -0.1), seabed=sb, + types=f"chain {d_s} {m_s} 1.0e8 -1 1.0e4 1.2 0.008 1 0\n") + rc, log = run(a.exe, cdir, "shallow", deck("shallow cross", **sc)) + check(rc == 0, "shallow_cross converges (rc %d)%s" % (rc, "" if rc == 0 else ": " + log.strip()[-300:])) + if rc == 0: + p = first_row(cdir / "shallow.Line1.p.out") + # the run away from its two held ends, which rest at their deck positions above the + # sunk run and bend it within a few elements of them + y_run = [p[3 * i + 1] for i in range(5, len(p) // 3 - 5)] + # the sink of a node under its submerged weight and under its load with the dry part + kn_l = 1.0e5 * d_s + dry_load = w_s + for _ in range(100): + u = max(-1.0, min(1.0, (-0.1 - dry_load / kn_l) / (0.5 * d_s))) + phi = 1.0 - (math.acos(u) - u * math.sqrt(1.0 - u * u)) / math.pi + dry_load = w_s + phi * RHO * G * math.pi / 4.0 * d_s * d_s + spurious = (dry_load - w_s) / kn_l * math.tan(math.radians(5.0)) + dev = max(abs(y) for y in y_run) + check(dev < 0.1 * spurious, + "shallow_cross: the run rests on its laid path (%.2e m off it, a reference without the dry part " + "would hold it %.2e m off)" % (dev, spurious)) + rc, _ = run(a.exe, cdir, "shallow_rest", deck("shallow cross rest", **sc, tmax=1.0)) + check(rc == 0, "shallow_cross at-rest march runs (rc %d)" % rc) + if rc == 0: + drift = at_rest(cdir, "shallow", "shallow_rest") + check(drift <= REST_TOL, "shallow_cross stays at rest from the static state (drift %.2e)" % drift) + + # --- net-buoyant cable on a flat seabed shallower than its radius, soft seabed --- + # A 0.5 m cable of 180 kg/m floats (its displaced water is 201 kg/m), held on a seabed 0.01 m + # deep with kBot = 1000. A node held off the floor is wholly dry and one sunk under the dry + # load wholly wet, so the full Newton step of the reference's dry load jumps between about + # -208 and 728 N/m; the root (70.3 N/m, a sink of 0.14 m) lies between. Checked: the + # halved steps reach it, the static solve converges, and a march from it stays at rest. + cdir = root / "buoyant_shallow" + cdir.mkdir(parents=True, exist_ok=True) + rows = ["%.1f %.1f 0.01" % (x, y) for x in range(-300, 1000, 100) for y in (-300.0, 300.0)] + (cdir / "bathy.txt").write_text("\n".join(rows) + "\n", encoding="utf-8", newline="\n") + bs = dict(ei=1.0e4, length=100.0, nseg=50, fair=(100.0, 0.0, -0.005), anch=(0.0, 0.0, -0.01), seabed=sb, + types="chain 0.5 180.0 1.0e8 -1 1.0e4 1.2 0.008 1 0\n") + rc, log = run(a.exe, cdir, "buoyant", deck("buoyant shallow", **bs).replace("100000.0 kBot", "1000.0 kBot")) + check(rc == 0, "buoyant_shallow converges (rc %d)%s" % (rc, "" if rc == 0 else ": " + log.strip()[-300:])) + if rc == 0: + rc, _ = run(a.exe, cdir, "buoyant_rest", + deck("buoyant shallow rest", **bs, tmax=1.0).replace("100000.0 kBot", "1000.0 kBot")) + check(rc == 0, "buoyant_shallow at-rest march runs (rc %d)" % rc) + if rc == 0: + drift = at_rest(cdir, "buoyant", "buoyant_rest") + check(drift <= REST_TOL, "buoyant_shallow stays at rest from the static state (drift %.2e)" % drift) + # --- per-line rows, non-default azimuth and tension, anisotropic friction (EI = 0) --- cdir = root / "perline" keys = ("FairTen1", "AnchTen1", "Point1Fx", "Point2Fx", "Point1Fz") From ecde21a4b886d47be01a47c7e384cba0548857d5 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 04:36:19 +0900 Subject: [PATCH 18/40] fix(python): refuse a channel token containing a NUL (#126) Signed-off-by: Jae Hoon Seo --- python/cabledyn/objects.py | 3 +++ python/tests/test_objects.py | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/python/cabledyn/objects.py b/python/cabledyn/objects.py index 12a8c0b..083e25b 100644 --- a/python/cabledyn/objects.py +++ b/python/cabledyn/objects.py @@ -494,6 +494,9 @@ def eval_channel(model: CableDyn, token: str) -> float: """Evaluate one OUTPUTS token (the caller holds the model lock).""" if not isinstance(token, str): raise TypeError(f"channel token must be a string; got {type(token).__name__}") + if "\0" in token: + # the library ends a token at its first NUL, so "FairTen1\0x" would read as FairTen1 + raise ValueError(f"channel token must not contain a NUL character; got {token!r}") try: raw = token.strip().encode("ascii") except UnicodeEncodeError: diff --git a/python/tests/test_objects.py b/python/tests/test_objects.py index 6486fc4..99de88b 100644 --- a/python/tests/test_objects.py +++ b/python/tests/test_objects.py @@ -562,6 +562,16 @@ def test_channel_tokens_must_be_ascii(tmp_path): model.channel("FairTen\u00b91") +@pytest.mark.parametrize("token", ["FairTen1\x00", "FairTen1\x00x", "\x00FairTen1"]) +def test_channel_tokens_must_not_hold_a_nul(tmp_path, token): + # the library reads a token up to its first NUL: "FairTen1\0x" would read as FairTen1 + with CableDyn(_held_chain(tmp_path)) as model: + with pytest.raises(ValueError, match="NUL"): + model.channel(token) + with pytest.raises(ValueError, match="NUL"): + model.channels(["FairTen1", token]) + + def test_zero_length_rod_axis_is_a_value_error(tmp_path, monkeypatch): deck = _write(tmp_path, "rod.dat", _ROD_DECK) with CableDyn(deck) as model: From ec864735b433e11cd875d67174c68f230fff5357 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 05:03:44 +0900 Subject: [PATCH 19/40] fix(statics): note a Point3 buoy that starts off balance (#134) Signed-off-by: Jae Hoon Seo --- CMakeLists.txt | 10 +++ doc/driver_format.md | 5 +- doc/theory.rst | 4 +- src/CableDyn_DeckDriver.f90 | 82 ++++++++++++++++++++++- tests/test_point3_balance_note.py | 107 ++++++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 tests/test_point3_balance_note.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 918a8f6..894026b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1867,6 +1867,16 @@ if(Python3_Interpreter_FOUND) TIMEOUT 300 ENVIRONMENT "${_cabledyn_python_test_environment}") + # A Point3 buoy that starts off balance under bodyIC static is noted with its unbalanced force. + add_test(NAME point3_balance_note + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_point3_balance_note.py + --exe $ + --output ${CMAKE_BINARY_DIR}/point3_balance_note) + set_tests_properties(point3_balance_note PROPERTIES + LABELS "python;bodies;static" + TIMEOUT 300 + ENVIRONMENT "${_cabledyn_python_test_environment}") + # The note on grounded lines that a current moves over a seabed without friction: once per run # on every standalone route, and never without a grounded node, a current, or a march, or # with friction. diff --git a/doc/driver_format.md b/doc/driver_format.md index cc093f0..e9a7e67 100644 --- a/doc/driver_format.md +++ b/doc/driver_format.md @@ -527,7 +527,10 @@ A line's End A attaches to a body through a `Body` point; the point's positi fairlead offset in the body frame. - A `Point3` body supports exactly one attachment point, which becomes a dynamic point with the - body's `Mass`/`Vol`/`CdA`/`Ca`. + body's `Mass`/`Vol`/`CdA`/`Ca`. The static solve does not place it: it starts at its deck + position. Under `bodyIC static` (the default) a run whose buoy is not balanced there notes the + unbalanced force and the distance to its static balance as a `Free` point; give the deck + position of that balance, or set `bodyIC deck` to start it off balance on purpose. - A `Rigid6` body transfers structural force and moment through rigid attachment kinematics, plus lumped translational current and wave hydrodynamics from `CdA`/`Ca`. Rotational body hydrodynamics are not modelled. diff --git a/doc/theory.rst b/doc/theory.rst index 42fddbf..7f75983 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -1633,7 +1633,9 @@ hang it from the seabed. The contact acts along the surface normal of a sloped o bathymetry, in the point static equilibrium and in the monolithic and staggered point steps alike (in the staggered step its stiffness and damping are linearly implicit, like the end blocks of the attached lines), so a grounded point starts at rest. A ``Point3`` buoy is not -placed by the static solve (it starts at its deck position) but carries the same contact in +placed by the static solve (it starts at its deck position; under ``bodyIC static`` the run notes +the force its line, weight, buoyancy, drag and contact leave on it there and the distance to its +balance as a ``Free`` point) but carries the same contact in every march, the monolithic and staggered steps and the coupled point step of the OpenFAST aggregate and the C API. The contact is frictionless: the point routes reject ``frictionMu``. A point clear of the seabed sees exactly no contact load, and its results are unchanged. diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index 76d4079..6d9bd28 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -12299,7 +12299,10 @@ SUBROUTINE solve_point_equilibrium(types, points, lines, sections, opts, ErrStat DO i = 1, SIZE(points) IF (point_moves(i)) np = np + 1 END DO - IF (np == 0) RETURN + IF (np == 0) THEN + CALL point3_balance_note() + RETURN + END IF ALLOCATE (pidx(np)) np = 0 DO i = 1, SIZE(points) @@ -12327,9 +12330,74 @@ SUBROUTINE solve_point_equilibrium(types, points, lines, sections, opts, ErrStat END DO CALL solve_body_equilibrium(types, no_bodies, no_rod_types, no_rods, points, lines, sections, opts, ErrStat, & ErrMsg, bathymetry, points_only=.TRUE.) + IF (ErrStat == CD_DECKDRV_OK) CALL point3_balance_note() CONTAINS + SUBROUTINE point3_balance_note() + !! A Point3 buoy is not placed by the static solve: it starts at its deck position. Under + !! bodyIC static, where every other free object starts balanced, note the force its line, + !! weight, buoyancy, current drag and seabed contact leave on it there, and how far its + !! static equilibrium (the balance of the buoy as a Free point, with the Free/Connect + !! points held where the static solve put them) lies from it. + TYPE(DeckPoint), ALLOCATABLE :: pc(:) + REAL(wp), ALLOCATABLE :: f0(:) + REAL(wp) :: w_net + INTEGER :: ip, es_n + LOGICAL :: any3, skp + CHARACTER(1024) :: em_n + CHARACTER(32) :: fbuf, dbuf + IF (.NOT. opts%body_ic_static) RETURN + any3 = .FALSE. + DO ip = 1, SIZE(points) + IF (points(ip)%point3 .AND. point_has_line(ip)) any3 = .TRUE. + END DO + IF (.NOT. any3) RETURN + pc = points + DO ip = 1, SIZE(pc) + IF (pc(ip)%point3 .AND. point_has_line(ip)) THEN + pc(ip)%ptype = 'free' + ELSE IF (TRIM(pc(ip)%ptype) == 'free' .OR. TRIM(pc(ip)%ptype) == 'connect') THEN + pc(ip)%ptype = 'fixed' + END IF + END DO + ALLOCATE (f0(SIZE(pc))) + f0 = CD_ZERO + skp = .FALSE. + CALL solve_body_equilibrium_core(types, no_bodies, no_rod_types, no_rods, pc, lines, sections, opts, es_n, & + em_n, bathymetry, points_only=.TRUE., quiet=.TRUE., skipped=skp, & + point_force_start=f0) + IF (skp) RETURN + DO ip = 1, SIZE(points) + IF (.NOT. (points(ip)%point3 .AND. point_has_line(ip))) CYCLE + w_net = ABS(opts%rhoW*points(ip)%vol - points(ip)%mass)*opts%g + IF (.NOT. (f0(ip) > 1.0e-4_wp*w_net + CD_ONE)) CYCLE + WRITE (fbuf, '(ES10.3)') f0(ip) + IF (es_n == CD_DECKDRV_OK) THEN + WRITE (dbuf, '(ES10.3)') NORM2(pc(ip)%pos - points(ip)%pos) + CALL print_note(output_unit, ' ', 'BODY '//TRIM(int_to_str(points(ip)%body_id))//' (Point3) starts '// & + 'at its deck position, which the static solve does not change, with an unbalanced '// & + 'force of '//TRIM(ADJUSTL(fbuf))//' N there; its static equilibrium lies '// & + TRIM(ADJUSTL(dbuf))//' m away. Give the deck position of its equilibrium, or set '// & + 'OPTION bodyIC deck to start it from its deck position on purpose') + ELSE + CALL print_note(output_unit, ' ', 'BODY '//TRIM(int_to_str(points(ip)%body_id))//' (Point3) starts '// & + 'at its deck position, which the static solve does not change, with an unbalanced '// & + 'force of '//TRIM(ADJUSTL(fbuf))//' N there. Give the deck position of its '// & + 'equilibrium, or set OPTION bodyIC deck to start it from its deck position on purpose') + END IF + END DO + END SUBROUTINE point3_balance_note + + LOGICAL FUNCTION point_has_line(ip) RESULT(has) + INTEGER, INTENT(IN) :: ip + INTEGER :: jl + has = .FALSE. + DO jl = 1, SIZE(lines) + IF (lines(jl)%nodeA == points(ip)%id .OR. lines(jl)%nodeB == points(ip)%id) has = .TRUE. + END DO + END FUNCTION point_has_line + LOGICAL FUNCTION point_moves(ip) RESULT(mv) INTEGER, INTENT(IN) :: ip INTEGER :: jl @@ -27294,7 +27362,8 @@ END SUBROUTINE rigid6_rod_loads SUBROUTINE solve_body_equilibrium_core(types, bodies, rod_types, rods, points, lines, sections, opts, ErrStat, & ErrMsg, bathymetry, points_only, jac_check_err, ext_pt, ext_fm, & - end_force_a, quiet, skipped, ext_km, ext_rot, end_force_b, res_left) + end_force_a, quiet, skipped, ext_km, ext_rot, end_force_b, res_left, & + point_force_start) !! Static equilibrium of the free objects of a deck: free Rigid6 bodies (six DOFs: position !! and a rotation-vector increment on SO(3); a CoupledPinned body holds its position at its !! host node and balances its moment about it), free rods (position and the two rotations @@ -27381,6 +27450,9 @@ SUBROUTINE solve_body_equilibrium_core(types, bodies, rod_types, rods, points, l ! [N m] of an object at the returned poses (zero when nothing was balanced): the resolution of ! the balance, which the passes of solve_body_equilibrium cannot refine REAL(wp), INTENT(OUT), OPTIONAL :: res_left + ! point_force_start: the magnitude of the net force on every moving point at its starting + ! position (per entry of points; zero for a point that does not move) + REAL(wp), INTENT(OUT), OPTIONAL :: point_force_start(:) TYPE :: LineStaticCache !! A moving line's mesh and loads (fixed) and its last converged shape (the warm seed). @@ -27429,6 +27501,7 @@ SUBROUTINE solve_body_equilibrium_core(types, bodies, rod_types, rods, points, l IF (PRESENT(quiet)) silent = quiet IF (PRESENT(skipped)) skipped = .FALSE. IF (PRESENT(res_left)) res_left = CD_ZERO + IF (PRESENT(point_force_start)) point_force_start = CD_ZERO IF (.NOT. (only_points .OR. opts%body_ic_static)) RETURN ! the objects that move: free Rigid6 bodies and free rods with a line, then Free/Connect points ALLOCATE (okind(SIZE(bodies) + SIZE(rods) + SIZE(points)), idx(SIZE(bodies) + SIZE(rods) + SIZE(points))) @@ -27616,6 +27689,11 @@ SUBROUTINE solve_body_equilibrium_core(types, bodies, rod_types, rods, points, l DO CALL residual(x, r, sc, ok, .TRUE.) IF (.NOT. ok) RETURN + IF (phase == 1 .AND. PRESENT(point_force_start)) THEN + DO k = 1, nobj + IF (okind(k) == OBJ_POINT) point_force_start(idx(k)) = NORM2(r(off(k) + 1:off(k) + 3)) + END DO + END IF rnorm = NORM2(r) mu = 1.0e-8_wp trust = -CD_ONE diff --git a/tests/test_point3_balance_note.py b/tests/test_point3_balance_note.py new file mode 100644 index 0000000..e69bca0 --- /dev/null +++ b/tests/test_point3_balance_note.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""A Point3 buoy that starts off balance under bodyIC static is noted with its unbalanced force. + +The static solve does not place a Point3 buoy: it starts at its deck position. A 112 t buoy of +259 m^3 on one slack wire leg, 11 m below the surface, starts with about 1.5 MN of net buoyancy +unbalanced. Under the default bodyIC static the run must note it, with the force equal to the +line force on the buoy (Point1F at t = 0) plus its net buoyancy (rhoW Vol - Mass) g to 1e-3, and +with bodyIC deck (an off-balance start on purpose) it must not. + +usage: test_point3_balance_note.py --exe --output

+""" + +from __future__ import annotations + +import argparse +import math +import os +import re +import subprocess +import sys +from pathlib import Path + +DECK = """--------------------- CableDyn Input File ------------------------------------ +Point3 buoy on one wire leg, off balance at its deck position +--------------------- LINE TYPES --------------------------------------- +TypeName Diam Mass EA BA EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (N-s) (N-m2) (-) (-) (-) (-) +leg 0.0631 19.93 3.588e8 -0.5 0.0 1.2 0.008 1.0 0.0 +--------------------- BODIES ------------------------------------------- +ID Type X Y Z Roll Pitch Yaw Mass Vol C33 C44 C55 CdA Ca Ixx Iyy Izz +(-) (-) (m) (m) (m) (deg) (deg) (deg) (kg) (m3) (N/m) (Nm/rad) (Nm/rad) (m2) (-) (kgm2) (kgm2) (kgm2) +1 Point3 27.92 -29.39 -11.07 0.0 0.0 0.0 111760.0 258.6 0.0 0.0 0.0 5.56 0.5 683510.0 683510.0 683510.0 +--------------------- POINTS ------------------------------------------- +ID Type X Y Z +(-) (-) (m) (m) (m) +1 Body1 0.0 0.0 0.0 +2 Fixed 166.94 -51.95 -56.85 +--------------------- LINES -------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +1 1 2 - +--------------------- SECTIONS ----------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +1 leg 178.39 23 +--------------------- OPTIONS ------------------------------------------ +56.85 WtrDpth +1.8e5 kBot +0.01 dtM +0.02 TMax +{ic} bodyIC +--------------------- OUTPUTS ------------------------------------------ +Point1Fx +Point1Fy +Point1Fz +--------------------- need this line ----------------------------------- +""" + +NOTE = re.compile(r"BODY 1 \(Point3\) starts at its deck position, which the static solve does not change, " + r"with an unbalanced force of\s+(\S+) N there") +G, RHO = 9.80665, 1025.0 + + +def run(exe: str, out: Path, ic: str) -> tuple: + deck = out / ("buoy_%s.dat" % ic) + deck.write_text(DECK.format(ic=ic), encoding="utf-8", newline="\n") + root = out / ("buoy_%s" % ic) + env = dict(os.environ) + env.setdefault("OPENBLAS_NUM_THREADS", "1") + p = subprocess.run([exe, str(deck), str(root)], capture_output=True, text=True, env=env, cwd=str(out)) + if p.returncode != 0: + raise RuntimeError("bodyIC %s run failed: %s" % (ic, (p.stdout + p.stderr)[-400:])) + rows = Path(str(root) + ".out").read_text(encoding="utf-8").splitlines() + return [float(v) for v in rows[2].split()[1:4]], p.stdout + p.stderr + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--exe", required=True) + ap.add_argument("--output", required=True) + a = ap.parse_args() + out = Path(a.output) + out.mkdir(parents=True, exist_ok=True) + failures = [] + f, log = run(a.exe, out, "static") + m = NOTE.search(log) + if not m: + failures.append("bodyIC static: no note of the unbalanced Point3 buoy") + else: + net = (f[0], f[1], f[2] + (RHO * 258.6 - 111760.0) * G) + expect = math.sqrt(sum(c * c for c in net)) + got = float(m.group(1)) + if abs(got - expect) > 1.0e-3 * expect: + failures.append("bodyIC static: noted force %.4e N, expected %.4e N" % (got, expect)) + _, log = run(a.exe, out, "deck") + if NOTE.search(log): + failures.append("bodyIC deck: the off-balance start is noted") + print("Point3 balance note: %d failures" % len(failures)) + for x in failures: + print("FAIL:", x) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 9c79ae475c7b6f4ae31522c96e146aefbd953813 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 05:23:25 +0900 Subject: [PATCH 20/40] fix(modal): note a mode with omega^2 < 0 and document its signed frequency (#132) Signed-off-by: Jae Hoon Seo --- CMakeLists.txt | 8 +++ doc/outputs.rst | 6 +- doc/theory.rst | 4 +- src/CableDyn_DeckDriver.f90 | 13 +++- tests/test_modal_negative_note.py | 104 ++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 tests/test_modal_negative_note.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 894026b..499f039 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3174,6 +3174,14 @@ if(Python3_Interpreter_FOUND) --source ${CMAKE_CURRENT_SOURCE_DIR} --output ${CMAKE_CURRENT_BINARY_DIR}/modal_torsion_deck) set_tests_properties(modal_torsion_deck PROPERTIES LABELS "python;driver;integration;validation") + # A modal analysis about an unstable static state writes its negative omega^2 as a negative + # frequency of period 0 and notes it; the stable state of a finer mesh does neither. + add_test( + NAME modal_negative_note + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_modal_negative_note.py + --exe $ + --output ${CMAKE_CURRENT_BINARY_DIR}/modal_negative_note) + set_tests_properties(modal_negative_note PROPERTIES LABELS "python;driver;modal" TIMEOUT 300) # Static-initialization robustness matrix: a deterministic sample of EI=0 mooring regimes # (catenary, semi-taut, taut, suspended, composite, slack/vertical, anchors off the seabed, # sloped seabed, fine mesh, soft seabed, current, one/two segments, Free/Connect points) diff --git a/doc/outputs.rst b/doc/outputs.rst index ac7e6fb..1fd0325 100644 --- a/doc/outputs.rst +++ b/doc/outputs.rst @@ -459,7 +459,11 @@ holds two tab-separated tables, each introduced by a ``#`` comment line, a heade row: * ``# Natural frequencies``: ``LineID Mode Frequency Period Omega`` (Hz, s, rad/s), the - ``nModes`` lowest modes of every line in ascending order; + ``nModes`` lowest modes of every line in ascending order of :math:`\omega^2`. A mode with + :math:`\omega^2 < 0`, an instability of the static state (a planar equilibrium that is + unstable out of its plane, a compressed strut), is written with the signed values + ``Omega`` :math:`= -\sqrt{|\omega^2|}` and ``Frequency`` :math:`= -\sqrt{|\omega^2|}/2\pi` and + ``Period`` 0, and the run prints a note naming the line and the number of such modes; * ``# Mode shapes``: ``LineID Mode Node X Y Z dX dY dZ``, one row per mode and node. ``X Y Z`` is the static node position and ``dX dY dZ`` the nodal translation of the mode, scaled so that the largest nodal displacement of the mode is 1. Nodes are numbered End A first, diff --git a/doc/theory.rst b/doc/theory.rst index 7f75983..5600cdf 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -2075,7 +2075,9 @@ positive; where two components are equally large to round-off (the mirrored node symmetric line), the first of them in the solver's node order is. Memory grows linearly with the line length. The frequencies :math:`f = \omega/2\pi` and the nodal translations of each mode (scaled to a unit largest -nodal displacement) are written to ``.modes.out``. For a taut string of tension +nodal displacement) are written to ``.modes.out``; an eigenvalue :math:`\omega^2 < 0`, an +instability of the static state, is written as the negative frequency +:math:`-\sqrt{|\omega^2|}/2\pi` and noted. For a taut string of tension :math:`T` the transverse frequencies are :math:`f_n = (n/2L)\sqrt{T/m}`; for a shallow sagging cable the out-of-plane and antisymmetric in-plane modes follow the string with the horizontal tension :math:`H`, and the symmetric in-plane modes satisfy diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index 6d9bd28..e6a9e16 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -33439,7 +33439,7 @@ SUBROUTINE write_modes_file(out_root, lines, res, ErrStat, ErrMsg) TYPE(ModalLineResult), INTENT(IN) :: res(:) INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg - INTEGER :: unit, ios, li, part, nn, k, dpn, src, dst + INTEGER :: unit, ios, li, part, nn, k, dpn, src, dst, n_neg REAL(wp), ALLOCATABLE :: q_a(:), shapes_a(:, :), polar(:) CHARACTER(1) :: tb LOGICAL :: twist @@ -33500,6 +33500,17 @@ SUBROUTINE write_modes_file(out_root, lines, res, ErrStat, ErrMsg) END DO END DO CLOSE (unit) + ! A mode with omega^2 < 0 is an instability of the static state: it is written with a + ! negative frequency, and the run says so. + DO li = 1, SIZE(lines) + n_neg = 0 + IF (ALLOCATED(res(li)%omega2) .AND. res(li)%n_found > 0) & + n_neg = COUNT(res(li)%omega2(1:res(li)%n_found) < CD_ZERO) + IF (n_neg > 0) CALL print_note(output_unit, ' ', 'line '//TRIM(int_to_str(lines(li)%id))//': '// & + TRIM(int_to_str(n_neg))//' of its modes have omega^2 < 0, so its static state '// & + 'is unstable in them; '//TRIM(out_root)//'.modes.out gives each a negative '// & + 'frequency, -sqrt(|omega^2|)/(2 pi), and a period of 0') + END DO END SUBROUTINE write_modes_file SUBROUTINE resolve_deck_friction(opts, ErrStat, ErrMsg) diff --git a/tests/test_modal_negative_note.py b/tests/test_modal_negative_note.py new file mode 100644 index 0000000..ec11e45 --- /dev/null +++ b/tests/test_modal_negative_note.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""A modal analysis about an unstable static state reports its negative omega^2. + +A short finite-EI cable on a coarse mesh (27 elements) is solved in its vertical plane, where +it is unstable out of the plane: its lowest eigenvalue omega^2 is negative. modes.out must +write that mode with a negative frequency and omega and a period of 0, and the run must print +a note naming the line. On a mesh refined 4x the planar state is stable: every frequency is +positive and the note is absent. + +usage: test_modal_negative_note.py --exe --output +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +DECK = """--------------------- CableDyn Input File ------------------------------------ +Finite-EI cable whose planar static state is unstable out of its plane on a coarse mesh +--------------------- LINE TYPES --------------------------------------- +TypeName Diam Mass EA BA EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (N-s) (N-m2) (-) (-) (-) (-) +cab 0.167 45.5 5.72e8 -0.5 9250.0 1.2 0.01 1.0 0.0 +--------------------- POINTS ------------------------------------------- +ID Type X Y Z +(-) (-) (m) (m) (m) +1 Coupled 0.0 0.0 -0.24 +2 Fixed 77.65 0.0 -21.06 +--------------------- LINES -------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +1 1 2 - +--------------------- SECTIONS ----------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +1 cab 90.56 {n} +--------------------- OPTIONS ------------------------------------------ +21.06 WtrDpth +1.5e5 kBot +0.01 dtM +0.0 TMax +4 nModes +--------------------- OUTPUTS ------------------------------------------ +FairTen1 +--------------------- need this line ----------------------------------- +""" + +NOTE = "of its modes have omega^2 < 0" + + +def run(exe: str, out: Path, n: int) -> tuple: + deck = out / ("cable_%d.dat" % n) + deck.write_text(DECK.format(n=n), encoding="utf-8", newline="\n") + root = out / ("cable_%d" % n) + env = dict(os.environ) + env.setdefault("OPENBLAS_NUM_THREADS", "1") + p = subprocess.run([exe, str(deck), str(root)], capture_output=True, text=True, env=env, cwd=str(out)) + if p.returncode != 0: + raise RuntimeError("run with %d elements failed: %s" % (n, (p.stdout + p.stderr)[-400:])) + rows = [] + for ln in Path(str(root) + ".modes.out").read_text(encoding="utf-8").splitlines(): + if ln.startswith("# Mode shapes"): + break + t = ln.split() + if len(t) == 5 and t[0].isdigit(): + rows.append([float(v) for v in t[2:]]) + return rows, p.stdout + p.stderr + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--exe", required=True) + ap.add_argument("--output", required=True) + a = ap.parse_args() + out = Path(a.output) + out.mkdir(parents=True, exist_ok=True) + failures = [] + rows, log = run(a.exe, out, 27) + neg = [r for r in rows if r[0] < 0.0] + if not neg: + failures.append("coarse mesh: no negative frequency (%s)" % rows) + for f, per, om in neg: + if per != 0.0 or om >= 0.0: + failures.append("coarse mesh: negative mode with period %g, omega %g" % (per, om)) + if "line 1: %d %s" % (len(neg), NOTE) not in log: + failures.append("coarse mesh: the run does not note the %d negative mode(s)" % len(neg)) + rows, log = run(a.exe, out, 108) + if any(r[0] <= 0.0 for r in rows): + failures.append("refined mesh: a non-positive frequency (%s)" % rows) + if NOTE in log: + failures.append("refined mesh: the run notes a negative mode") + print("modal negative-mode note: %d failures" % len(failures)) + for f in failures: + print("FAIL:", f) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 45693e76107dccfebdb2b94947d87df0ef53ddc9 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 06:44:42 +0900 Subject: [PATCH 21/40] fix(deck): overridden waves and current rows keep no stale values; validator checks the effective row (#128) Signed-off-by: Jae Hoon Seo --- python/cabledyn/deck_file.py | 31 ++++++++++++++--- python/tests/test_deck_native_parity.py | 44 +++++++++++++++++++++++++ src/CableDyn_DeckDriver.f90 | 4 +++ tests/test_deck_driver.f90 | 36 ++++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/python/cabledyn/deck_file.py b/python/cabledyn/deck_file.py index 221ffa6..322be45 100644 --- a/python/cabledyn/deck_file.py +++ b/python/cabledyn/deck_file.py @@ -1020,8 +1020,15 @@ def _positional_numbers_are_finite(tokens: list[str]) -> bool: return False -def _positional_range_error(tokens: list[str]) -> str | None: - """Return a native-finalizer range error for a parsed positional option.""" +def _positional_range_error(tokens: list[str], *, effective: bool = True) -> str | None: + """Return a native range error for a parsed positional option. + + The native reader checks some rules on every row as it reads it (positive wave + height and period, distinct profile depths, every spectral and wavetrain row) and + the magnitude bounds of a regular or JONSWAP ``waves`` row and of a ``current`` row + only on the row in effect once OPTIONS is read: the last one. ``effective=False`` + returns the per-row rules alone, for a row a later row may override. + """ mode = tokens[0].lower() if mode == "profile": # The native two-point parser accepts either input direction and @@ -1030,7 +1037,7 @@ def _positional_range_error(tokens: list[str]) -> str | None: if abs(_native_float(tokens[5]) - _native_float(tokens[1])) <= 100.0 * math.ulp(1.0): return "current profile depths must be strictly increasing" values = [_native_float(token) for token in tokens[1:9]] - if ( + if effective and ( abs(values[0]) > _NATIVE_MAX_COORDINATE or abs(values[4]) > _NATIVE_MAX_COORDINATE or any(abs(value) > _NATIVE_MAX_FLUID_SPEED for value in values[1:4] + values[5:8]) @@ -1040,7 +1047,9 @@ def _positional_range_error(tokens: list[str]) -> str | None: "in magnitude" ) elif mode == "uniform": - if any(abs(_native_float(token)) > _NATIVE_MAX_FLUID_SPEED for token in tokens[1:4]): + if effective and any( + abs(_native_float(token)) > _NATIVE_MAX_FLUID_SPEED for token in tokens[1:4] + ): return "OPTION current velocity components must be at most 1e3 m/s" elif ( mode in {"airy", "jonswap", "stream", "dean"} @@ -1051,6 +1060,8 @@ def _positional_range_error(tokens: list[str]) -> str | None: gamma = _native_float(tokens[3]) if mode == "jonswap" else 1.0 if height <= 0.0 or period <= 0.0 or gamma < 1.0: return "waves require positive height/period and JONSWAP gamma >= 1" + if not effective: + return None low, high = _NATIVE_WAVE_PERIOD_RANGE if height > _NATIVE_MAX_WAVE_HEIGHT or not low <= period <= high: return "waves need height <= 1e3 m and period in [0.1, 1e5] s" @@ -3264,7 +3275,8 @@ def _option_row(self, index: int) -> OptionRecord | None: raise DeckFormatError( f"{where}: positional option values must be finite plain numbers" ) - if error := _positional_range_error(tokens): + # the magnitude bounds apply to the row in effect only (``_validate``) + if error := _positional_range_error(tokens, effective=False): raise DeckFormatError(f"{where}: {error}") _raise_option_defect(where, tokens) return OptionRecord(index, tokens[-1], tuple(tokens[:-1]), description) @@ -3389,6 +3401,15 @@ def _validate(self) -> None: options = self.options channels = self.outputs option_by_key = {_option_key(option.keyword): option for option in options} + # native finalize_checks: the magnitude bounds of the waves and current rows in + # effect (the last of each); a row a later row overrides takes its row rules only + for overridden_kind in ("waves", "current"): + record = option_by_key.get(overridden_kind) + if record is None or record.keyword_first: + continue + tokens = [*record.values, record.keyword] + if _is_positional_option(tokens) and (error := _positional_range_error(tokens)): + raise DeckFormatError(f"{self._label}:{record.index + 1}: {error}") has_depth = "water_depth" in option_by_key has_bathymetry = "bathymetry" in option_by_key if has_depth and has_bathymetry: diff --git a/python/tests/test_deck_native_parity.py b/python/tests/test_deck_native_parity.py index df4b136..7d1ee33 100644 --- a/python/tests/test_deck_native_parity.py +++ b/python/tests/test_deck_native_parity.py @@ -584,12 +584,41 @@ def _carries_waves(name: str) -> bool: if _carries_waves(name) for variant in ("spelled", "upper") ] +_PROFILE_ROW = "profile -30 0.2 0 0 0 0.5 0 0 current" +# (rows, message): the last waves or current row is the one in effect; the native reader +# checks the magnitude bounds on that row alone and the row rules on every row +_OVERRIDDEN_ROW_CASES = [ + (("airy 2000 8 0 waves", "none waves"), None), + (("airy 2 0.01 0 waves", "airy 2 8 0 waves"), None), + (("stream 2 2e5 0 waves", "pm 3 10 0 waves"), None), + (("jonswap 3 10 40 0 waves", "airy 2 8 0 waves"), None), + (("jonswap 3 10 2000 0 waves", "airy 2 8 0 waves"), None), + (("jonswap 3 10 2000 0 waves", "stream 2 8 0 waves"), None), + (("jonswap 3000 10 3.3 0 waves", "none waves"), None), + (("uniform 2000 0 0 current", "none current"), None), + (("uniform 2000 0 0 current", _PROFILE_ROW), None), + (("profile -2e6 0.2 0 0 0 0.5 0 0 current", "uniform 0.1 0 0 current"), None), + (("profile -30 2000 0 0 0 0.5 0 0 current", _PROFILE_ROW), None), + (("airy 2 8 0 waves", "airy 2000 8 0 waves"), r"waves need height <= 1e3 m"), + (("none waves", "jonswap 3 10 40 0 waves"), r"JONSWAP gamma must be in \[1, 32\.6\)"), + (("airy 2 8 0 waves", "jonswap 3 10 2000 0 waves"), r"JONSWAP gamma must be in \[1, 32\.6\)"), + ((_PROFILE_ROW, "uniform 2000 0 0 current"), r"at most 1e3 m/s"), + (("uniform 0.1 0 0 current", "profile -2e6 0.2 0 0 0 0.5 0 0 current"), r"at most 1e6 m"), + # the row rules hold on an overridden row too + (("airy 0 8 0 waves", "none waves"), r"positive height/period"), + (("jonswap 3 10 0.5 0 waves", "airy 2 8 0 waves"), r"JONSWAP gamma >= 1"), + (("profile -30 0.2 0 0 -30 0.5 0 0 current", "none current"), r"strictly increasing"), + (("pm 3000 10 0 waves", "none waves"), r"a wave train needs height <= 1e3 m"), +] + + _ALL_FILE_CASES = [ *[("held", rows, files) for rows, files in _WAVE_RECORD_CASES], *_ANCILLARY_CASES, *_SPELLED_CASES, *_STATIC_CASES, *_SHIPPED_CASES, + *[("held", rows, {}) for rows, _ in _OVERRIDDEN_ROW_CASES], ] @@ -827,6 +856,21 @@ def test_jonswap_gamma_limit_applies_on_every_path(rows): _rejects(_options(_HELD, *rows), r"JONSWAP gamma must be in \[1, 32\.6\)") +@pytest.mark.parametrize(("rows", "message"), _OVERRIDDEN_ROW_CASES) +def test_magnitude_bounds_apply_to_the_waves_and_current_rows_in_effect(rows, message): + text = _options(_HELD, *rows) + if message is None: + DeckFile.from_text(text) + else: + _rejects(text, message) + + +def test_an_edited_waves_row_takes_the_magnitude_bounds(): + deck = DeckFile.from_text(_options(_HELD, "airy 2000 8 0 waves", "airy 2 8 0 waves")) + with pytest.raises(ValueError, match="waves need height <= 1e3 m"): + deck.set_option("waves", ("airy", 2000.0, 8.0, 0.0)) + + def test_spread_jonswap_row_is_validated_as_a_wave_train(): DeckFile.from_text(_options(_HELD, "jonswap 2 8 3.3 2e6 waves")) _rejects( diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index e6a9e16..10f30fe 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -17339,6 +17339,8 @@ SUBROUTINE apply_option(line, opts, ErrStat, ErrMsg) opts%current_profile_velocity(:, 1) = [x6, x7, x8] opts%current_profile_velocity(:, 2) = [x2, x3, x4] END IF + ! last row wins: an earlier uniform row leaves no velocity behind + opts%current_velocity = CD_ZERO opts%has_current = .TRUE. opts%has_current_profile = .TRUE. RETURN @@ -17394,6 +17396,8 @@ SUBROUTINE apply_option(line, opts, ErrStat, ErrMsg) opts%wave_height = x1 opts%wave_period = x2 opts%wave_direction = x3 + ! last row wins: a regular wave has no gamma, so an earlier JONSWAP row leaves none + opts%wave_gamma = CD_ONE opts%has_wave = .TRUE. opts%wave_kind = WAVE_AIRY IF (to_lower(TRIM(mode)) /= 'airy') opts%wave_kind = WAVE_STREAM diff --git a/tests/test_deck_driver.f90 b/tests/test_deck_driver.f90 index 78f0d38..96adb52 100644 --- a/tests/test_deck_driver.f90 +++ b/tests/test_deck_driver.f90 @@ -2197,6 +2197,42 @@ SUBROUTINE check_parser_hardening() 'LINE 1 combines EI=0 and finite-EI sections') END IF END BLOCK + + ! The last waves or current row sets the sea: a row it overrides leaves nothing behind + ! that the bounds of the effective row are checked against (a JONSWAP gamma under an + ! Airy row, a uniform velocity under a profile row), while the effective row is checked. + BLOCK + CHARACTER(1) :: nl + CHARACTER(*), PARAMETER :: CLOCK = '0.1 dtM'//NEW_LINE('a')//'0.2 TMax' + CHARACTER(64), PARAMETER :: OVERRIDDEN(4) = [CHARACTER(64) :: & + 'jonswap 3.0 10.0 2000.0 0.0 waves', & + 'jonswap 3.0 10.0 2000.0 0.0 waves', & + 'uniform 2000.0 0.0 0.0 current', & + 'airy 2000.0 8.0 0.0 waves'] + CHARACTER(64), PARAMETER :: EFFECTIVE(4) = [CHARACTER(64) :: & + 'airy 2.0 8.0 0.0 waves', 'stream 2.0 8.0 0.0 waves', & + 'profile -30 0.2 0 0 0 0.5 0 0 current', 'none waves'] + INTEGER :: k + nl = NEW_LINE('a') + DO k = 1, SIZE(OVERRIDDEN) + CALL write_text_file('deck_hard_overridden.dat', hardening_deck(ANCHOR, SECTION, CLOCK//nl// & + TRIM(OVERRIDDEN(k))//nl//TRIM(EFFECTIVE(k)), & + 'FairTen1')) + CALL CD_Run_Deck_Driver('deck_hard_overridden.dat', 'deck_hard_overridden', conv_l, es_l, em_l) + CALL require(es_l == CD_DECKDRV_OK .AND. conv_l, '"'//TRIM(OVERRIDDEN(k))//'" overridden by "'// & + TRIM(EFFECTIVE(k))//'" leaves no stale bound: '//TRIM(em_l)) + END DO + CALL expect_badinput('deck_hard_effective_gamma.dat', hardening_deck(ANCHOR, SECTION, CLOCK//nl// & + 'airy 2.0 8.0 0.0 waves'//nl// & + 'jonswap 3.0 10.0 2000.0 0.0 waves', & + 'FairTen1'), & + 'an out-of-range JONSWAP gamma on the effective row', 'gamma <= 1e3') + CALL expect_badinput('deck_hard_effective_current.dat', hardening_deck(ANCHOR, SECTION, CLOCK//nl// & + 'profile -30 0.2 0 0 0 0.5 0 0 current'// & + nl//'uniform 2000.0 0.0 0.0 current', & + 'FairTen1'), & + 'an out-of-range uniform current on the effective row', 'at most 1e3 m/s') + END BLOCK END SUBROUTINE check_parser_hardening FUNCTION rigid6_failure_deck(failure_rows, extra_point) RESULT(body) From 026b9d9032db4b3cb418cd5f1c949e8c90bacf03 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 06:58:37 +0900 Subject: [PATCH 22/40] perf(statics): stop the axial refinement when it leaves the compression where it was (#131) Signed-off-by: Jae Hoon Seo --- python/tests/test_deck_native_parity.py | 33 +++++++++++++++++++++++++ src/CableDyn_HermiteCableStatic.f90 | 17 ++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/python/tests/test_deck_native_parity.py b/python/tests/test_deck_native_parity.py index 7d1ee33..d3dde78 100644 --- a/python/tests/test_deck_native_parity.py +++ b/python/tests/test_deck_native_parity.py @@ -2757,3 +2757,36 @@ def test_route_rules_match_the_driver(text, tmp_path): ) assert completed.returncode in (0, 1), completed.stderr[-600:] assert _accepted(deck) == (completed.returncode == 0), completed.stderr[-600:] + + +@pytest.mark.skipif( + not _PARITY_DRIVER or not Path(_PARITY_DRIVER).is_file(), + reason="set CABLEDYN_TEST_DRIVER to a built native driver to run the refinement-stop check", +) +def test_a_compression_refinement_leaves_where_it_was_stops_the_refinement(tmp_path): + # the power cable with its sections given twice: too long to hang clear of the seabed, it is + # held in axial compression by its bending stiffness, a compression of the equilibrium that + # no mesh removes; the axial refinement stops once two passes leave it where it was instead + # of splitting elements to the element budget (6 passes, 32768 elements) + tail = "1 bare 52.101 256\n" + text = _example( + "lozon_gomex80_power_cable.dat", + (tail, tail + "1 bare 68.114 448\n1 buoy 50.000 320\n" + tail), + ) + deck = tmp_path / "long.dat" + deck.write_text(text, encoding="utf-8") + completed = subprocess.run( + [_PARITY_DRIVER, str(deck), str(tmp_path / "long")], + capture_output=True, + text=True, + errors="replace", + cwd=tmp_path, + timeout=900, + env={**os.environ, "OMP_NUM_THREADS": "1", "OPENBLAS_NUM_THREADS": "1"}, + check=False, + ) + assert completed.returncode == 2, completed.stderr[-600:] + assert "two passes left the compression where it was" in completed.stderr, completed.stderr[ + -900: + ] + assert _accepted(deck) diff --git a/src/CableDyn_HermiteCableStatic.f90 b/src/CableDyn_HermiteCableStatic.f90 index 60f09d2..3270bbc 100644 --- a/src/CableDyn_HermiteCableStatic.f90 +++ b/src/CableDyn_HermiteCableStatic.f90 @@ -6743,7 +6743,9 @@ SUBROUTINE CD_HermiteCable_Static_Solve_AutoMesh(l0, EA, EI, w, seed, fixed_dofs ! move toward the compression, so it is given two more. INTEGER :: max_axial_stall_passes INTEGER, PARAMETER :: AXIAL_BUDGET_FLOOR = 2048 - INTEGER :: axial_budget, axial_stall, wios + INTEGER :: axial_budget, axial_stall, axial_flat, wios + ! A violation within this share of the previous pass's is flat: refinement does not move it. + REAL(wp), PARAMETER :: AXIAL_FLAT_REL = 1.0e-3_wp REAL(wp) :: axial_prev, end_res_prev LOGICAL :: end_min, end_prev, end_settled CHARACTER(400) :: axial_stop @@ -6754,6 +6756,7 @@ SUBROUTINE CD_HermiteCable_Static_Solve_AutoMesh(l0, EA, EI, w, seed, fixed_dofs applied_scale = 1 local_passes = 0 axial_stall = 0 + axial_flat = 0 axial_prev = CD_ZERO end_prev = .FALSE. end_res_prev = CD_ZERO @@ -7048,6 +7051,14 @@ SUBROUTINE CD_HermiteCable_Static_Solve_AutoMesh(l0, EA, EI, w, seed, fixed_dofs ELSE axial_stall = axial_stall + 1 END IF + ! A compression that two successive passes leave where it was (to 0.1 %) is the + ! equilibrium's, not the mesh's: splitting the elements around it further only grows + ! the mesh to its budget. (A mesh compression falls as its elements are split.) + IF (ABS(diag_out%axial_violation - axial_prev) <= AXIAL_FLAT_REL*axial_prev) THEN + axial_flat = axial_flat + 1 + ELSE + axial_flat = 0 + END IF END IF needs_refinement = (must_be_tensile .AND. diag_out%axial_compression) .OR. & (refine_other .AND. refinable(diag_out)) @@ -7057,6 +7068,10 @@ SUBROUTINE CD_HermiteCable_Static_Solve_AutoMesh(l0, EA, EI, w, seed, fixed_dofs axial_stop = 'the minimum axial strain stopped improving' EXIT END IF + IF (needs_refinement .AND. local_axial .AND. axial_flat >= 2) THEN + axial_stop = 'two passes left the compression where it was: it is the equilibrium''s, not the mesh''s' + EXIT + END IF IF (needs_refinement .AND. local_axial .AND. end_settled) THEN axial_stop = 'the compression is the reaction of a held end, which refinement does not change' EXIT From 252501eed68b8e32151f1d0293e601c77d33cc26 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 07:24:42 +0900 Subject: [PATCH 23/40] fix(syrope): every element of a one-section rope takes its own static secant (#138) Signed-off-by: Jae Hoon Seo --- CMakeLists.txt | 11 +++ doc/driver_format.md | 8 +- doc/theory.rst | 11 +-- src/CableDyn_DeckDriver.f90 | 80 ++++++++++++-------- tests/test_syrope_taut_rest.py | 131 +++++++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+), 40 deletions(-) create mode 100644 tests/test_syrope_taut_rest.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 499f039..f99e3c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1877,6 +1877,17 @@ if(Python3_Interpreter_FOUND) TIMEOUT 300 ENVIRONMENT "${_cabledyn_python_test_environment}") + # Taut single-section Syrope ropes whose tension varies along them start at rest: every element + # takes its own secant on its working curve. + add_test(NAME syrope_taut_rest + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_syrope_taut_rest.py + --exe $ + --output ${CMAKE_BINARY_DIR}/syrope_taut_rest) + set_tests_properties(syrope_taut_rest PROPERTIES + LABELS "python;syrope;static;dynamic" + TIMEOUT 300 + ENVIRONMENT "${_cabledyn_python_test_environment}") + # The note on grounded lines that a current moves over a seabed without friction: once per run # on every standalone route, and never without a grounded node, a current, or a march, or # with friction. diff --git a/doc/driver_format.md b/doc/driver_format.md index e9a7e67..7b0f8ec 100644 --- a/doc/driver_format.md +++ b/doc/driver_format.md @@ -306,10 +306,10 @@ and `BA = BA_s|BA_d`: waves, host fluid fields and VIV of any `EI = 0` line, and active line control of a Syrope segment on the OpenFAST route ([CONTROL](#control-optional-openfast-line-control)). - The static start solves each Syrope element on the rest curve of its load history (the OWC, or - the `Tmax0` working curve under `SYROPE IC`). A single-section line takes one secant EA from the - curve at its mean strain; on a composite line, where the tension varies along a sagging Syrope - section, every Syrope element takes its own secant at its own tension, so every element starts - on its curve and the dynamics start in the static equilibrium. + the `Tmax0` working curve under `SYROPE IC`). The tension varies along a Syrope section (a + sagging section of a composite line, or the weight of a long or inclined taut rope), so every + Syrope element takes its own secant EA at its own tension: every element starts on its curve + and the dynamics start in the static equilibrium. The settings-file grammar is in [Auxiliary file formats](file_formats.rst). diff --git a/doc/theory.rst b/doc/theory.rst index 5600cdf..6d7344d 100644 --- a/doc/theory.rst +++ b/doc/theory.rst @@ -958,15 +958,16 @@ ratchets and regenerates the working curve when the mean tension exceeds it. **Initial condition.** The static solve uses the rest curve of the initial load history: the OWC for a virgin rope, or, with ``SYROPE IC``, the working curve regenerated at :math:`T_\text{max,0}` (zero tension below its zero-tension strain, the OWC above its top -strain :math:`\varepsilon_\text{OWC}(T_\text{max,0})`). A single secant axial stiffness of that -curve is iterated to the solved mean strain, the line otherwise built as any ``EI = 0`` line +strain :math:`\varepsilon_\text{OWC}(T_\text{max,0})`). Each element's secant axial stiffness of +that curve is iterated to the curve's strain at its solved tension, the line otherwise built as +any ``EI = 0`` line (its drag in a deck current included). Each element then starts with the running maximum :math:`\max(T_\text{max,0}, T_e)` (:math:`T_e` for a virgin rope), where :math:`T_e` is its static tension, and with the slow strain whose kernel tension at the static strain and zero strain rate equals :math:`T_e`. The dynamic state therefore starts in the static equilibrium, -with no initial transient. Where the static strain lies on the rest curve, that slow strain is -the curve's rest point. Elsewhere, for example along a sagging line, it creeps at -:math:`(T_e - T_\text{mean})/BA_s`. With fixed line ends the mean tension follows from the +with no initial transient: every element's static strain lies on the rest curve, where that slow +strain is the curve's rest point. (With one secant for a whole section, an element away from the +mean tension sat off the curve and its slow strain crept at :math:`(T_e - T_\text{mean})/BA_s`.) With fixed line ends the mean tension follows from the geometry and :math:`T_\text{max,0}`, so :math:`T_\text{mean,0}` does not enter the state. A value that differs from the static mean tension by more than 1 % is reported on the console. A strain below the working curve's zero-tension strain (a slack rope on that history) stops at diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index 10f30fe..8c31da4 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -10682,15 +10682,17 @@ RECURSIVE SUBROUTINE init_one_syrope_model(types, ln, sections, points, l0, opts !! strain whose t = 0 kernel force equals its static tension, so the dynamic state starts in !! the static equilibrium. Tmean0 is not a state input (the fixed-end geometry and Tmax0 set !! the mean tension); a disagreement beyond 1 % is reported on the console. - !! The static solve uses secant EAs, iterated to the rest curve. A single-section line, taut - !! and of nearly uniform tension, takes one secant from the curve at its mean strain (the - !! strain the taut geometry fixes). On a composite line the tension varies along a sagging - !! Syrope section, so each Syrope element takes its own secant, from the curve's strain at the - !! element's tension: every element then starts on its rest curve, its slow strain at rest - !! (one secant per section would leave the elements away from the mean off the curve, and - !! their slow strains would settle the line after the start). The tension, which the chains' - !! weight and the geometry largely fix, keeps this update a contraction where a strain update - !! can overshoot. + !! The static solve uses secant EAs, iterated to the rest curve. The tension varies along a + !! Syrope section (a sagging section of a composite line, or the weight of a single long or + !! inclined taut rope), so each Syrope element takes its own secant, from the curve's strain + !! at the element's tension: every element then starts on its rest curve, its slow strain at + !! rest (one secant per section, from the curve at the mean strain, left the elements away + !! from the mean off the curve, and their slow strains settled the line after the start: a + !! 991 m single-section rope at rest moved its fairlead tension by 3.4e-4 in 1 s). A + !! single-section line starts the iteration from the secant at the strain its taut geometry + !! fixes, a composite line from the curve's alpha. The tension, which the chains' weight and + !! the geometry largely fix, keeps this update a contraction where a strain update can + !! overshoot. TYPE(DeckLineType), INTENT(IN) :: types(:) TYPE(DeckLine), INTENT(IN) :: ln TYPE(DeckSection), INTENT(IN) :: sections(:) @@ -10709,9 +10711,11 @@ RECURSIVE SUBROUTINE init_one_syrope_model(types, ln, sections, points, l0, opts TYPE(CD_SyropeType), ALLOCATABLE :: tds(:), stype(:) INTEGER, ALLOCATABLE :: sec_type(:), sec_nseg(:), el_sec(:) LOGICAL, ALLOCATABLE :: sec_syr(:), sis(:) - REAL(wp), ALLOCATABLE :: sec_el(:), cand_el(:), sslow(:), stmax(:), t_static(:), eps_prev(:), t_prev(:) - REAL(wp) :: anchor(3), fairlead(3), eps0, t_ref, total_l0, t_hist, eps_bar, t_mean_static - REAL(wp) :: eps_e, change, secant, cand, eps_now, d_eps, d_t, eps_r, t_r, acc + REAL(wp), ALLOCATABLE :: sec_el(:), cand_el(:), sslow(:), stmax(:), t_static(:), eps_prev(:), t_prev(:), & + step_prev(:) + REAL(wp) :: anchor(3), fairlead(3), eps0, t_ref, total_l0, t_hist, t_mean_static + REAL(wp) :: eps_e, change, secant, eps_now, d_eps, d_t, eps_r, t_r, acc + LOGICAL :: have_acc INTEGER :: ne, e, es, n_owc, iter, nsec, j, k, isec, st LOGICAL :: composite, converged CHARACTER(1024) :: em @@ -10784,10 +10788,11 @@ RECURSIVE SUBROUTINE init_one_syrope_model(types, ln, sections, points, l0, opts t_hist = CD_ZERO IF (ln%has_syrope_ic) t_hist = ln%syrope_tmax0 ALLOCATE (sec_el(ne), cand_el(ne), stype(ne), sis(ne), sslow(ne), stmax(ne), t_static(ne), eps_prev(ne), & - t_prev(ne)) + t_prev(ne), step_prev(ne)) sis = .FALSE. sslow = CD_ZERO stmax = CD_ZERO + step_prev = CD_ZERO sec_el = CD_ZERO DO e = 1, ne IF (.NOT. sec_syr(el_sec(e))) CYCLE @@ -10837,7 +10842,7 @@ RECURSIVE SUBROUTINE init_one_syrope_model(types, ln, sections, points, l0, opts ! current's deflection). The solve just made belongs to the current secants, which are kept ! once the candidates agree with them. converged = .FALSE. - DO iter = 1, MERGE(MAX_ITER_COMPOSITE, 6, composite) + DO iter = 1, MAX_ITER_COMPOSITE CALL init_one_model(ln, sections, types, points, opts, model, ErrStat, ErrMsg, bathymetry=bathymetry, & syrope_secant=sec_el) IF (ErrStat /= CD_DECKDRV_OK) EXIT @@ -10848,7 +10853,7 @@ RECURSIVE SUBROUTINE init_one_syrope_model(types, ln, sections, points, l0, opts ErrMsg = em END IF IF (ErrStat /= CD_MODEL_OK) EXIT - IF (composite) THEN + BLOCK ! each Syrope element: the curve's strain at its tension change = CD_ZERO cand_el = sec_el @@ -10889,19 +10894,40 @@ RECURSIVE SUBROUTINE init_one_syrope_model(types, ln, sections, points, l0, opts ! equilibrium. It is taken only beyond the tension update in the same direction and ! at most 100 times as far, else the tension update stands. eps_now = t_static(e)/sec_el(e) + have_acc = .FALSE. IF (iter > 1) THEN d_eps = eps_now - eps_prev(e) d_t = t_static(e) - t_prev(e) - IF (d_eps*d_t < CD_ZERO) THEN + ! (a response of fixed strain, d_eps = 0, as of a taut rope whose geometry sets its + ! strain, crosses the curve at that strain) + IF (d_eps*d_t < CD_ZERO .OR. (.NOT. (ABS(d_eps) > CD_ZERO) .AND. ABS(d_t) > CD_ZERO)) THEN CALL response_root(isec, eps_now, t_static(e), d_eps, d_t, eps_r, t_r) IF (eps_r > CD_ZERO .AND. t_r > CD_ZERO) THEN acc = t_r/eps_r - IF ((acc - sec_el(e))*(cand_el(e) - sec_el(e)) > CD_ZERO .AND. & - ABS(acc - sec_el(e)) >= ABS(cand_el(e) - sec_el(e)) .AND. & - ABS(acc - sec_el(e)) <= 100.0_wp*ABS(cand_el(e) - sec_el(e))) cand_el(e) = acc + have_acc = .TRUE. END IF END IF + ! A tension update that reverses the previous one and is not below half of it + ! overshot the crossing without contracting: on a curve concave enough (the secant + ! far above the tangent) it would cycle. The secant step then stands where it lies + ! between the secant and the update, else half the update. Any other update (in the + ! same direction as the last, as on a convex curve, or an alternating one that + ! contracts) keeps the rule below. + IF ((cand_el(e) - sec_el(e))*step_prev(e) < CD_ZERO .AND. & + ABS(cand_el(e) - sec_el(e)) >= 0.5_wp*ABS(step_prev(e))) THEN + IF (have_acc .AND. (acc - sec_el(e))*(cand_el(e) - sec_el(e)) > CD_ZERO .AND. & + ABS(acc - sec_el(e)) < ABS(cand_el(e) - sec_el(e))) THEN + cand_el(e) = acc + ELSE + cand_el(e) = sec_el(e) + 0.5_wp*(cand_el(e) - sec_el(e)) + END IF + ELSE IF (have_acc .AND. d_eps*d_t < CD_ZERO) THEN + IF ((acc - sec_el(e))*(cand_el(e) - sec_el(e)) > CD_ZERO .AND. & + ABS(acc - sec_el(e)) >= ABS(cand_el(e) - sec_el(e)) .AND. & + ABS(acc - sec_el(e)) <= 100.0_wp*ABS(cand_el(e) - sec_el(e))) cand_el(e) = acc + END IF END IF + step_prev(e) = cand_el(e) - sec_el(e) eps_prev(e) = eps_now t_prev(e) = t_static(e) END DO @@ -10910,15 +10936,7 @@ RECURSIVE SUBROUTINE init_one_syrope_model(types, ln, sections, points, l0, opts EXIT END IF sec_el = cand_el - ELSE - eps_bar = SUM(l0*t_static)/(secant*SUM(l0)) - CALL rest_tension(1, eps_bar, t_ref, es, em) - IF (es /= CD_SYROPE_OK .OR. .NOT. (t_ref > CD_ZERO .AND. eps_bar > CD_ZERO)) EXIT - cand = t_ref/eps_bar - IF (ABS(cand - secant) <= 1.0e-10_wp*secant) EXIT - secant = cand - sec_el = secant - END IF + END BLOCK END DO IF (ErrStat /= CD_MODEL_OK) THEN CALL release_syrope(.TRUE.) @@ -10926,8 +10944,8 @@ RECURSIVE SUBROUTINE init_one_syrope_model(types, ln, sections, points, l0, opts IF (ErrStat /= CD_DECKDRV_BADINPUT) ErrStat = CD_DECKDRV_SOLVEFAIL RETURN END IF - IF (composite) THEN - ! a composite line starts only from converged secants, every Syrope element on its + BLOCK + ! a Syrope line starts only from converged secants, every Syrope element on its ! curve's table IF (.NOT. converged) THEN CALL release_syrope(.TRUE.) @@ -10951,7 +10969,7 @@ RECURSIVE SUBROUTINE init_one_syrope_model(types, ln, sections, points, l0, opts RETURN END IF END DO - END IF + END BLOCK ! Per Syrope element: the running maximum is the history's (raised to the element's static ! tension where that exceeds it, i.e. the element sits on the OWC; without SYROPE IC ! every element is a virgin rope at its own static tension), and the slow strain diff --git a/tests/test_syrope_taut_rest.py b/tests/test_syrope_taut_rest.py new file mode 100644 index 0000000..d26e800 --- /dev/null +++ b/tests/test_syrope_taut_rest.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""A taut single-section Syrope rope whose tension varies along it starts at rest. + +Three deep, inclined polyester ropes (481, 850 and 991 m, 330 to 750 m of water) carry their +weight, so the tension falls by up to 15 % from fairlead to anchor. The static start took one +secant EA from the working curve at the rope's mean strain: the elements away from the mean +started off their curve and their slow strains settled the rope at rest (the fairlead tension +moved by 1.2e-4 to 6.1e-4 in 1 s). Each rope, held at rest for 1 s, must keep its end tensions +at their static values to 1e-6. + +usage: test_syrope_taut_rest.py --exe --output +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +SETTINGS = """syrope_owc.dat OWC +EXP WCType +0.20 k1 +1.50 k2 +""" + +DECK = """--------------------- CableDyn Input File ------------------------------------ +Taut polyester rope in deep water, held at rest +--------------------- LINE TYPES --------------------------------------- +TypeName Diam Mass EA BA EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (N-s) (N-m2) (-) (-) (-) (-) +rope 0.1438 26.8406 SYROPE:syrope_settings.dat|{alpha}|{beta} {bas}|{bad} 0.0 1.2 0.2 1.0 0.0 +--------------------- POINTS ------------------------------------------- +ID Type X Y Z +(-) (-) (m) (m) (m) +1 Coupled {a} +2 Fixed {b} +--------------------- LINES -------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +1 1 2 - +--------------------- SECTIONS ----------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +1 rope {length} {n} +--------------------- OPTIONS ------------------------------------------ +{depth} WtrDpth +{kbot} kBot +0.01 dtM +1.0 TMax +--------------------- OUTPUTS ------------------------------------------ +FairTen1 +AnchTen1 +--------------------- need this line ----------------------------------- +""" + +ROPES = [ + dict(alpha="1.83494e8", beta="15.9308", bas="2.28256e10", bad="4.81630e5", a="6.0913 -27.1467 -4.6733", + b="185.5774 -660.5800 -751.7888", length="990.9851", n=20, depth="751.7888", kbot="1.829e5"), + dict(alpha="1.89709e8", beta="16.1960", bas="1.31046e9", bad="7.86974e5", a="-6.0871 -36.8963 -4.5814", + b="-360.0368 -124.1505 -328.1981", length="481.3015", n=10, depth="328.1981", kbot="4.494e5"), + dict(alpha="2.67068e8", beta="15.9927", bas="1.06925e10", bad="2.82844e5", a="14.7149 -33.3903 -4.1781", + b="-60.8938 -595.2920 -643.3068", length="849.5532", n=13, depth="643.3068", kbot="1.616e5"), +] + + +# A short vertical rope on a strongly concave curve, T = 1e6 eps^0.4: its secant lies far above +# its tangent, so the per-element tension update overshoots the curve's crossing and reverses +# (it cycled between about 108 and 560 kN and the statics failed); the reversed updates take +# the secant step, and the rope must start at rest in its weight's equilibrium. +CONCAVE = dict(alpha="1.0e8", beta="15.9308", bas="2.28256e10", bad="4.81630e5", a="0.0 0.0 -29.6", + b="0.0 0.0 -50.0", length="20.0", n=2, depth="50.0", kbot="1.0e5") + + +def concave_table() -> str: + rows = ["Strain Tension", "(-) (N)"] + for k in range(61): + rows.append("%r %r" % (0.001 * k, 1.0e6 * (0.001 * k) ** 0.4)) + return "\n".join(rows) + "\n" + + +def owc_table() -> str: + rows = ["Strain Tension", "(-) (N)"] + for k in range(30): + rows.append("%r %r" % (0.00206897 * k, 1.71768e5 * k * (1.0 - 0.012 * k))) + return "\n".join(rows) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--exe", required=True) + ap.add_argument("--output", required=True) + a = ap.parse_args() + out = Path(a.output) + out.mkdir(parents=True, exist_ok=True) + (out / "syrope_settings.dat").write_text(SETTINGS, encoding="utf-8", newline="\n") + (out / "syrope_owc.dat").write_text(owc_table(), encoding="utf-8", newline="\n") + (out / "syrope_settings_concave.dat").write_text(SETTINGS.replace("syrope_owc.dat", "syrope_owc_concave.dat"), + encoding="utf-8", newline="\n") + (out / "syrope_owc_concave.dat").write_text(concave_table(), encoding="utf-8", newline="\n") + env = dict(os.environ) + env.setdefault("OPENBLAS_NUM_THREADS", "1") + failures = [] + cases = [("rope_%d" % k, DECK.format(**rope)) for k, rope in enumerate(ROPES)] + cases.append(("rope_concave", DECK.format(**CONCAVE).replace("syrope_settings.dat", "syrope_settings_concave.dat"))) + for name, text in cases: + deck = out / (name + ".dat") + deck.write_text(text, encoding="utf-8", newline="\n") + root = out / name + p = subprocess.run([a.exe, str(deck), str(root)], capture_output=True, text=True, env=env, cwd=str(out)) + if p.returncode != 0: + failures.append("%s: exit %d: %s" % (name, p.returncode, (p.stdout + p.stderr)[-300:])) + continue + rows = Path(str(root) + ".out").read_text(encoding="utf-8").splitlines() + vals = [[float(v) for v in r.split()] for r in rows[2:] if r.strip()] + for j, ch in ((1, "FairTen1"), (2, "AnchTen1")): + t0 = vals[0][j] + drift = max(abs(v[j] - t0) for v in vals) / abs(t0) + if drift > 1.0e-6: + failures.append("%s: %s drifts by %.3e of its static value" % (name, ch, drift)) + print("taut Syrope ropes at rest: %d ropes, %d failures" % (len(cases), len(failures))) + for f in failures: + print("FAIL:", f) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) From ae59f10e3ddbabb9f1719b8f634dbe78ab3fb174 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Mon, 9 Nov 2026 07:45:52 +0900 Subject: [PATCH 24/40] docs(validation): re-measure the L5 performance records and commit their scripts (#118) Signed-off-by: Jae Hoon Seo --- VALIDATION.md | 111 +-- release/check_release_metadata.py | 1 + tests/test_validation_records.py | 53 +- validation/README.md | 2 +- validation/performance/PERFORMANCE_AUDIT.md | 70 +- validation/performance/README.md | 127 ++- .../performance/performance_metrics.json | 570 ++++++++++--- .../performance/performance_summary.csv | 23 +- validation/performance/wallclock_summary.csv | 7 + .../scripts/orcaflex_longduration_mooring.py | 7 +- validation/scripts/performance_models.py | 320 ++++++++ validation/scripts/performance_records.py | 751 ++++++++++++++++++ 12 files changed, 1824 insertions(+), 218 deletions(-) create mode 100644 validation/performance/wallclock_summary.csv create mode 100644 validation/scripts/performance_models.py create mode 100644 validation/scripts/performance_records.py diff --git a/VALIDATION.md b/VALIDATION.md index 63dbd7f..398776d 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -88,9 +88,9 @@ article's methods, are compared in | **L4-rot** | Coupled mooring rotational stiffness | held 1° pitch geometry | upwind −16.0 kN, downwind +7.8 kN each; first-order restoring moment | signs + first-order | Pass | | **L5-stab** | Snap-load Δt-stability | sub-CFL fine-dt (0.5 ms) self-ref | peak 1.5% at dt = 20 ms (explicit limit 3.2 ms) | — | Pass | | **L5-stab** | Snap-load Δt-stability, implicit and explicit steps | MoorDyn-C explicit step limit | 20 ms implicit; ~1 ms explicit | — | Pass | -| **L5-V** | VolturnUS-S 1-hr mooring wall-clock | measured, matched protocol | CableDyn 18.1–32 s across repeated runs (full Newton) vs OrcaFlex 14.5 s; heave-only config 10.3 s | — | Pass (response at L3-7) | -| **L5-wc** | Wall-clock vs explicit C solver | external-C RK2 (WD chain) | L2 0.967 s vs external-C 0.541 s; traces validated | matched comparison reported | Pass measured | -| **L5-DPC** | Pinned-TDP lazy-wave spans, 80/200/800 m | OrcaFlex / refined MoorDyn-F | CableDyn 1.55/4.64/7.37 s; OrcaFlex 2.29/4.56/23.17 s; MoorDyn-F 249/145/243 s | resolved meshes, same host, one thread | Pass measured | +| **L5-V** | VolturnUS-S 1-hr mooring wall-clock | OrcaFlex 11.6d, same drive and host | CableDyn 4.44 s (IFX release) / 6.18 s (GNU) vs OrcaFlex 19.72 s; the timed deck reproduces the L3-7 response | — | Pass measured | +| **L5-wc** | Wall-clock, tension-only chain | MoorDyn-C v2.7.2, explicit RK2 (WD0050 chain) | CableDyn 0.200 s (IFX) / 0.277 s (GNU) vs MoorDyn-C 0.482 s; tensions 1.35 / 1.71% (mean / max) | matched comparison reported | Pass measured | +| **L5-DPC** | Pinned-TDP lazy-wave spans, 80/200/800 m | OrcaFlex / refined MoorDyn-F | CableDyn 1.44/2.56/5.29 s (IFX release; GNU 2.06/3.40/7.59 s); OrcaFlex 2.69/5.31/27.81 s; MoorDyn-F 327/192/321 s | resolved meshes, same host, one thread | Pass measured | | **L5-pk** | Snap-load peak, 3-code | MoorDyn-C + OrcaFlex | 4.86 / 6.32 / 10.22 MN | — | Informative | | **T-1** | Torsion kernel: twist of the centreline, gradient, Hessian | automatic differentiation; finite differences | ≤3.8e-15; ≤5.3e-11 | <1e-10; <1e-6 | Pass | | **T-2** | Pure torsion (multi-section, end springs, five turns) | `Φ/C`; OrcaFlex 11.6d | round-off; ≤1.7e-8 | <1e-12; <1e-7 | Pass | @@ -505,49 +505,68 @@ scripts, results, and source provenance are in ### Wall-clock time -- **Tension-only chain (Pass measured)**: MoorDyn-C runs the 8 s snap in about - 0.03 s and CableDyn in about 0.26 s; cheap lumped-mass substeps make the explicit - model faster for this short axial-line problem. For the L2 WD0050/A1 80 s deck, - CableDyn takes **0.967 s** and the external-C RK2 calculation **0.541 s**; the - traces agree to 1.67 / 3.61% in mean / maximum fairlead tension. -- **VolturnUS-S one-hour mooring**: - -| Case | CableDyn | Reference | Note | -|------|----------|-----------|------| -| All-chain static IC | converged | OrcaFlex 2427 / MoorDyn 2439 kN | `FairTen` 2437 kN (0.42% / 0.08%); anchor 0.77% (L2) | -| 1-hour (3600 s) dynamic, heave-only config | **10.3 s** (dt=0.1 s, 36000 steps, 0 non-convergent, ~2 iterations/step) | — | throughput at production step | -| 1-hour dynamic, matched 2-component drive (L3-7) | **18.1–32 s** across repeated runs (full Newton) | OrcaFlex **14.5 s** | same drive and host | - -- **Finite-bending lazy-wave spans (L5-DPC)**: the 80, 200, and 800 m pinned-TDP - suspended spans are timed after spatial convergence, without grounded tail or - seabed contact. CableDyn uses 240, 480, and 960 Hermite elements at 0.05 s. - OrcaFlex uses independently checked segment lengths and the same step. MoorDyn-F - uses a fourfold-refined mesh and the largest step for which all reported - responses change by less than 0.01% on further halving. - -| Depth | CableDyn | OrcaFlex | Refined MoorDyn-F | -|------:|---------:|---------:|------------------:| -| 80 m | 1.55 s | 2.29 s | 249.07 s | -| 200 m | 4.64 s | 4.56 s | 144.61 s | -| 800 m | 7.37 s | 23.17 s | 243.08 s | - -Totals include initialisation and the same 60 s dynamic interval. The explicit MoorDyn-F -step is set by its stability limit on the fourfold-refined bending mesh, so these times reflect -the chosen resolution protocol rather than a general property of any code. Each value is the -median of three runs after one warm-up on one Intel Core i9-13900K thread. Model -sizes, timing ranges, memory, Newton work, and the OrcaFlex mesh sweep are in +The measurements below were made on 2026-11-08 on one host (AMD Ryzen Threadripper PRO 7965WX, +Windows 11 Pro) with one thread per code, as the median of three runs after one warm-up. They +come from [`validation/scripts/performance_records.py`](https://github.com/SMI-Lab-Inha/CableDyn/blob/main/validation/scripts/performance_records.py), +which also checks each timed model against its reference response. CableDyn is timed with the +Windows release driver (IFX, the shipped binary) and with a GNU Release build of `dev`. Their +versions and hashes, the ranges and the timing definitions are in [`validation/performance`](https://github.com/SMI-Lab-Inha/CableDyn/blob/main/validation/performance/README.md). - -- **Hermite solver timings** (development workstation; machine-specific, not - portable tests): the banded solves reproduce the dense reference to identical - digits and Newton counts, and the dynamic step performs no heap allocation. The - 960-element Humboldt static solve takes 40.3 s and the 480-element solve 17.3 s; - the 240-element pinned-span static solve takes 471 Newton iterations direct and - 477 with mesh sequencing; the installed-cable sequence takes 529 coarse and - 20/60/14 polishing iterations; the 240-element 200 m dynamic case runs at - 2.21 ms per step with 3.95 Newton iterations per step; and the 78-component - irregular-wave lazy-wave comparison (the article's sea) runs in 66.6 s. -- **Coupled throughput**: at `dtM = 0.025 s` the one-line, 50-element aggregate +The figures depend on each code's configuration and on this host; they are not rankings. + +- **Tension-only chain (L5-wc)**: the WD0050 grounded chain (`examples/wd0050_chain.dat`) + under the L2-2 A1 heave for 80 s. CableDyn at dt 0.05 s takes **0.200 s** (IFX) and + **0.277 s** (GNU); MoorDyn-C v2.7.2 through `moordyn_c_driver.cpp` (explicit RK2, dtM 1 ms) + takes **0.482 s**, of which 0.452 s is its stepping loop. Over the 20 s scored window the + fairlead tensions of the two codes differ by 1.35% in mean and 1.71% in maximum. The + original external solver of this row was MoorDyn-C v2.6.1 through the same driver, whose deck + settings were not recorded. +- **VolturnUS-S one-hour mooring (L5-V)**: the L3-7 drive (5 m / 90 s surge and 2 m / 12 s + heave of the fairlead) on one 850 m chain with 50 elements. + +| Case | CableDyn (IFX) | CableDyn (GNU) | OrcaFlex 11.6d | Note | +|------|---------------:|---------------:|---------------:|------| +| All-chain static IC, `FairTen` | 2437 kN | 2437 kN | 2427 kN | 0.42% from OrcaFlex, 0.08% from MoorDyn (2439 kN); anchor 0.77% (L2) | +| 1-hour dynamic, dt 0.1 s | **4.44 s** (4.44–4.45) | **6.18 s** (6.16–6.19) | **19.72 s** (19.33–19.74) | 36,000 steps, 2.03 Newton iterations per step; OrcaFlex adds a 200 s build-up | + + The timed CableDyn deck reproduces the L3-7 response: fairlead tension mean, standard + deviation, minimum and maximum within 0.27, 0.26, 0.50 and 0.43% of OrcaFlex. + +- **Finite-bending lazy-wave spans (L5-DPC)**: the 80, 200 and 800 m pinned-touchdown + suspended spans of the L3-6 protocol (3 m / 12 s hang-off heave, 60 s), without grounded tail + or seabed contact. CableDyn uses 240, 480 and 960 Hermite elements at 0.05 s; OrcaFlex uses + 0.75 m segments (179, 350 and 1227) at the same step; MoorDyn-F uses its native meshes refined + fourfold (208, 224 and 232 segments) at the largest explicit step for which halving the step + moves every scored response by less than 0.01% (0.125 ms at 80 and 200 m, 0.5 ms at 800 m, + re-checked in this run: halving moved them by at most 0.0005%). + +| Depth | CableDyn (IFX) | CableDyn (GNU) | OrcaFlex | Refined MoorDyn-F | +|------:|---------------:|---------------:|---------:|------------------:| +| 80 m | 1.44 s | 2.06 s | 2.69 s | 326.6 s | +| 200 m | 2.56 s | 3.40 s | 5.31 s | 191.9 s | +| 800 m | 5.29 s | 7.59 s | 27.81 s | 320.7 s | + +Totals include initialisation and the dynamic interval. The timed CableDyn and refined +MoorDyn-F models reproduce the OrcaFlex hang-off tension mean, minimum and maximum of the L3-6 +gate within 0.12% and 0.29%. The explicit MoorDyn-F step is set by its stability limit on the +fourfold-refined bending mesh, so these times reflect the chosen resolution protocol rather than +a general property of any code; the MoorDyn-F totals include its dynamic-relaxation +initialisation. CableDyn takes 2.7–3.3 linear solves per step, and no step needs recovery +subdivision. + +- **Cubic-Hermite solver work** (GNU Release build of `dev` on the same host; the iteration + counts are deterministic, the times are not): the 240-element pinned Humboldt span + (`hermite_sequenced`) takes 507 Newton iterations solved directly and 460 with mesh + sequencing (450 / 4 / 6 by level); the installed 800 m cable (`l3_lazywave_fullcable`) takes + 524 iterations on its 89-element mesh and 20 / 60 / 14 to polish at 178 / 356 / 712 + elements; the mesh-sequenced 480- and 960-element static solves of `l3_lazywave_refinement` + take 81 and 4 iterations and 0.1 s or less each; and the irregular-sea comparison in the + article's 78-component sea (`test_l3_lazywave_irregular OUT TABLE.dat`, the table written by + `orcaflex_lazywave_irregular_reference.py --orcaflex-jonswap`) runs in 10.9 s. The per-step + advance of the OpenFAST aggregate route, Hermite cables included, makes no heap allocation + (`step_allocations`). +- **Coupled throughput** (measured in September 2026 with the code of that date on an Intel Core + i9-13900K; not re-measured for the records above): at `dtM = 0.025 s` the one-line, 50-element aggregate advances at 0.540 ms per step and the three-line spread at 0.590 ms per step; these are mooring-module figures only. With `OpenFAST DT = CableDyn dtM = OpenFAST DT_Out = 0.025 s`, uninterrupted 4200 s @@ -556,7 +575,7 @@ sizes, timing ranges, memory, Newton work, and the OrcaFlex mesh sweep are in and **1399.44 s** with an added 55-element suspended finite-EI power cable (a variant of the mixed example without touchdown), using default full-Newton settings (no `dynamic_solver` override, no modified Newton, no adaptive - remeshing), on the development workstation. Each binary output has + remeshing). Each binary output has exactly 144,001 rows from 600 s to 4200 s with 47 channels. The OpenFAST runtime Jacobian is the fixed-state direct-feedthrough derivative; formal linearisation differentiates the line dynamics with the line states registered. diff --git a/release/check_release_metadata.py b/release/check_release_metadata.py index d556679..8632cee 100644 --- a/release/check_release_metadata.py +++ b/release/check_release_metadata.py @@ -41,6 +41,7 @@ def capture(relative_path: str, pattern: str, label: str) -> str: r"^(CHANGELOG\.md|validation/(RELEASE|PERFORMANCE)_\d+_\d+_\d+\.md" r"|validation/PAPER_REPRODUCTION\.md|validation/README\.md" r"|validation/experiments/[^/]+/provenance\.json|validation/bodies/references/.*" + r"|validation/performance/.*" r"|tests/test_documentation\.py)$" ) diff --git a/tests/test_validation_records.py b/tests/test_validation_records.py index b408552..967c0cb 100644 --- a/tests/test_validation_records.py +++ b/tests/test_validation_records.py @@ -177,24 +177,51 @@ def test_bergdahl_refinement_and_waveforms(self) -> None: def test_performance_summary_matches_machine_readable_record(self) -> None: directory = VALIDATION / "performance" rows = load_csv(directory / "performance_summary.csv") + others = load_csv(directory / "wallclock_summary.csv") record = load_json(directory / "performance_metrics.json") - self.assertEqual(len(rows), 9) - self.assertEqual(len(record["rows"]), 9) - lookup = {(row["site"], row["solver"]): row for row in rows} + # L5-DPC: three spans, CableDyn with the GNU development build and the IFX release driver, + # OrcaFlex and MoorDyn-F. + cabledyn_builds = {row["build"] for row in rows if row["solver"] == "CableDyn"} + self.assertEqual(len(cabledyn_builds), 2) + expected = {(site, "CableDyn", build) for site in ("80m", "200m", "800m") for build in cabledyn_builds} + expected |= {(site, solver, solver.lower()) for site in ("80m", "200m", "800m") + for solver in ("OrcaFlex", "MoorDyn-F")} + lookup = {(row["site"], row["solver"], row["build"]): row for row in rows} + self.assertEqual(len(lookup), len(rows)) + self.assertEqual(set(lookup), expected) + self.assertEqual({row["case"] for row in rows}, {"L5-DPC"}) + # L5-V (CableDyn builds and OrcaFlex) and L5-wc (CableDyn builds and MoorDyn-C). + other_lookup = {(row["case"], row["solver"], row["build"]): row for row in others} + self.assertEqual(len(other_lookup), len(others)) self.assertEqual( - set(lookup), - {(site, solver) for site in ("80m", "200m", "800m") for solver in ("CableDyn", "OrcaFlex", "MoorDyn-F")}, + set(other_lookup), + {(case, "CableDyn", build) for case in ("L5-V", "L5-wc") for build in cabledyn_builds} + | {("L5-V", "OrcaFlex", "orcaflex"), ("L5-wc", "MoorDyn-C", "moordyn-c")}, ) - records = {(row["site"], row["solver"]): row for row in record["rows"]} - self.assertEqual(set(records), set(lookup)) - for key, row in lookup.items(): - median = float(row["total_wall_median_s"]) - self.assertLessEqual(float(row["total_wall_min_s"]), median) - self.assertGreaterEqual(float(row["total_wall_max_s"]), median) - assert_close(self, median, records[key]["total_wall_median_s"]) + for table, key_fields, json_rows in ( + (lookup, ("site", "solver", "build"), record["rows"]), + (other_lookup, ("case", "solver", "build"), record["wallclock_rows"]), + ): + records = {tuple(row[field] for field in key_fields): row for row in json_rows} + self.assertEqual(set(records), set(table)) + for key, row in table.items(): + median = float(row["total_wall_median_s"]) + self.assertLessEqual(float(row["total_wall_min_s"]), median) + self.assertGreaterEqual(float(row["total_wall_max_s"]), median) + assert_close(self, median, records[key]["total_wall_median_s"]) + self.assertIn(row["build"], record["codes"]) + for key, row in list(lookup.items()) + list(other_lookup.items()): + if row["solver"] == "CableDyn" and row["subdivided_intervals"]: + self.assertEqual(int(row["subdivided_intervals"]), 0, key) for site in ("80m", "200m", "800m"): - self.assertEqual(int(lookup[(site, "CableDyn")]["subdivided_intervals"]), 0) + for build in cabledyn_builds: + self.assertEqual(int(lookup[(site, "CableDyn", build)]["subdivided_intervals"]), 0) + # The refined MoorDyn-F step is the largest whose halving moves the scored responses < 0.01 %. + checks = record["moordyn_f_step_checks"] + self.assertEqual(len(checks), 3) + for check in checks.values(): + self.assertLess(check["largest_relative_move"], 1.0e-4) if __name__ == "__main__": diff --git a/validation/README.md b/validation/README.md index ff577ab..95d2c18 100644 --- a/validation/README.md +++ b/validation/README.md @@ -18,7 +18,7 @@ redistributed. | [`bodies/`](bodies/README.md) | Rigid-body, rod and shared-anchor cases with MoorDyn-C twins, stored references and pass limits | | [`experiments/`](experiments/README.md) | Holcombe et al. (2025) static lazy-wave and Bergdahl et al. (2016) dynamic-chain comparisons | | [`PERFORMANCE_0_1_0.md`](PERFORMANCE_0_1_0.md) | Wall time of CableDyn 0.1.0, MoorDyn-C and MoorDyn-F at equal accuracy on five mooring, cable, buoy and coupled cases | -| [`performance/`](performance/README.md) | Same-host timing of the three spatially resolved lazy-wave cables | +| [`performance/`](performance/README.md) | Same-host wall-clock records of the L5 cases: the resolved lazy-wave spans, the one-hour VolturnUS-S mooring and the tension-only chain | | [`PAPER_REPRODUCTION.md`](PAPER_REPRODUCTION.md) | Every CableDyn result in the journal article, re-run with the release defaults and in paper mode | | [`OPENFAST_DLC_VERIFICATION.md`](OPENFAST_DLC_VERIFICATION.md) | Design-load-case comparison against stock MoorDyn-F in OpenFAST, maintained by NLR (National Laboratory of the Rockies, formerly NREL) | | `scripts/` | Cross-code comparison and reference-generation scripts cited in `VALIDATION.md` | diff --git a/validation/performance/PERFORMANCE_AUDIT.md b/validation/performance/PERFORMANCE_AUDIT.md index 4c83b1f..aa1bff0 100644 --- a/validation/performance/PERFORMANCE_AUDIT.md +++ b/validation/performance/PERFORMANCE_AUDIT.md @@ -1,31 +1,59 @@ -# Same-host computational performance +# Same-host wall times -This page lists the wall times and timing definitions behind the lazy-wave performance -comparison. The protocol is in [README.md](README.md). +This page lists the wall times behind the L5 rows of [`VALIDATION.md`](../../VALIDATION.md) with +their ranges and the timing definitions. The cases, protocol and builds are in +[README.md](README.md); the values come from `performance_summary.csv` and +`wallclock_summary.csv`. -All runs used one thread of an Intel Core i9-13900K. Each value is the median of three measured -runs after one warm-up; the range of the three runs is in brackets. The values come from -`performance_summary.csv`. +All runs used one thread on an AMD Ryzen Threadripper PRO 7965WX under Windows 11 Pro on +2026-11-08. Each value is the median of three timed runs after one warm-up; the range of the +three runs is in brackets. `IFX` is the v0.4.1 release driver, `GNU` a GNU Release build of `dev`. -| Water depth | CableDyn | OrcaFlex | Refined MoorDyn-F | -| ---: | ---: | ---: | ---: | -| 80 m | 1.55 s (1.55–1.84), 240 elements, 0.05 s | 2.29 s (2.26–2.31), 179 segments, 0.05 s | 249.07 s (249.04–363.90), 208 segments, 0.125 ms | -| 200 m | 4.64 s (4.62–4.69), 480 elements, 0.05 s | 4.56 s (4.51–4.74), 350 segments, 0.05 s | 144.61 s (142.70–214.11), 224 segments, 0.125 ms | -| 800 m | 7.37 s (7.37–7.54), 960 elements, 0.05 s | 23.17 s (23.00–23.42), 1227 segments, 0.05 s | 243.08 s (240.15–249.30), 232 segments, 0.5 ms | +## L5-DPC: pinned-touchdown lazy-wave spans, 60 s -The MoorDyn-F models are refined fourfold, and their time step is the largest at which every -reported response changes by less than 0.01 % when the step is halved. That accuracy -requirement is much stricter than the one normally used in design work, and it sets the -explicit time step. The figures are specific to this host, these models and these settings. +| Water depth | CableDyn (IFX) | CableDyn (GNU) | OrcaFlex 11.6d | Refined MoorDyn-F | +| ---: | ---: | ---: | ---: | ---: | +| 80 m | 1.44 s (1.43–1.45) | 2.06 s (2.04–2.06) | 2.69 s (2.68–2.71) | 326.6 s (323.8–343.3) | +| 200 m | 2.56 s (2.54–2.56) | 3.40 s (3.38–3.43) | 5.31 s (5.18–5.64) | 191.9 s (191.7–193.5) | +| 800 m | 5.29 s (5.28–5.48) | 7.59 s (7.47–7.59) | 27.81 s (27.44–28.15) | 320.7 s (319.3–326.9) | +| Mesh, step | 240 / 480 / 960 elements, 0.05 s | as IFX | 179 / 350 / 1227 segments, 0.05 s | 208 / 224 / 232 segments; 0.125 / 0.125 / 0.5 ms | + +CableDyn's static initialisation takes 0.11 / 0.20 / 0.34 s (IFX) of these totals; it takes +3.04 / 2.75 / 3.27 linear solves per step, and no interval needs recovery subdivision. OrcaFlex's +statics take 0.32 / 0.30 / 2.65 s. The MoorDyn-F total includes its dynamic relaxation to +`threshIC` 1e-5. Halving the MoorDyn-F step moves the scored curvature and tension by at most +0.0005 %, inside the 0.01 % that selects the step; that accuracy requirement is much stricter +than the one normally used in design work, and it sets the explicit step. + +## L5-V: VolturnUS-S chain, one hour + +| | CableDyn (IFX) | CableDyn (GNU) | OrcaFlex 11.6d | +| --- | ---: | ---: | ---: | +| Wall time | 4.44 s (4.44–4.45) | 6.18 s (6.16–6.19) | 19.72 s (19.33–19.74) | +| Model | 50 elements, dt 0.1 s, 3600 s | as IFX | 50 segments, dt 0.1 s, 200 s build-up and 3600 s | + +CableDyn takes 2.03 Newton iterations per step over the 36,000 steps. + +## L5-wc: WD0050 chain, 80 s + +| | CableDyn (IFX) | CableDyn (GNU) | MoorDyn-C v2.7.2 | +| --- | ---: | ---: | ---: | +| Wall time | 0.200 s (0.198–0.204) | 0.277 s (0.275–0.406) | 0.482 s (0.476–0.483) | +| Model | 41 elements, dt 0.05 s | as IFX | 41 segments, RK2, dtM 1 ms, coupling step 0.1 s | + +MoorDyn-C's stepping loop takes 0.452 s of its total. Over the scored 20 s the fairlead tensions +of the two codes differ by 1.35 % in mean and 1.71 % in maximum. ## Timing definitions -- CableDyn and OrcaFlex totals comprise static initialisation and the 60 s dynamic march. -- The MoorDyn-F total is the complete dynamic-driver call, including its dynamic-relaxation - initialisation. The separately timed MoorDyn-F static-only call is a diagnostic and is not - added a second time. +- CableDyn, MoorDyn-F and MoorDyn-C: the wall time of the whole process, from start to exit, + including input, initial condition, dynamics and output. +- OrcaFlex: `CalculateStatics` plus `RunSimulation`, timed inside a fresh Python process; model + building, licence checkout and result extraction are outside the timed interval. +- The static and dynamic split of CableDyn comes from a separate run with `CABLEDYN_PROFILE`. +- Peak working set is that of the whole process; for OrcaFlex it includes the Python + interpreter. -CableDyn used 34, 16 and 8 line-search backtracking trials over the 1,200 timed intervals at -80, 200 and 800 m, respectively. No timed interval required recovery subdivision. +The figures are specific to this host, these models and these settings. diff --git a/validation/performance/README.md b/validation/performance/README.md index f4c78c1..c08ce99 100644 --- a/validation/performance/README.md +++ b/validation/performance/README.md @@ -1,48 +1,105 @@ -# Lazy-wave cable performance comparison +# Same-host wall-clock records (L5) -This directory records the same-host wall-time comparison of CableDyn, OrcaFlex and MoorDyn-F -on the three spatially resolved Lozon lazy-wave cables. Use it to check the timing figures in -[`VALIDATION.md`](../../VALIDATION.md). +This directory records the same-host wall-clock measurements behind the L5 rows of +[`VALIDATION.md`](../../VALIDATION.md): the matched lazy-wave spans (L5-DPC), the one-hour +VolturnUS-S mooring (L5-V) and the tension-only chain (L5-wc). Every figure comes from +[`validation/scripts/performance_records.py`](../scripts/performance_records.py), which builds the +models, runs and times each code and writes the files below. | File | Contents | | --- | --- | -| `performance_summary.csv` | Per-site, per-solver mesh size, time step, wall and CPU times, memory and Newton statistics | -| `performance_metrics.json` | The same rows in JSON | -| [`PERFORMANCE_AUDIT.md`](PERFORMANCE_AUDIT.md) | Wall times with their ranges, and timing definitions | +| `performance_summary.csv` | L5-DPC: per span, code and build, the mesh, time step, wall and CPU times, memory and solver counts | +| `wallclock_summary.csv` | L5-V and L5-wc in the same columns | +| `performance_metrics.json` | The same rows, the code versions and builds, the host, the date, the responses each timed model reproduced, the MoorDyn-F step checks and the CableDyn driver notes | +| [`PERFORMANCE_AUDIT.md`](PERFORMANCE_AUDIT.md) | Wall times with their ranges, and the timing definitions | | [`ORCAFLEX_MESH_CONVERGENCE.md`](ORCAFLEX_MESH_CONVERGENCE.md) | 800 m OrcaFlex segment-length study; data in `orcaflex_mesh_convergence.csv` | +## Cases + +- **L5-DPC.** The matched 80, 200 and 800 m suspended spans of the L3-6 protocol: hang-off at + (0, 0, −14) m heaved 3 sin(2πt/12) m, touchdown pinned at the static touchdown point, no + seabed in reach, still water, Cdn 1.2, Cdt 0.1, Can 1.0, Cat 0; static initialisation and + 60 s of dynamics. + - CableDyn: driver decks with 240, 480 and 960 cubic-Hermite elements, aligned with the + sections, dt 0.05 s, the default solver settings with the Newton tolerance `1e-4` of the + shipped Lozon decks. The hang-off motion starts at full velocity, as in the L3-6 gate. + - OrcaFlex: the models of `orcaflex_lazywave_dynamic_reference.py`, 0.75 m segments (179, 350 + and 1227 segments; [`ORCAFLEX_MESH_CONVERGENCE.md`](ORCAFLEX_MESH_CONVERGENCE.md)), implicit + dt 0.05 s, a 12 s build-up and 48 s. + - MoorDyn-F: the decks of `moordyn_lazywave_dynamic_reference.py` with the native meshes + refined fourfold (208, 224 and 232 segments), RK4, dtM 0.125 ms at 80 and 200 m and 0.5 ms + at 800 m, its dynamic-relaxation initialisation (`threshIC` 1e-5, `TmaxIC` 900 s) and + 61.5 s of dynamics. These steps are the largest at which halving the step moves every + scored response by less than 0.01 %; the tool re-checks this with `--step-check` + (`moordyn_f_step_checks` in the JSON). The resolved bending modes of the contact-free + cable, not seabed stiffness, set this explicit step. +- **L5-V.** One IEA-15MW VolturnUS-S chain (850 m, 50 elements or segments, 200 m water) with + the fairlead driven for one hour by 5 sin(2πt/90) m of surge and 2 sin(2πt/12) m of heave. + CableDyn runs `examples/iea15mw_volturnus_mooring.dat` with the drive of the + `l3_longduration_mooring` gate (a 100 s half-cosine ramp, dt 0.1 s, 3600 s, no seabed + damping) as a motion file; OrcaFlex runs the model of `orcaflex_longduration_mooring.py` + (implicit dt 0.1 s, 200 s build-up and 3600 s). +- **L5-wc.** The WD0050 grounded chain of `examples/wd0050_chain.dat` (410 m, 41 elements or + segments, 50 m water) under the L2-2 A1 fairlead heave 2 sin(2πt/10) m for 80 s: the 60 s + warm-up and the 20 s scored window of `l2_2_dynamic_parity`. CableDyn runs the deck at + dt 0.05 s. MoorDyn-C runs the same chain through `moordyn_c_driver.cpp` (explicit RK2, + dtM 1 ms, coupling step 0.1 s, its dynamic-relaxation initialisation). + +Before a model is recorded, the tool checks its response against the reference: on L5-DPC the +hang-off tension mean, minimum and maximum over 24–60 s against the OrcaFlex values of the +L3-6 gate (within 2 %), and on L5-V the fairlead tension statistics over 200–3600 s against the +OrcaFlex values of the L3-7 gate (within that gate's bands). The responses are in +`performance_metrics.json`. + ## Protocol -- Models: matched 80, 200 and 800 m suspended spans with a pinned touchdown point, no grounded - tail and no seabed contact. -- Timed work: static initialisation and a 60 s dynamic march. -- Host: one Intel Core i9-13900K processor thread; median of three runs after one warm-up, with - the range retained. -- CableDyn and OrcaFlex use implicit 0.05 s steps. -- MoorDyn-F finite-bending models are refined fourfold and their explicit step is halved until - every reported response changes by less than 0.01%. The retained steps are 0.125 ms at 80 and - 200 m and 0.5 ms at 800 m. These are below the shortest-segment axial transit time because - the resolved bending modes of the contact-free cable, not seabed penalty stiffness, set the - explicit stability limit. -- The 800 m OrcaFlex segment length, 0.75 m, is the converged value from - [`ORCAFLEX_MESH_CONVERGENCE.md`](ORCAFLEX_MESH_CONVERGENCE.md). +- One thread: `OMP_NUM_THREADS = OPENBLAS_NUM_THREADS = 1`, and OrcaFlex `threadCount = 1`. +- Each process at high priority; one warm-up run, then three timed runs. The median and the + range are recorded. +- CableDyn, MoorDyn-F and MoorDyn-C: the wall time of the whole process (input, initial + condition, dynamics and output). OrcaFlex: `CalculateStatics` plus `RunSimulation`, in a + fresh Python process for each run. +- CableDyn's static and dynamic split and its solver counts come from one further run with + `CABLEDYN_PROFILE`. `newton_per_step` is the linear solves per step for the finite-EI spans + and the Newton iterations per line step for the chains. MoorDyn-C's dynamic time is the + stepping loop timed by its driver. +- `host_busy_median` is the busy fraction of all host processors during the timed runs. Other + work was running on the host; the ranges show its effect. ## Provenance -The rows record the host (one thread of an Intel Core i9-13900K), the mesh, the time step and the -timing statistics of each run. The OrcaFlex segment-length study used OrcaFlex 11.6d -([`ORCAFLEX_MESH_CONVERGENCE.md`](ORCAFLEX_MESH_CONVERGENCE.md)). The executables of the three -codes, their build identifiers and the measurement dates were not recorded with the rows, and -the timing models and driver scripts are not included in the repository. Treat the figures as -indicative of this host and these settings; the release timings, with the code versions and -builds used, are in [`PERFORMANCE_0_1_0.md`](../PERFORMANCE_0_1_0.md). - -## Result - -Total times at 80, 200 and 800 m are 1.55, 4.64 and 7.37 s for CableDyn, 2.29, 4.56 and -23.17 s for OrcaFlex, and 249.07, 144.61 and 243.08 s for the refined MoorDyn-F models. The -MoorDyn-F times reflect the explicit step that the fourfold-refined bending mesh requires under -this protocol. The figures are specific to this host, these models and these settings, and do -not apply to the separate tension-only chain benchmark in `VALIDATION.md`. +Measured on 2026-11-08 on an AMD Ryzen Threadripper PRO 7965WX (24 cores), Windows 11 Pro. + +| Code | Build | +| --- | --- | +| CableDyn `ifx-release-0.4.1` | the v0.4.1 release `CableDyn_driver.exe` (IFX 2025.3.2, static, `/fp:precise /arch:CORE-AVX2 /Qfma-`, reference LAPACK 3.12.1) | +| CableDyn `gnu-release-dev` | `dev` at `b7c883b` (v0.4.1-2-gb7c883b): GNU Fortran 16.2.0 (conda-forge), CMake `Release` (`-O3`), baseline x86-64, OpenBLAS 0.3.33 | +| OrcaFlex | OrcaFlex 11.6d (OrcFxAPI DLL 11.6d) from Python, `threadCount = 1` | +| MoorDyn-F | MoorDyn v2.3.8 in the OpenFAST v5.0.0 MoorDyn driver: GNU Fortran 16.2.0, CMake `Release` (`-O3`), `DOUBLE_PRECISION=OFF` | +| MoorDyn-C | v2.7.2 release source: `libmoordyn` by GNU g++ 16.2.0, CMake `Release` (`-O3`); `moordyn_c_driver.cpp` by `g++ -O2` | + +The JSON records the driver hashes. The earlier L5-wc figure used MoorDyn-C v2.6.1 through the +same driver (the trace committed in `tests/data/l2_2_external_refs`). Its deck settings were not +recorded, so v2.7.2 runs the deck written by `performance_models.py`; over the scored window it +differs from the committed v2.6.1 trace by 0.37 % in mean and 1.19 % in maximum tension. + +## Reproduce + +From the repository root, with numpy, the OrcFxAPI Python interface on `PYTHONPATH`, the +OpenFAST MoorDyn driver and a MoorDyn-C build: + +```text +python validation/scripts/performance_records.py run --codes cabledyn \ + --cabledyn-driver --cabledyn-label