From 9f29b08b41b2d1e45d355a4539e647f8372c2a4e Mon Sep 17 00:00:00 2001 From: David Ogden <12831266+dav-og@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:46:06 +0100 Subject: [PATCH 1/4] Fix Windows staging of Chrono Parsers Python runtime DLLs --- CMakeLists.txt | 55 +------ cmake/SeaStackWindowsPythonRuntime.cmake | 186 +++++++++++++++++++++++ scripts/windows/build.ps1 | 103 +++++-------- 3 files changed, 229 insertions(+), 115 deletions(-) create mode 100644 cmake/SeaStackWindowsPythonRuntime.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 9b49936..fd61f87 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -204,57 +204,7 @@ if(SEASTACK_ENABLE_CHRONO) # by chrono-config.cmake and works for both script and IDE builds. if(WIN32) add_CHRONO_DLLS_copy_command() - # Chrono_parsers may link python3xx.dll (PyChrono); it is not in Chrono's bin/. - find_package(Python3 COMPONENTS Interpreter QUIET) - set(SEASTACK_WINDOWS_PYTHON_DLL "") - set(SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS "") - if(Python3_Interpreter_FOUND) - get_filename_component(_seastack_py_exe_dir "${Python3_EXECUTABLE}" DIRECTORY) - set(_seastack_py_dll_name "python${Python3_VERSION_MAJOR}${Python3_VERSION_MINOR}.dll") - foreach(_seastack_py_sub IN ITEMS "" "DLLs") - if(_seastack_py_sub STREQUAL "") - set(_seastack_py_candidate "${_seastack_py_exe_dir}/${_seastack_py_dll_name}") - else() - set(_seastack_py_candidate "${_seastack_py_exe_dir}/${_seastack_py_sub}/${_seastack_py_dll_name}") - endif() - if(EXISTS "${_seastack_py_candidate}") - set(SEASTACK_WINDOWS_PYTHON_DLL "${_seastack_py_candidate}") - break() - endif() - endforeach() - # Stable ABI shim (some layouts); conda/python314 often still needs OpenSSL below. - if(EXISTS "${_seastack_py_exe_dir}/python3.dll") - list(APPEND SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS "${_seastack_py_exe_dir}/python3.dll") - endif() - # Conda/Miniforge: python3xx.dll imports zlib.dll by name (not zlib1.dll from HDF5); - # libssl/libcrypto also live under Library/bin. - set(_seastack_conda_libbin "${_seastack_py_exe_dir}/Library/bin") - if(EXISTS "${_seastack_conda_libbin}") - if(EXISTS "${_seastack_conda_libbin}/zlib.dll") - list(APPEND SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS "${_seastack_conda_libbin}/zlib.dll") - endif() - file(GLOB _seastack_conda_ssl - "${_seastack_conda_libbin}/libssl-*.dll" - "${_seastack_conda_libbin}/libcrypto-*.dll") - list(APPEND SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS ${_seastack_conda_ssl}) - endif() - if(SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS) - list(REMOVE_DUPLICATES SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS) - message(STATUS "Python extra DLLs for packaging (zlib/OpenSSL etc.): ${SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS}") - endif() - if(SEASTACK_WINDOWS_PYTHON_DLL) - message(STATUS "Python runtime DLL for packaging: ${SEASTACK_WINDOWS_PYTHON_DLL}") - else() - message(WARNING - "Python interpreter found (${Python3_EXECUTABLE}) but ${_seastack_py_dll_name} not beside it or in DLLs/. " - "Packaged/naked run_seastack.exe may fail (0xC0000135) if Chrono_parsers links Python. " - "Use the same PythonRoot in build-config.json as Chrono's PyChrono build, or rebuild Chrono without Python in parsers.") - endif() - else() - message(WARNING - "Python3 interpreter not found at configure time. If Chrono_parsers.dll imports python*.dll, " - "add Python to PATH or pass -DPython3_ROOT_DIR (see build-config.json PythonRoot) so the matching DLL can be installed.") - endif() + include("${CMAKE_SOURCE_DIR}/cmake/SeaStackWindowsPythonRuntime.cmake") endif() endif() @@ -398,6 +348,7 @@ if(SEASTACK_ENABLE_CHRONO) if(SEASTACK_ENABLE_APPS) add_subdirectory(apps/seastack) + seastack_windows_python_runtime_postbuild(run_seastack) endif() endif() @@ -640,7 +591,7 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows") endif() endif() - # Python runtime DLL required when Chrono_parsers is built with PyChrono (not in Chrono bin/). + # libpython (pythonNN.dll) when Chrono_parsers imports it — not shipped next to Chrono DLLs. if(DEFINED SEASTACK_WINDOWS_PYTHON_DLL AND SEASTACK_WINDOWS_PYTHON_DLL) install(FILES "${SEASTACK_WINDOWS_PYTHON_DLL}" DESTINATION bin COMPONENT runtime) endif() diff --git a/cmake/SeaStackWindowsPythonRuntime.cmake b/cmake/SeaStackWindowsPythonRuntime.cmake new file mode 100644 index 0000000..4e75ce6 --- /dev/null +++ b/cmake/SeaStackWindowsPythonRuntime.cmake @@ -0,0 +1,186 @@ +# Windows: locate libpython (pythonNN.dll) and peers required at load time by Chrono_parsers, for install() and POST_BUILD. +# Include only under if(WIN32) after find_package(Chrono COMPONENTS ... Parsers) and add_CHRONO_DLLS_copy_command(). +# Outputs: SEASTACK_WINDOWS_PYTHON_DLL, SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS (parent scope). + +# Chrono_parsers may import python3xx.dll (Parsers embedded Python / Python C API); it is not in Chrono's bin/. +# Use the same discovery for install() and for POST_BUILD copies to bin/ (single source of truth). +find_package(Python3 COMPONENTS Interpreter QUIET) +set(SEASTACK_WINDOWS_PYTHON_DLL "") +set(SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS "") +set(_seastack_py_exe_dir "") +set(_seastack_py_search_roots "") +if(Python3_Interpreter_FOUND) + get_filename_component(_seastack_py_exe_dir "${Python3_EXECUTABLE}" DIRECTORY) + list(APPEND _seastack_py_search_roots "${_seastack_py_exe_dir}") +endif() +if(DEFINED Python3_ROOT_DIR AND NOT "${Python3_ROOT_DIR}" STREQUAL "" AND EXISTS "${Python3_ROOT_DIR}") + list(APPEND _seastack_py_search_roots "${Python3_ROOT_DIR}") +endif() +if(_seastack_py_search_roots) + list(REMOVE_DUPLICATES _seastack_py_search_roots) +endif() + +# Prefer the exact pythonNN.dll that Chrono_parsers imports, not only the DLL name implied by FindPython3. +set(_seastack_required_python_dll "") +if(MSVC AND TARGET Chrono::Chrono_parsers) + set(_seastack_chrono_parsers_dll "") + foreach(_cfg RELEASE RELWITHDEBINFO MINSIZEREL DEBUG) + string(TOUPPER "${_cfg}" _cfg_u) + get_target_property(_loc Chrono::Chrono_parsers IMPORTED_LOCATION_${_cfg_u}) + if(_loc AND NOT _loc STREQUAL "Chrono::Chrono_parsers-NOTFOUND" AND EXISTS "${_loc}") + set(_seastack_chrono_parsers_dll "${_loc}") + break() + endif() + endforeach() + if(NOT _seastack_chrono_parsers_dll) + get_target_property(_loc Chrono::Chrono_parsers IMPORTED_LOCATION) + if(_loc AND NOT _loc STREQUAL "Chrono::Chrono_parsers-NOTFOUND" AND EXISTS "${_loc}") + set(_seastack_chrono_parsers_dll "${_loc}") + endif() + endif() + if(_seastack_chrono_parsers_dll) + get_filename_component(_seastack_dumpbin_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) + find_program(SEASTACK_DUMPBIN dumpbin HINTS "${_seastack_dumpbin_dir}") + if(NOT SEASTACK_DUMPBIN) + find_program(SEASTACK_DUMPBIN dumpbin) + endif() + if(SEASTACK_DUMPBIN) + execute_process( + COMMAND "${SEASTACK_DUMPBIN}" /nologo /dependents "${_seastack_chrono_parsers_dll}" + OUTPUT_VARIABLE _seastack_db_out + ERROR_VARIABLE _seastack_db_err + ) + string(CONCAT _seastack_db_all "${_seastack_db_out}" "${_seastack_db_err}") + string(TOLOWER "${_seastack_db_all}" _seastack_db_lc) + string(REGEX MATCH "python[0-9][0-9]+\\.dll" _seastack_dm "${_seastack_db_lc}") + if(_seastack_dm) + set(_seastack_required_python_dll "${_seastack_dm}") + message(STATUS "Chrono_parsers.dll load-time Python import: ${_seastack_required_python_dll}") + endif() + endif() + endif() +endif() + +if(_seastack_required_python_dll AND _seastack_py_search_roots) + foreach(_root ${_seastack_py_search_roots}) + foreach(_sub IN ITEMS "" "DLLs" "Library/bin") + if(_sub STREQUAL "") + set(_cand "${_root}/${_seastack_required_python_dll}") + else() + set(_cand "${_root}/${_sub}/${_seastack_required_python_dll}") + endif() + if(EXISTS "${_cand}") + set(SEASTACK_WINDOWS_PYTHON_DLL "${_cand}") + break() + endif() + endforeach() + if(SEASTACK_WINDOWS_PYTHON_DLL) + break() + endif() + endforeach() + if(NOT SEASTACK_WINDOWS_PYTHON_DLL) + message(WARNING + "Chrono_parsers imports ${_seastack_required_python_dll} but that file was not found under Python search roots " + "(${_seastack_py_search_roots}). Set PythonRoot/Python3_ROOT_DIR to the same Python environment Chrono's Parsers " + "module was built against, then reconfigure (clear CMake cache if needed). Packaged run_seastack may fail (0xC0000135).") + endif() +elseif(Python3_Interpreter_FOUND) + set(_seastack_py_dll_name "python${Python3_VERSION_MAJOR}${Python3_VERSION_MINOR}.dll") + foreach(_root ${_seastack_py_search_roots}) + foreach(_sub IN ITEMS "" "DLLs" "Library/bin") + if(_sub STREQUAL "") + set(_cand "${_root}/${_seastack_py_dll_name}") + else() + set(_cand "${_root}/${_sub}/${_seastack_py_dll_name}") + endif() + if(EXISTS "${_cand}") + set(SEASTACK_WINDOWS_PYTHON_DLL "${_cand}") + break() + endif() + endforeach() + if(SEASTACK_WINDOWS_PYTHON_DLL) + break() + endif() + endforeach() + if(NOT SEASTACK_WINDOWS_PYTHON_DLL) + message(WARNING + "Python interpreter found (${Python3_EXECUTABLE}) but ${_seastack_py_dll_name} not under env root, DLLs/, or Library/bin/. " + "Packaged/naked run_seastack.exe may fail (0xC0000135) if Chrono_parsers links Python. " + "Use the same PythonRoot in build-config.json as the Python used when building Chrono's Parsers module, or rebuild " + "Chrono with Parsers not linked to Python if your upstream build allows it.") + endif() +elseif(_seastack_required_python_dll) + message(WARNING + "Chrono_parsers imports ${_seastack_required_python_dll} but Python3 was not found at configure time. " + "Add Python to PATH or pass -DPython3_ROOT_DIR (see build-config.json PythonRoot).") +endif() + +# Stable ABI shim and conda peers (zlib/OpenSSL): beside interpreter, beside resolved pythonNN.dll, and Library/bin. +if(Python3_Interpreter_FOUND OR SEASTACK_WINDOWS_PYTHON_DLL) + if(Python3_Interpreter_FOUND) + if(EXISTS "${_seastack_py_exe_dir}/python3.dll") + list(APPEND SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS "${_seastack_py_exe_dir}/python3.dll") + endif() + set(_seastack_conda_libbin "${_seastack_py_exe_dir}/Library/bin") + if(EXISTS "${_seastack_conda_libbin}/python3.dll") + list(APPEND SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS "${_seastack_conda_libbin}/python3.dll") + endif() + if(EXISTS "${_seastack_conda_libbin}") + if(EXISTS "${_seastack_conda_libbin}/zlib.dll") + list(APPEND SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS "${_seastack_conda_libbin}/zlib.dll") + endif() + file(GLOB _seastack_conda_ssl + "${_seastack_conda_libbin}/libssl-*.dll" + "${_seastack_conda_libbin}/libcrypto-*.dll") + list(APPEND SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS ${_seastack_conda_ssl}) + endif() + endif() + if(SEASTACK_WINDOWS_PYTHON_DLL) + get_filename_component(_seastack_py_dll_dir "${SEASTACK_WINDOWS_PYTHON_DLL}" DIRECTORY) + if(EXISTS "${_seastack_py_dll_dir}/python3.dll") + list(APPEND SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS "${_seastack_py_dll_dir}/python3.dll") + endif() + if(EXISTS "${_seastack_py_dll_dir}/zlib.dll") + list(APPEND SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS "${_seastack_py_dll_dir}/zlib.dll") + endif() + file(GLOB _seastack_peer_ssl + "${_seastack_py_dll_dir}/libssl-*.dll" + "${_seastack_py_dll_dir}/libcrypto-*.dll") + list(APPEND SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS ${_seastack_peer_ssl}) + endif() + if(SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS) + list(REMOVE_DUPLICATES SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS) + message(STATUS "Python extra DLLs for packaging (zlib/OpenSSL etc.): ${SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS}") + endif() + if(SEASTACK_WINDOWS_PYTHON_DLL) + message(STATUS "Python runtime DLL for packaging: ${SEASTACK_WINDOWS_PYTHON_DLL}") + endif() +endif() + +if(NOT Python3_Interpreter_FOUND AND NOT _seastack_required_python_dll) + message(WARNING + "Python3 interpreter not found at configure time. If Chrono_parsers.dll imports python*.dll, " + "add Python to PATH or pass -DPython3_ROOT_DIR (see build-config.json PythonRoot) so the matching DLL can be installed.") +endif() + +# Call after add_executable(run_seastack): copies the same DLL set as install(FILES ...). +macro(seastack_windows_python_runtime_postbuild _target) + if(WIN32 AND TARGET "${_target}") + if(SEASTACK_WINDOWS_PYTHON_DLL) + add_custom_command(TARGET "${_target}" POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${SEASTACK_WINDOWS_PYTHON_DLL}" + "$" + COMMENT "SEA-Stack: copy Python runtime DLL for Chrono_parsers") + endif() + foreach(_seastack_wpyx IN LISTS SEASTACK_WINDOWS_PYTHON_EXTRA_DLLS) + if(EXISTS "${_seastack_wpyx}") + add_custom_command(TARGET "${_target}" POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_seastack_wpyx}" + "$" + COMMENT "SEA-Stack: copy Python-related DLL") + endif() + endforeach() + endif() +endmacro() diff --git a/scripts/windows/build.ps1 b/scripts/windows/build.ps1 index a0faeda..7f7efa7 100644 --- a/scripts/windows/build.ps1 +++ b/scripts/windows/build.ps1 @@ -227,6 +227,39 @@ function Get-CMakeCacheVariable { } # Infer VSG DLL directory from ChronoConfig.cmake vsg_DIR when CMake did not set VSG_DLL_DIR. +function Write-SeaStackChronoParsersPythonDllCheck { + param( + [Parameter(Mandatory)][string]$BinDir, + [Parameter(Mandatory)][string]$DiagLogPath + ) + $parsers = Join-Path $BinDir 'Chrono_parsers.dll' + if (-not (Test-Path -LiteralPath $parsers)) { + return + } + $dumpbinCmd = Get-Command dumpbin -ErrorAction SilentlyContinue + if (-not $dumpbinCmd) { + Write-Detail 'dumpbin not on PATH; skipping Chrono_parsers Python import check' + Add-SeaStackDiagLog -Path $DiagLogPath -Line 'Chrono_parsers Python check skipped (no dumpbin)' + return + } + $raw = & dumpbin /nologo /dependents $parsers 2>&1 + $imp = $null + foreach ($line in $raw) { + if ([string]$line -match '(?i)\b(python\d{2,}\.dll)\b') { + $imp = $Matches[1].ToLowerInvariant() + break + } + } + if (-not $imp) { + return + } + Add-SeaStackDiagLog -Path $DiagLogPath -Line "Chrono_parsers.dll imports: $imp" + $targetPath = Join-Path $BinDir $imp + if (-not (Test-Path -LiteralPath $targetPath)) { + Write-Warn "Chrono_parsers.dll imports $imp but that file is not in the same folder as the app ($BinDir). Loader may fail (0xC0000135)." + } +} + function Resolve-SeaStackVsgDllDirFromChronoVsgDir { param([string]$VsgCmakeDir) if ([string]::IsNullOrWhiteSpace($VsgCmakeDir)) { @@ -639,7 +672,7 @@ if ($useChrono) { Write-OK ('Chrono_DIR: ' + $ChronoDir) if ([string]::IsNullOrWhiteSpace($PythonRoot)) { - Write-Warn 'PythonRoot not set in build-config.json: CMake may not find the Python runtime DLL for packaging. Set it to the same conda/env used when Chrono was built with PyChrono, or run_seastack may fail to start (0xC0000135) on machines without that DLL on PATH.' + Write-Warn 'PythonRoot not set in build-config.json: CMake may not find the Python runtime DLL for packaging. Set it to the same conda/env Python that Chrono''s Parsers module was linked against (libpython), or run_seastack may fail to start (0xC0000135) on machines without that DLL on PATH.' } Write-SeaStackStep "Chrono pre-configure hints (optional)" @@ -955,56 +988,7 @@ if ($useChrono -and $chronoContent) { } } -# Chrono_parsers may import python3xx.dll (not shipped in Chrono's bin/). Copy -# next to run_seastack for local bin\ runs; CPack also installs via CMake. -if ($useChrono -and $PythonRoot -and (Test-Path -LiteralPath $binPath)) { - $pyExe = Join-Path $PythonRoot "python.exe" - if (Test-Path -LiteralPath $pyExe) { - $pyVerOut = & $pyExe -c "import sys; print(sys.version_info.major, sys.version_info.minor)" 2>$null - if ($pyVerOut -match '^(\d+)\s+(\d+)') { - $dllName = "python{0}{1}.dll" -f $Matches[1], $Matches[2] - $pyDllSrc = $null - foreach ($cand in @( - (Join-Path $PythonRoot $dllName), - (Join-Path $PythonRoot "DLLs\$dllName") - )) { - if (Test-Path -LiteralPath $cand) { - $pyDllSrc = $cand - break - } - } - if ($pyDllSrc) { - Copy-Item -LiteralPath $pyDllSrc -Destination (Join-Path $binPath $dllName) -Force - Write-OK "Copied $dllName for Chrono_parsers load-time dependency" - } else { - Write-Warn "Could not find $dllName under PythonRoot (local run_seastack may need Python on PATH)" - } - $py3dll = Join-Path $PythonRoot "python3.dll" - if (Test-Path -LiteralPath $py3dll) { - Copy-Item -LiteralPath $py3dll -Destination (Join-Path $binPath "python3.dll") -Force - Write-OK "Copied python3.dll (Python stable ABI shim)" - } - $condaLibBin = Join-Path $PythonRoot "Library\bin" - if (Test-Path -LiteralPath $condaLibBin) { - $zlibDll = Join-Path $condaLibBin "zlib.dll" - if (Test-Path -LiteralPath $zlibDll) { - Copy-Item -LiteralPath $zlibDll -Destination (Join-Path $binPath "zlib.dll") -Force - Write-OK "Copied zlib.dll (python314.dll import; distinct from zlib1.dll from HDF5)" - } - $n = 0 - foreach ($pat in @('libssl-*.dll', 'libcrypto-*.dll')) { - Get-ChildItem -Path $condaLibBin -Filter $pat -ErrorAction SilentlyContinue | ForEach-Object { - Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $binPath $_.Name) -Force - $n++ - } - } - if ($n -gt 0) { - Write-OK "Copied $n Conda OpenSSL DLL(s) from Library\bin (python314 may load them)" - } - } - } - } -} +# Python runtime for Chrono_parsers is staged by CMake (install + POST_BUILD on run_seastack); see CMakeLists.txt. # ============================================================================= # Verify outputs @@ -1026,6 +1010,7 @@ if (Test-Path -LiteralPath $binDir) { $size = [math]::Round((Get-Item -LiteralPath $app).Length / 1MB, 1) Write-OK ('run_seastack.exe (' + $size + ' MB)') } + Write-SeaStackChronoParsersPythonDllCheck -BinDir $binDir -DiagLogPath $diagLogPath } # standalone_controller is an SDK-only install target (not shipped in the runtime ZIP). @@ -1103,24 +1088,16 @@ if ($Package) { $binDirForSmoke = Join-Path $prefix "bin" $oldSmokePath = $env:PATH try { - # Loader searches the exe directory first; optional conda Library\bin for DLLs CMake did not copy. - $smokePathChunks = @($binDirForSmoke) - if ($PythonRoot -and -not [string]::IsNullOrWhiteSpace([string]$PythonRoot)) { - $smokePathChunks += [string]$PythonRoot - $condaLibBinSmoke = Join-Path $PythonRoot 'Library\bin' - if (Test-Path -LiteralPath $condaLibBinSmoke) { - $smokePathChunks += $condaLibBinSmoke - } - } - $smokePathChunks += $oldSmokePath - $env:PATH = $smokePathChunks -join ';' + # Loader uses the staged bin\ folder first (must contain pythonNN.dll peers installed by CMake). + $env:PATH = ($binDirForSmoke + ';' + $oldSmokePath) + Write-SeaStackChronoParsersPythonDllCheck -BinDir $binDirForSmoke -DiagLogPath $diagLogPath $p = Start-Process -FilePath $stagedExe -ArgumentList @('--help') -WorkingDirectory $prefix -Wait -PassThru -NoNewWindow } finally { $env:PATH = $oldSmokePath } if ($null -eq $p.ExitCode -or $p.ExitCode -ne 0) { $ec = $p.ExitCode - Write-Fail "Staged run_seastack.exe --help failed (exit $ec). Ensure PythonRoot matches Chrono's PyChrono env; conda builds need python314.dll plus OpenSSL DLLs from Library\bin (now installed by CMake when found). Reconfigure and reinstall." + Write-Fail "Staged run_seastack.exe --help failed (exit $ec). Reconfigure with PythonRoot matching the Python used for Chrono Parsers (or -DPython3_ROOT_DIR); CMake stages the pythonNN.dll Chrono_parsers imports. Use -Clean if CMake cached the wrong Python." exit 1 } Write-OK "Staged run_seastack.exe starts (--help)" From d94b2bc0063e7e43f39d419642ba8585e9db756f Mon Sep 17 00:00:00 2001 From: David Ogden <12831266+dav-og@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:57:05 +0100 Subject: [PATCH 2/4] Fix CMake scope for run_seastack post-build DLL copy --- CMakeLists.txt | 1 - apps/seastack/CMakeLists.txt | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index fd61f87..4ab32f3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -348,7 +348,6 @@ if(SEASTACK_ENABLE_CHRONO) if(SEASTACK_ENABLE_APPS) add_subdirectory(apps/seastack) - seastack_windows_python_runtime_postbuild(run_seastack) endif() endif() diff --git a/apps/seastack/CMakeLists.txt b/apps/seastack/CMakeLists.txt index 49bb870..a018681 100644 --- a/apps/seastack/CMakeLists.txt +++ b/apps/seastack/CMakeLists.txt @@ -68,3 +68,8 @@ if(APPLE) elseif(UNIX) set_target_properties(run_seastack PROPERTIES INSTALL_RPATH "\$ORIGIN/../lib") endif() + +# POST_BUILD must run in this directory (same as add_executable(run_seastack)); macro from SeaStackWindowsPythonRuntime.cmake. +if(WIN32) + seastack_windows_python_runtime_postbuild(run_seastack) +endif() From 658967ceb6af89fbfed08920efb8e5619ee33e39 Mon Sep 17 00:00:00 2001 From: alliemr123 Date: Tue, 5 May 2026 23:20:11 -0600 Subject: [PATCH 3/4] Add h5 reader for outputs to plot various device responses --- data/demos/run_seastack/h5outputsReader.py | 287 +++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 data/demos/run_seastack/h5outputsReader.py diff --git a/data/demos/run_seastack/h5outputsReader.py b/data/demos/run_seastack/h5outputsReader.py new file mode 100644 index 0000000..b8c7052 --- /dev/null +++ b/data/demos/run_seastack/h5outputsReader.py @@ -0,0 +1,287 @@ + +import h5py +import matplotlib.pyplot as plt +import argparse + +def parse_args(): + parser = argparse.ArgumentParser() + + parser.add_argument("path", help="Define path to HDF5 file") + + parser.add_argument("--bodies", nargs="*", type=int, + help="Body number(s) (default: all)") + + parser.add_argument("--field", nargs="+", + default=["position"], + choices=["position", "velocity", "acceleration", "energy", "power", "joint_force", "joint_torque", "tsda_force"], + help="Data to plot (can pass multiple)") + + parser.add_argument("--dof", nargs="+", + default= ["heave"], + choices=["surge", "sway", "heave"], + help="Degree of freedom(s)") + + parser.add_argument("--joints", nargs="*", type=str, + help="Joint number(s) ie. 12 23 34 etc. (default: all)") + + parser.add_argument("--tsdas", nargs="*", type=str, + help="TSDA names (default: all)") + + parser.add_argument("--list", action="store_true", + help="List available joint and TSDA force outputs") + + return parser.parse_args() + +DOF_MAP = { + "surge": 0, + "sway": 1, + "heave": 2 +} + +JOINT_MAP = { + "joint_force": "reaction1_force", + "joint_torque": "reaction1_torque" +} + +BODY_FIELDS = {"position", "velocity", "acceleration"} +TSDA_FIELDS = {"energy", "power", "tsda_force"} +JOINT_FIELDS = {"joint_force", "joint_torque"} + +TSDA_MAP = { + "tsda_force": "reaction_force_body1" +} + +FIELD_META = { + "position": { + "title": "Body Position", + "ylabel": "Position [m]" + }, + "velocity": { + "title": "Body Velocity", + "ylabel": "Velocity [m/s]" + }, + "acceleration": { + "title": "Body Acceleration", + "ylabel": "Acceleration [m/s²]" + }, + "energy": { + "title": "Cumulative Energy", + "ylabel": "Energy [J]" + }, + "power": { + "title": "Instantaneous Power", + "ylabel": "Power [W]" + }, + "joint_force": { + "title": "Reaction Force", + "ylabel": "Force [N]" + }, + "reactTorque": { + "title": "Reaction Torque", + "ylabel": "Torque [N m]" + } +} + +FIELD_META["tsda_force"] = { + "title": "TSDA Reaction Force (Body 1)", + "ylabel": "Force [N]" +} + +def load_data(path, bodies, fields, joints, tsdas): + import numpy as np + + data = {} + + with h5py.File(path, "r") as f: + + time = f["results/time/time"][:] + + if any(f in {"position", "velocity", "acceleration"} for f in fields): + + all_bodies = [ + b for b in f["results/model/bodies"].keys() + if b != "ground" + ] + + selected_bodies = ( + all_bodies if bodies is None else [f"body{b}" for b in bodies] + ) + + for body in selected_bodies: + data[body] = {} + + for field in fields: + if field in {"position", "velocity", "acceleration"}: + data[body][field] = f[ + f"results/model/bodies/{body}/{field}" + ][:] + + if any(f in {"joint_force", "joint_torque"} for f in fields): + + all_joints = list(f["results/model/joints"].keys()) + selected_joints = all_joints if joints is None else joints + + for joint in selected_joints: + key = f"joint_{joint}" if not joint.startswith("joint_") else joint + + data[key] = {} + + for field in fields: + if field in JOINT_MAP: + path = f"results/model/joints/{joint}/{JOINT_MAP[field]}" + + if path in f: + data[key][field] = f[path][:] + + + if any(f in TSDA_FIELDS for f in fields): + + tsda_group = f["results/model/tsdas"] + all_tsdas = [k for k in tsda_group.keys() if k != "names"] + + selected_tsdas = all_tsdas if tsdas is None else tsdas + + for name in selected_tsdas: + + if name not in tsda_group: + continue + + tsda = tsda_group[name] + key = f"tsda_{name}" + + data[key] = {} + + for field in fields: + + if field == "energy": + data[key][field] = tsda["absorbed_energy"][:] + + elif field == "power": + data[key][field] = tsda["absorbed_power"][:] + + elif field in TSDA_MAP: + h5name = TSDA_MAP[field] + + if h5name in tsda: + data[key][field] = tsda[h5name][:] + + return time, data + +def plot_data(time, data, fields, dofs=None, joints=None): + + if isinstance(dofs, str): + dofs = [dofs] + + for field in fields: + if field in BODY_FIELDS: + + if not dofs: + raise ValueError(f"{field} requires at least one DOF") + + for dof in dofs: + plt.figure() + + meta = FIELD_META.get(field, {}) + title = f"{meta.get('title', field)} ({dof})" + ylabel = meta.get("ylabel", field) + + for key, group in data.items(): + + if field not in group: + continue + + y = group[field] + idx = DOF_MAP[dof] + y = y[:, idx] + + plt.plot(time, y, label=key) + + plt.title(title) + plt.xlabel("Time [s]") + plt.ylabel(ylabel) + plt.legend() + plt.grid(True) + + elif field in JOINT_FIELDS or field in TSDA_FIELDS: + plt.figure() + + meta = FIELD_META.get(field, {}) + title = meta.get("title", field) + ylabel = meta.get("ylabel", field) + # label = key.replace("tsda_", "").replace("joint_", "") + + for key, group in data.items(): + + if field not in group: + continue + + label = key.replace("tsda_", "").replace("joint_", "") + + y = group[field] + + if len(y.shape) == 2: + y = y[:, 2] # default to heave (z) + + plt.plot(time, y, label=label) + + plt.title(title) + plt.xlabel("Time [s]") + plt.ylabel(ylabel) + plt.legend() + plt.grid(True) + plt.show() + + # input("Press Enter OR close all plots to continue...\n") + # plt.close("all") + + +def list_outputs(path): + with h5py.File(path, "r") as f: + + print("\n--- JOINT OUTPUTS ---") + joints = f["results/model/joints"] + + for joint_name in joints.keys(): + joint = joints[joint_name] + + if "reaction1_force" in joint: + print(f"{joint_name} → joint_force") + + if "reaction1_torque" in joint: + print(f"{joint_name} → joint_torque") + + print("\n--- TSDA OUTPUTS ---") + tsdas = f["results/model/tsdas"] + + for name in tsdas.keys(): + if name == "names": + continue + + tsda = tsdas[name] + + if "reaction_force_body1" in tsda: + print(f"{name} → tsda_force") + + print() + + +def main(): + args = parse_args() + if args.list: + list_outputs(args.path) + return + + if not args.path: + raise ValueError("You must provide a path to an HDF5 file.") + time, data = load_data(args.path, args.bodies, args.field, args.joints, args.tsdas) + plot_data(time, data, args.field, args.dof, args.joints) + + if "energy" in args.field and "TSDA_total" in data: + total_energy = data["TSDA_total"]["energy"][-1] + print(f"Total absorbed energy: {total_energy:.3f} J") + + + +if __name__ == "__main__": + main() + From da54829e9e16eef615744f80207a9e093d1042f8 Mon Sep 17 00:00:00 2001 From: alliemr123 Date: Wed, 6 May 2026 12:41:06 -0600 Subject: [PATCH 4/4] Finalized generalized funtions (mostly for TSDA plotting) --- data/demos/run_seastack/h5outputsReader.py | 72 ++++++++++++++++------ 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/data/demos/run_seastack/h5outputsReader.py b/data/demos/run_seastack/h5outputsReader.py index b8c7052..7e60bc4 100644 --- a/data/demos/run_seastack/h5outputsReader.py +++ b/data/demos/run_seastack/h5outputsReader.py @@ -139,31 +139,57 @@ def load_data(path, bodies, fields, joints, tsdas): tsda_group = f["results/model/tsdas"] all_tsdas = [k for k in tsda_group.keys() if k != "names"] - selected_tsdas = all_tsdas if tsdas is None else tsdas + use_total = tsdas is not None and "all" in tsdas - for name in selected_tsdas: + if use_total: + total_energy = None + total_power = None - if name not in tsda_group: - continue + for name in all_tsdas: + tsda = tsda_group[name] - tsda = tsda_group[name] - key = f"tsda_{name}" + if "energy" in fields and "absorbed_energy" in tsda: + e = tsda["absorbed_energy"][:] + total_energy = e if total_energy is None else total_energy + e - data[key] = {} + if "power" in fields and "absorbed_power" in tsda: + p = tsda["absorbed_power"][:] + total_power = p if total_power is None else total_power + p - for field in fields: + data["TSDA_total"] = {} + + if total_energy is not None: + data["TSDA_total"]["energy"] = total_energy + + if total_power is not None: + data["TSDA_total"]["power"] = total_power + + else: + selected_tsdas = all_tsdas if tsdas is None else tsdas + + for name in selected_tsdas: - if field == "energy": - data[key][field] = tsda["absorbed_energy"][:] + if name not in tsda_group: + continue + + tsda = tsda_group[name] + key = f"tsda_{name}" + + data[key] = {} - elif field == "power": - data[key][field] = tsda["absorbed_power"][:] + for field in fields: - elif field in TSDA_MAP: - h5name = TSDA_MAP[field] + if field == "energy": + data[key][field] = tsda["absorbed_energy"][:] - if h5name in tsda: - data[key][field] = tsda[h5name][:] + elif field == "power": + data[key][field] = tsda["absorbed_power"][:] + + elif field in TSDA_MAP: + h5name = TSDA_MAP[field] + + if h5name in tsda: + data[key][field] = tsda[h5name][:] return time, data @@ -276,12 +302,18 @@ def main(): time, data = load_data(args.path, args.bodies, args.field, args.joints, args.tsdas) plot_data(time, data, args.field, args.dof, args.joints) - if "energy" in args.field and "TSDA_total" in data: - total_energy = data["TSDA_total"]["energy"][-1] - print(f"Total absorbed energy: {total_energy:.3f} J") - + if "TSDA_total" in data: + if "energy" in data["TSDA_total"]: + total_energy = data["TSDA_total"]["energy"][-1] + print(f"\nTotal absorbed energy (all TSDAs): {total_energy:.3f} J") + if "power" in data["TSDA_total"]: + power = data["TSDA_total"]["power"] + avg_power = power.mean() + peak_power = power.max() + print(f"Average power (all TSDAs): {avg_power:.3f} W") + print(f"Peak power (all TSDAs): {peak_power:.3f} W") if __name__ == "__main__": main()