From 1e9e191828fb8f31ec1faceac579e0dacad3121a Mon Sep 17 00:00:00 2001 From: cbuahin Date: Sat, 23 May 2026 12:01:06 -0400 Subject: [PATCH 01/26] Promote -Wimplicit-function-declaration to error in all presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implicit declarations silently truncate pointer-returning libc calls to 32-bit int — exactly the bug that produced the Linux x64 Debug segfaults fixed in 553ec210. Make the build fail loudly the next time a POSIX/libc call sneaks into a translation unit without a visible prototype. Cross-platform flags: - gcc / clang (Linux, Darwin): -Werror=implicit-function-declaration - MSVC (Windows): /we4013 (promotes warning C4013 — "function undefined; assuming extern returning int" — to error) C++ is unaffected: implicit declarations are already illegal in the language, so the flag is C-only (added to CMAKE_C_FLAGS only). Also fix src/legacy/output/CMakeLists.txt with C_EXTENSIONS ON so swmm_output.c's fseeko()/ftello() are visible under glibc strict ANSI — mirrors the legacy-engine fix in 553ec210. Without this the Werror would block the Linux build. Signed-off-by: cbuahin --- CMakePresets.json | 12 ++++++------ src/legacy/output/CMakeLists.txt | 7 +++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CMakePresets.json b/CMakePresets.json index a69b97551..8ca627c71 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -30,7 +30,7 @@ "binaryDir": "${sourceDir}/build/windows", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "CMAKE_C_FLAGS": "/O2 /GL /fp:precise /W4", + "CMAKE_C_FLAGS": "/O2 /GL /fp:precise /W4 /we4013", "CMAKE_CXX_FLAGS": "/O2 /GL /fp:precise /W4", "CMAKE_EXE_LINKER_FLAGS": "/LTCG /OPT:REF /OPT:ICF", "CMAKE_SHARED_LINKER_FLAGS": "/LTCG /OPT:REF /OPT:ICF", @@ -60,7 +60,7 @@ "binaryDir": "${sourceDir}/build/windows-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "CMAKE_C_FLAGS": "/Zi /Od /DDEBUG /W4 /fp:precise", + "CMAKE_C_FLAGS": "/Zi /Od /DDEBUG /W4 /we4013 /fp:precise", "CMAKE_CXX_FLAGS": "/Zi /Od /DDEBUG /W4 /fp:precise", "CMAKE_EXE_LINKER_FLAGS": "/DEBUG", "CMAKE_EXPORT_COMPILE_COMMANDS": "YES", @@ -81,7 +81,7 @@ "binaryDir": "${sourceDir}/build/linux", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "CMAKE_C_FLAGS": "-O2 -flto -fdata-sections -ffunction-sections -fipa-icf -fno-fast-math -fexcess-precision=standard -ffloat-store -Wall -Wextra -fvisibility=hidden", + "CMAKE_C_FLAGS": "-O2 -flto -fdata-sections -ffunction-sections -fipa-icf -fno-fast-math -fexcess-precision=standard -ffloat-store -Wall -Wextra -Werror=implicit-function-declaration -fvisibility=hidden", "CMAKE_CXX_FLAGS": "-O2 -flto -fdata-sections -ffunction-sections -fipa-icf -fno-fast-math -fexcess-precision=standard -ffloat-store -Wall -Wextra -fvisibility=hidden", "CMAKE_EXE_LINKER_FLAGS": "-Wl,--gc-sections -flto", "CMAKE_SHARED_LINKER_FLAGS": "-Wl,--gc-sections -flto", @@ -106,7 +106,7 @@ "binaryDir": "${sourceDir}/build/linux-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "CMAKE_C_FLAGS": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math -fexcess-precision=standard -ffloat-store -fvisibility=hidden", + "CMAKE_C_FLAGS": "-O0 -g -DDEBUG -Wall -Wextra -Werror=implicit-function-declaration -fno-fast-math -fexcess-precision=standard -ffloat-store -fvisibility=hidden", "CMAKE_CXX_FLAGS": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math -fexcess-precision=standard -ffloat-store -fvisibility=hidden", "CMAKE_EXE_LINKER_FLAGS": "-g", "CMAKE_EXPORT_COMPILE_COMMANDS": "YES", @@ -132,7 +132,7 @@ }, "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "CMAKE_C_FLAGS": "-O2 -flto -fdata-sections -ffunction-sections -fno-fast-math -Wall -Wextra -fvisibility=hidden", + "CMAKE_C_FLAGS": "-O2 -flto -fdata-sections -ffunction-sections -fno-fast-math -Wall -Wextra -Werror=implicit-function-declaration -fvisibility=hidden", "CMAKE_CXX_FLAGS": "-O2 -flto -fdata-sections -ffunction-sections -fno-fast-math -Wall -Wextra -fvisibility=hidden", "CMAKE_EXE_LINKER_FLAGS": "-Wl, -flto", "CMAKE_EXPORT_COMPILE_COMMANDS": "YES", @@ -158,7 +158,7 @@ "binaryDir": "${sourceDir}/build/darwin-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "CMAKE_C_FLAGS": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math -fvisibility=hidden", + "CMAKE_C_FLAGS": "-O0 -g -DDEBUG -Wall -Wextra -Werror=implicit-function-declaration -fno-fast-math -fvisibility=hidden", "CMAKE_CXX_FLAGS": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math -fvisibility=hidden", "CMAKE_EXE_LINKER_FLAGS": "-g", "CMAKE_EXPORT_COMPILE_COMMANDS": "YES", diff --git a/src/legacy/output/CMakeLists.txt b/src/legacy/output/CMakeLists.txt index 39ee025a1..aef7f111c 100644 --- a/src/legacy/output/CMakeLists.txt +++ b/src/legacy/output/CMakeLists.txt @@ -32,6 +32,13 @@ set_target_properties(openswmm_legacy_output OUTPUT_NAME "openswmm.legacy.output" VERSION ${PROJECT_VERSION} SOVERSION ${PROJECT_VERSION_MAJOR} + # swmm_output.c calls fseeko()/ftello(), which glibc gates behind + # _POSIX_C_SOURCE/_DEFAULT_SOURCE. With the root CMAKE_C_EXTENSIONS + # OFF + -std=c17 they're hidden under __STRICT_ANSI__, implicit-int + # truncates pointers/64-bit offsets, and the program crashes at + # runtime on Linux. Override per-target so CMake passes -std=gnu17 + # for this target and the rest of the project keeps strict ANSI. + C_EXTENSIONS ON ) # Generate export header From 795e4f832d1b7515890a23e94ecd99311bd5d570 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Sat, 23 May 2026 12:17:15 -0400 Subject: [PATCH 02/26] Standardize FP policy across presets; de-dup flag lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Honors the documented project policy (PHASE2_HANDOFF_2026-04-26.md:66-68): -fno-fast-math is non-negotiable, regression tolerance is +/-0.001 abs and +/-0.1% rel per timestep, not bit-exact across architectures. The preset changes here are policy-preserving — no numerics change — and fix three inconsistencies the audit surfaced. 1. Replace -ffloat-store (Linux only, an x87 holdover that is a no-op on x86_64) with -ffp-contract=off (Linux + Darwin). FMA fusion is the real source of cross-platform drift today; -ffp-contract=off is the modern cross-vendor gate. MSVC /fp:precise already covers it. 2. Add -fno-math-errno on gcc/clang. Lets libm calls lower to compiler builtins (often inlined / vectorized). Bit-identical math results; only post-libm errno semantics change. Apple clang has this default already; making it explicit aligns gcc. 3. Add /Gy /Gw to Windows Release for section-stripping parity with -fdata-sections -ffunction-sections on Linux/Darwin. The matching /OPT:REF /OPT:ICF linker flags are already in place — they had nothing below the OBJ level to strip until now. Also reorganizes each preset so CMAKE_C_FLAGS carries config-invariant policy (warnings, FP, visibility) and CMAKE_C_FLAGS_ carries only optimization (O2/LTO/section-split or O0/g/DDEBUG). Eliminates the "both fields hold the full flag list" duplication that caused -ffloat-store to silently end up Linux-only in the first place. Net compile line is unchanged for policy flags; only the structure differs. Out of scope and intentionally not added: -O3, -march=native, -funroll-loops, -fno-signed-zeros, /fp:strict. Reasons enumerated in /Users/calebbuahin/.claude/plans/i-want-the-compiler-virtual-popcorn.md. Verification: macOS arm64 Darwin-debug rebuilt clean (no argument-unused / no implicit-decl errors), CMakeCache reflects new flags, build.ninja shows the new flags on the legacy swmm5.c compile line, full 42-test unit suite green. Signed-off-by: cbuahin --- CMakePresets.json | 72 +++++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/CMakePresets.json b/CMakePresets.json index 8ca627c71..633360e22 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -21,7 +21,7 @@ { "name": "Windows", "inherits": "default", - "description": "Windows build using Visual Studio generator", + "description": "Windows Release (VS gen). FP policy: /fp:precise (IEEE-754, no FMA contraction). C extensions /we4013 promote implicit-decl to error.", "generator": "Visual Studio 17 2022", "toolset": { "value": "v143", @@ -30,17 +30,17 @@ "binaryDir": "${sourceDir}/build/windows", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "CMAKE_C_FLAGS": "/O2 /GL /fp:precise /W4 /we4013", - "CMAKE_CXX_FLAGS": "/O2 /GL /fp:precise /W4", + "CMAKE_C_FLAGS": "/fp:precise /W4 /we4013", + "CMAKE_CXX_FLAGS": "/fp:precise /W4", "CMAKE_EXE_LINKER_FLAGS": "/LTCG /OPT:REF /OPT:ICF", "CMAKE_SHARED_LINKER_FLAGS": "/LTCG /OPT:REF /OPT:ICF", "CMAKE_MODULE_LINKER_FLAGS": "/LTCG /OPT:REF /OPT:ICF", "CMAKE_EXPORT_COMPILE_COMMANDS": "YES", "CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS": "OFF", - "CMAKE_C_FLAGS_RELEASE": "/O2 /GL /fp:precise /W4", - "CMAKE_CXX_FLAGS_RELEASE": "/O2 /GL /fp:precise /W4", - "CMAKE_C_FLAGS_DEBUG": "/Zi /Od /DDEBUG /W4 /fp:precise", - "CMAKE_CXX_FLAGS_DEBUG": "/Zi /Od /DDEBUG /W4 /fp:precise" + "CMAKE_C_FLAGS_RELEASE": "/O2 /GL /Gy /Gw", + "CMAKE_CXX_FLAGS_RELEASE": "/O2 /GL /Gy /Gw", + "CMAKE_C_FLAGS_DEBUG": "/Zi /Od /DDEBUG", + "CMAKE_CXX_FLAGS_DEBUG": "/Zi /Od /DDEBUG" }, "condition": { "type": "equals", @@ -51,7 +51,7 @@ { "name": "Windows-debug", "inherits": "default-debug", - "description": "Windows build for debugging using Visual Studio generator", + "description": "Windows Debug (VS gen). FP policy: /fp:precise (IEEE-754, no FMA contraction). C extensions /we4013 promote implicit-decl to error.", "generator": "Visual Studio 17 2022", "toolset": { "value": "v143", @@ -60,12 +60,12 @@ "binaryDir": "${sourceDir}/build/windows-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "CMAKE_C_FLAGS": "/Zi /Od /DDEBUG /W4 /we4013 /fp:precise", - "CMAKE_CXX_FLAGS": "/Zi /Od /DDEBUG /W4 /fp:precise", + "CMAKE_C_FLAGS": "/fp:precise /W4 /we4013", + "CMAKE_CXX_FLAGS": "/fp:precise /W4", "CMAKE_EXE_LINKER_FLAGS": "/DEBUG", "CMAKE_EXPORT_COMPILE_COMMANDS": "YES", - "CMAKE_C_FLAGS_DEBUG": "/Zi /Od /DDEBUG /W4 /fp:precise", - "CMAKE_CXX_FLAGS_DEBUG": "/Zi /Od /DDEBUG /W4 /fp:precise" + "CMAKE_C_FLAGS_DEBUG": "/Zi /Od /DDEBUG", + "CMAKE_CXX_FLAGS_DEBUG": "/Zi /Od /DDEBUG" }, "condition": { "type": "equals", @@ -76,21 +76,21 @@ { "name": "Linux", "inherits": "default", - "description": "Linux build using Ninja generator", + "description": "Linux Release (Ninja). FP policy: -fno-fast-math -ffp-contract=off -fexcess-precision=standard (no FMA fusion, no excess precision). -fno-math-errno is safe perf (libm builtins, bit-identical math).", "generator": "Ninja", "binaryDir": "${sourceDir}/build/linux", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "CMAKE_C_FLAGS": "-O2 -flto -fdata-sections -ffunction-sections -fipa-icf -fno-fast-math -fexcess-precision=standard -ffloat-store -Wall -Wextra -Werror=implicit-function-declaration -fvisibility=hidden", - "CMAKE_CXX_FLAGS": "-O2 -flto -fdata-sections -ffunction-sections -fipa-icf -fno-fast-math -fexcess-precision=standard -ffloat-store -Wall -Wextra -fvisibility=hidden", + "CMAKE_C_FLAGS": "-Wall -Wextra -Werror=implicit-function-declaration -fno-fast-math -ffp-contract=off -fno-math-errno -fexcess-precision=standard -fvisibility=hidden", + "CMAKE_CXX_FLAGS": "-Wall -Wextra -fno-fast-math -ffp-contract=off -fno-math-errno -fexcess-precision=standard -fvisibility=hidden", "CMAKE_EXE_LINKER_FLAGS": "-Wl,--gc-sections -flto", "CMAKE_SHARED_LINKER_FLAGS": "-Wl,--gc-sections -flto", "CMAKE_MODULE_LINKER_FLAGS": "-Wl,--gc-sections -flto", "CMAKE_EXPORT_COMPILE_COMMANDS": "YES", - "CMAKE_C_FLAGS_RELEASE": "-O2 -flto -fdata-sections -ffunction-sections -fipa-icf -fno-fast-math -fexcess-precision=standard -ffloat-store -Wall -Wextra", - "CMAKE_CXX_FLAGS_RELEASE": "-O2 -flto -fdata-sections -ffunction-sections -fipa-icf -fno-fast-math -fexcess-precision=standard -ffloat-store -Wall -Wextra", - "CMAKE_C_FLAGS_DEBUG": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math -fexcess-precision=standard -ffloat-store", - "CMAKE_CXX_FLAGS_DEBUG": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math -fexcess-precision=standard -ffloat-store" + "CMAKE_C_FLAGS_RELEASE": "-O2 -flto -fdata-sections -ffunction-sections -fipa-icf", + "CMAKE_CXX_FLAGS_RELEASE": "-O2 -flto -fdata-sections -ffunction-sections -fipa-icf", + "CMAKE_C_FLAGS_DEBUG": "-O0 -g -DDEBUG", + "CMAKE_CXX_FLAGS_DEBUG": "-O0 -g -DDEBUG" }, "condition": { "type": "equals", @@ -101,17 +101,17 @@ { "name": "Linux-debug", "inherits": "default-debug", - "description": "Linux build for debugging using Ninja generator", + "description": "Linux Debug (Ninja). Same FP policy as Linux preset (-fno-fast-math -ffp-contract=off -fexcess-precision=standard).", "generator": "Ninja", "binaryDir": "${sourceDir}/build/linux-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "CMAKE_C_FLAGS": "-O0 -g -DDEBUG -Wall -Wextra -Werror=implicit-function-declaration -fno-fast-math -fexcess-precision=standard -ffloat-store -fvisibility=hidden", - "CMAKE_CXX_FLAGS": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math -fexcess-precision=standard -ffloat-store -fvisibility=hidden", + "CMAKE_C_FLAGS": "-Wall -Wextra -Werror=implicit-function-declaration -fno-fast-math -ffp-contract=off -fno-math-errno -fexcess-precision=standard -fvisibility=hidden", + "CMAKE_CXX_FLAGS": "-Wall -Wextra -fno-fast-math -ffp-contract=off -fno-math-errno -fexcess-precision=standard -fvisibility=hidden", "CMAKE_EXE_LINKER_FLAGS": "-g", "CMAKE_EXPORT_COMPILE_COMMANDS": "YES", - "CMAKE_C_FLAGS_DEBUG": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math -fexcess-precision=standard -ffloat-store", - "CMAKE_CXX_FLAGS_DEBUG": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math -fexcess-precision=standard -ffloat-store" + "CMAKE_C_FLAGS_DEBUG": "-O0 -g -DDEBUG", + "CMAKE_CXX_FLAGS_DEBUG": "-O0 -g -DDEBUG" }, "condition": { "type": "equals", @@ -122,7 +122,7 @@ { "name": "Darwin", "inherits": "default", - "description": "macOS build using Ninja generator", + "description": "macOS Release (Ninja, clang). FP policy: -fno-fast-math -ffp-contract=off (no FMA fusion). -fexcess-precision is omitted: clang ignores it on x86_64/arm64. -fno-math-errno matches Apple clang default.", "generator": "Ninja", "binaryDir": "${sourceDir}/build/darwin", "environment": { @@ -132,16 +132,16 @@ }, "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "CMAKE_C_FLAGS": "-O2 -flto -fdata-sections -ffunction-sections -fno-fast-math -Wall -Wextra -Werror=implicit-function-declaration -fvisibility=hidden", - "CMAKE_CXX_FLAGS": "-O2 -flto -fdata-sections -ffunction-sections -fno-fast-math -Wall -Wextra -fvisibility=hidden", + "CMAKE_C_FLAGS": "-Wall -Wextra -Werror=implicit-function-declaration -fno-fast-math -ffp-contract=off -fno-math-errno -fvisibility=hidden", + "CMAKE_CXX_FLAGS": "-Wall -Wextra -fno-fast-math -ffp-contract=off -fno-math-errno -fvisibility=hidden", "CMAKE_EXE_LINKER_FLAGS": "-Wl, -flto", "CMAKE_EXPORT_COMPILE_COMMANDS": "YES", - "CMAKE_C_FLAGS_RELEASE": "-O2 -flto -fdata-sections -ffunction-sections -fno-fast-math -Wall -Wextra", - "CMAKE_CXX_FLAGS_RELEASE": "-O2 -flto -fdata-sections -ffunction-sections -fno-fast-math -Wall -Wextra", + "CMAKE_C_FLAGS_RELEASE": "-O2 -flto -fdata-sections -ffunction-sections", + "CMAKE_CXX_FLAGS_RELEASE": "-O2 -flto -fdata-sections -ffunction-sections", "CMAKE_SHARED_LINKER_FLAGS": "-flto -twolevel_namespace", "CMAKE_MODULE_LINKER_FLAGS": "-flto", - "CMAKE_C_FLAGS_DEBUG": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math", - "CMAKE_CXX_FLAGS_DEBUG": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math", + "CMAKE_C_FLAGS_DEBUG": "-O0 -g -DDEBUG", + "CMAKE_CXX_FLAGS_DEBUG": "-O0 -g -DDEBUG", "CMAKE_OSX_ARCHITECTURES": "arm64;x86_64" }, "condition": { @@ -153,18 +153,18 @@ { "name": "Darwin-debug", "inherits": "default-debug", - "description": "macOS build for debugging using Ninja generator", + "description": "macOS Debug (Ninja, clang). Same FP policy as Darwin preset (-fno-fast-math -ffp-contract=off -fno-math-errno).", "generator": "Ninja", "binaryDir": "${sourceDir}/build/darwin-debug", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug", - "CMAKE_C_FLAGS": "-O0 -g -DDEBUG -Wall -Wextra -Werror=implicit-function-declaration -fno-fast-math -fvisibility=hidden", - "CMAKE_CXX_FLAGS": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math -fvisibility=hidden", + "CMAKE_C_FLAGS": "-Wall -Wextra -Werror=implicit-function-declaration -fno-fast-math -ffp-contract=off -fno-math-errno -fvisibility=hidden", + "CMAKE_CXX_FLAGS": "-Wall -Wextra -fno-fast-math -ffp-contract=off -fno-math-errno -fvisibility=hidden", "CMAKE_EXE_LINKER_FLAGS": "-g", "CMAKE_EXPORT_COMPILE_COMMANDS": "YES", "CMAKE_SHARED_LINKER_FLAGS": "-twolevel_namespace", - "CMAKE_C_FLAGS_DEBUG": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math", - "CMAKE_CXX_FLAGS_DEBUG": "-O0 -g -DDEBUG -Wall -Wextra -fno-fast-math" + "CMAKE_C_FLAGS_DEBUG": "-O0 -g -DDEBUG", + "CMAKE_CXX_FLAGS_DEBUG": "-O0 -g -DDEBUG" }, "condition": { "type": "equals", From d1df7aa6d46c43c8e30009c675a564d433283c3e Mon Sep 17 00:00:00 2001 From: cbuahin Date: Sat, 23 May 2026 20:18:09 -0400 Subject: [PATCH 03/26] Work in progress Signed-off-by: cbuahin --- .github/workflows/codeql.yml | 4 +- .github/workflows/deployment.yml | 195 ++++++--------- .github/workflows/documentation.yml | 4 +- .github/workflows/regression_testing.yml | 132 +++++++--- .github/workflows/scorecard.yml | 2 +- .github/workflows/unit_testing.yml | 32 ++- .github/workflows/unit_testing_python.yml | 235 +++++------------- CMakeLists.txt | 111 ++++++++- CMakePresets.json | 72 +++++- python/CMakeLists.txt | 34 +++ python/pyproject.toml | 45 +++- python/scripts/repair_wheel_windows.py | 76 ------ src/cli/CMakeLists.txt | 24 +- src/engine/CMakeLists.txt | 28 ++- src/legacy/cli/CMakeLists.txt | 15 +- tests/CMakeLists.txt | 67 +++++ tests/regression/CMakeLists.txt | 33 +-- tests/unit/engine/CMakeLists.txt | 4 + tests/unit/legacy/engine/CMakeLists.txt | 2 + .../legacy/engine/data/hotstart/_api_test.rpt | 6 +- .../legacy/engine/data/hotstart/_err_test.rpt | 4 +- .../data/hotstart/_expanded_api_test.rpt | 4 +- .../engine/data/hotstart/_shap_test.rpt | 4 +- .../engine/data/hotstart/hotstart_end.hsf | Bin 1479 -> 1479 bytes .../site_drainage_model_use_hotstart_v1.rpt | 4 +- tests/unit/legacy/output/CMakeLists.txt | 2 + 26 files changed, 666 insertions(+), 473 deletions(-) delete mode 100644 python/scripts/repair_wheel_windows.py diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 52d0c3aa6..7b0898da7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: CodeQL on: push: - branches: [main, develop, swmm6_rel] + branches: [main, develop] pull_request: - branches: [main, develop, swmm6_rel] + branches: [main, develop] schedule: # Weekly scan so newly disclosed CWE queries hit dormant branches too. # Mondays at 06:00 UTC — outside US-business hours but before EU-morning. diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index d7383fa38..2bd382fb7 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -2,7 +2,10 @@ name: Deployment on: push: + branches: [main, develop] tags: ["v*.*.*"] + pull_request: + branches: [main, develop] workflow_dispatch: env: @@ -124,173 +127,117 @@ jobs: build-${{ matrix.vcpkg_triplet }}/*.zip # ────────────────────────────────────────────────────────────────────── - # Python wheels + # Python wheels — cibuildwheel matrix (one runner per OS/arch). + # + # Each runner produces wheels for every supported CPython version + # (3.10–3.13) in a single job. Linux builds run inside manylinux_2_28 + # containers; vcpkg is bootstrapped INSIDE the container by the + # `before-all` hook in pyproject.toml's [tool.cibuildwheel.linux] + # block. macOS / Windows run on the host runner and use the vcpkg + # checkout below. + # + # See docs/CIBUILDWHEEL_REVERT_PLAN.md for full rationale. # ────────────────────────────────────────────────────────────────────── wheels: - name: "Python Wheels (${{ matrix.alias }})" + name: "Wheels (${{ matrix.os }})" needs: build strategy: fail-fast: false matrix: - include: - - os: ubuntu-latest - alias: Linux-x64 - shell_ext: .sh - vcpkg_triplet: x64-linux - cmake_osx_arch: "" - - - os: macos-latest - alias: macOS-arm64 - cmake_preset: Darwin - shell_ext: .sh - vcpkg_triplet: arm64-osx - cmake_osx_arch: arm64 - - - os: macos-15-intel - alias: macOS-x64 - cmake_preset: Darwin - shell_ext: .sh - vcpkg_triplet: x64-osx - cmake_osx_arch: x86_64 - - - os: windows-latest - alias: Windows-x64 - cmake_preset: Windows - shell_ext: .bat - vcpkg_triplet: x64-windows - cmake_osx_arch: "" - + os: + - ubuntu-24.04 # Linux x86_64 + - ubuntu-24.04-arm # Linux aarch64 (native, no QEMU) + - windows-2025 # Windows x86_64 + - macos-15 # macOS arm64 + - macos-15-intel # macOS x86_64 runs-on: ${{ matrix.os }} steps: - name: Checkout repository uses: actions/checkout@v5 - # vcpkg is required by python/CMakePresets.json (toolchainFile = $env{VCPKG_ROOT}/...). - # Each runner is a fresh machine so we must check it out and bootstrap it here. - - name: Checkout vcpkg + # Export GHA cache creds so the in-container vcpkg (Linux) and the + # host vcpkg (macOS/Windows) can both reuse the x-gha binary cache. + - name: Export GitHub Actions cache variables + uses: actions/github-script@v8 + with: + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + + # On macOS and Windows there is no container; vcpkg lives on the host + # and is referenced by python/CMakePresets.json via $env{VCPKG_ROOT}. + - name: Checkout vcpkg (host-side, macOS/Windows) + if: runner.os != 'Linux' uses: actions/checkout@v5 with: repository: microsoft/vcpkg ref: 2025.02.14 path: vcpkg - - name: Install OpenMP and Ninja (macOS) + - name: Bootstrap vcpkg (macOS) if: runner.os == 'macOS' - run: brew install libomp ninja - - - name: Install Ninja (Linux) - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y ninja-build + run: ./vcpkg/bootstrap-vcpkg.sh -disableMetrics - name: Bootstrap vcpkg (Windows) if: runner.os == 'Windows' - working-directory: ${{ env.VCPKG_ROOT }} - run: | - .\bootstrap-vcpkg${{ matrix.shell_ext }} - .\vcpkg.exe integrate install + run: .\vcpkg\bootstrap-vcpkg.bat -disableMetrics - - name: Bootstrap vcpkg (Unix) - if: runner.os != 'Windows' - working-directory: ${{ env.VCPKG_ROOT }} - run: | - ./bootstrap-vcpkg${{ matrix.shell_ext }} - chmod +x vcpkg + - name: Build wheels + uses: pypa/cibuildwheel@v3.1.4 + with: + package-dir: ./python + output-dir: ./wheelhouse + env: + # Host-side VCPKG_ROOT consumed by macOS/Windows builds. + # Linux ignores this (VCPKG_ROOT is set to /host/vcpkg inside + # the container by pyproject.toml's before-all hook). + VCPKG_ROOT: ${{ github.workspace }}/vcpkg + VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - # Export the GHA cache tokens so vcpkg can restore binary packages built - # by the build job (avoids re-compiling SUNDIALS, etc.). - - name: Export GitHub Actions cache variables - uses: actions/github-script@v8 + - name: Upload Python wheels + if: always() + uses: actions/upload-artifact@v5 with: - script: | - core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + name: python-wheels-${{ matrix.os }} + path: ./wheelhouse/*.whl - # Run a cmake configure-only pass on Windows to pre-populate the vcpkg - # installed directory using the binary cache from the build job. - # cibuildwheel's isolated build env sometimes fails to hit the GHA binary - # cache, causing vcpkg to try (and fail) to build SUNDIALS from source. - # The installed dir is then forwarded to skbuild via OPENSWMM_CMAKE_ARGS. - - name: Pre-install vcpkg dependencies (Windows) - if: runner.os == 'Windows' - run: > - cmake - --preset=${{ matrix.cmake_preset }} - -B build-vcpkg-prefetch - -DOPENSWMM_BUILD_TESTS=OFF - -DOPENSWMM_BUILD_UNIT_TESTS=OFF - -DOPENSWMM_BUILD_REGRESSION_TESTS=OFF - -DOPENSWMM_BUILD_BENCHMARKS=OFF - --no-warn-unused-cli + # ────────────────────────────────────────────────────────────────────── + # Python source distribution (sdist) — platform-independent, built once. + # ────────────────────────────────────────────────────────────────────── + sdist: + name: "Python sdist" + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.13" - - name: Install Python requirements - working-directory: python + - name: Install build frontend run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt + python -m pip install build - - name: Clean stale build cache - run: python -c "import shutil, os; shutil.rmtree('python/_skbuild', ignore_errors=True)" - - - name: Build wheels - uses: pypa/cibuildwheel@v2.23.2 - with: - package-dir: ./python - output-dir: ./python/wheelhouse - env: - CMAKE_OSX_ARCHITECTURES: ${{ matrix.cmake_osx_arch }} - CIBW_BUILD_VERBOSITY: 3 - CIBW_BEFORE_BUILD_LINUX: pip install ninja - CIBW_BEFORE_BUILD_MACOS: pip install delocate - CIBW_ENVIRONMENT_MACOS: >- - MACOSX_DEPLOYMENT_TARGET=15.0 - CMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_osx_arch }} - VCPKG_ROOT=${{ env.VCPKG_ROOT }} - VCPKG_BINARY_SOURCES="clear;x-gha,readwrite" - VCPKG_MANIFEST_DIR=${{ github.workspace }} - ACTIONS_CACHE_URL=${{ env.ACTIONS_CACHE_URL }} - ACTIONS_RUNTIME_TOKEN=${{ env.ACTIONS_RUNTIME_TOKEN }} - CIBW_ENVIRONMENT_LINUX: >- - VCPKG_ROOT=${{ env.VCPKG_ROOT }} - VCPKG_BINARY_SOURCES="clear;x-gha,readwrite" - VCPKG_MANIFEST_DIR=${{ github.workspace }} - ACTIONS_CACHE_URL=${{ env.ACTIONS_CACHE_URL }} - ACTIONS_RUNTIME_TOKEN=${{ env.ACTIONS_RUNTIME_TOKEN }} - CIBW_BEFORE_BUILD_WINDOWS: pip install delvewheel - CIBW_ENVIRONMENT_WINDOWS: >- - CMAKE_GENERATOR="Visual Studio 17 2022" - VCPKG_ROOT=${{ env.VCPKG_ROOT }} - VCPKG_BINARY_SOURCES="clear;x-gha,readwrite" - VCPKG_MANIFEST_DIR=${{ github.workspace }} - VCPKG_DEFAULT_TRIPLET=x64-windows - OPENSWMM_CMAKE_ARGS=-DVCPKG_INSTALLED_DIR=${{ github.workspace }}/build-vcpkg-prefetch/vcpkg_installed - ACTIONS_CACHE_URL=${{ env.ACTIONS_CACHE_URL }} - ACTIONS_RUNTIME_TOKEN=${{ env.ACTIONS_RUNTIME_TOKEN }} - CIBW_REPAIR_WHEEL_COMMAND_LINUX: auditwheel repair -w {dest_dir} {wheel} - CIBW_REPAIR_WHEEL_COMMAND_MACOS: delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} - CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair -w {dest_dir} {wheel}" - CIBW_TEST_REQUIRES: pytest numpy - CIBW_TEST_COMMAND: pytest {package}/tests -v --import-mode=importlib + - name: Build sdist + working-directory: python + run: python -m build --sdist - - name: Upload Python wheels - if: always() + - name: Upload sdist uses: actions/upload-artifact@v5 with: - name: python-wheels-${{ matrix.vcpkg_triplet }} - path: | - python/wheelhouse/*.whl - python/dist/*.whl + name: python-sdist + path: python/dist/*.tar.gz + # ────────────────────────────────────────────────────────────────────── # Create GitHub Release # ────────────────────────────────────────────────────────────────────── release: name: Create GitHub Release if: startsWith(github.ref, 'refs/tags/v') - needs: [build, wheels] + needs: [build, wheels, sdist] runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index a22dfa669..ba9beec28 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -2,10 +2,10 @@ name: Documentation on: push: - branches: [main, develop, swmm6_rel] + branches: [main, develop] tags: ["v*.*.*"] pull_request: - branches: [main, develop, swmm6_rel] + branches: [main, develop] workflow_dispatch: # Allow only one concurrent deployment. Do NOT cancel in-progress runs so diff --git a/.github/workflows/regression_testing.yml b/.github/workflows/regression_testing.yml index ec8199292..e73262dd8 100644 --- a/.github/workflows/regression_testing.yml +++ b/.github/workflows/regression_testing.yml @@ -108,46 +108,45 @@ jobs: python -m pip install --upgrade pip python -m pip install pytest numpy - - name: Configure - # The *-tests preset sets VCPKG_MANIFEST_FEATURES=tests and enables - # both OPENSWMM_BUILD_UNIT_TESTS and OPENSWMM_BUILD_REGRESSION_TESTS - # as CMake cache vars (env-var-only propagation isn't honored by the - # vcpkg toolchain at configure time). + - name: Configure (Release with tests) + # The *-tests-release preset sets VCPKG_MANIFEST_FEATURES=tests;geopackage + # and enables OPENSWMM_BUILD_UNIT_TESTS + OPENSWMM_BUILD_REGRESSION_TESTS + # + OPENSWMM_WITH_GEOPACKAGE on top of the Release-flavoured base preset. run: > cmake - --preset=${{ matrix.cmake_preset }}-tests + --preset=${{ matrix.cmake_preset }}-tests-release -B build-${{ matrix.vcpkg_triplet }} -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_osx_arch }} - - name: Build - run: cmake --build build-${{ matrix.vcpkg_triplet }} --config Debug + - name: Build (Release) + run: cmake --build build-${{ matrix.vcpkg_triplet }} --config Release - name: Unit tests (sanity check) - run: ctest --test-dir build-${{ matrix.vcpkg_triplet }} -C Debug -L unit --output-on-failure - - - name: Diagnose Linux unit-test segfaults (gdb) - if: failure() && runner.os == 'Linux' - working-directory: tests/unit/legacy/engine/data - run: | - sudo apt-get install -y gdb - for t in test_solver_api test_solver_errors test_solver_hotstart test_solver_shapes test_solver_expanded_api; do - bin=$(find ${{ github.workspace }}/build-${{ matrix.vcpkg_triplet }} -name "$t" -type f -executable | head -1) - if [ -z "$bin" ]; then echo "Binary $t not found"; continue; fi - echo "===================================================" - echo "==== gdb backtrace: $t" - echo "===================================================" - gdb -batch \ - -ex 'set pagination off' \ - -ex 'set confirm off' \ - -ex 'handle SIGSEGV stop print nopass' \ - -ex run \ - -ex 'thread apply all bt full' \ - -ex quit \ - "$bin" 2>&1 | head -400 || true - done + run: ctest --test-dir build-${{ matrix.vcpkg_triplet }} -C Release -L unit --output-on-failure + + # - name: Diagnose Linux unit-test segfaults (gdb) + # if: failure() && runner.os == 'Linux' + # working-directory: tests/unit/legacy/engine/data + # run: | + # sudo apt-get install -y gdb + # for t in test_solver_api test_solver_errors test_solver_hotstart test_solver_shapes test_solver_expanded_api; do + # bin=$(find ${{ github.workspace }}/build-${{ matrix.vcpkg_triplet }} -name "$t" -type f -executable | head -1) + # if [ -z "$bin" ]; then echo "Binary $t not found"; continue; fi + # echo "===================================================" + # echo "==== gdb backtrace: $t" + # echo "===================================================" + # gdb -batch \ + # -ex 'set pagination off' \ + # -ex 'set confirm off' \ + # -ex 'handle SIGSEGV stop print nopass' \ + # -ex run \ + # -ex 'thread apply all bt full' \ + # -ex quit \ + # "$bin" 2>&1 | head -400 || true + # done - name: C++ regression tests - run: ctest --test-dir build-${{ matrix.vcpkg_triplet }} -C Debug -L regression --output-on-failure + run: ctest --test-dir build-${{ matrix.vcpkg_triplet }} -C Release -L regression --output-on-failure - name: Python regression tests working-directory: tests/regression_testing @@ -155,10 +154,79 @@ jobs: OPENSWMM_BUILD_DIR: ${{ github.workspace }}/build-${{ matrix.vcpkg_triplet }} run: python -m pytest -v --tb=short - - name: Upload artifacts + - name: Install Release tree + run: > + cmake --install build-${{ matrix.vcpkg_triplet }} + --config Release + --prefix "${{ github.workspace }}/regression-release-${{ matrix.vcpkg_triplet }}" + + - name: Verify bundled runtime libs (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + cd "${{ github.workspace }}/regression-release-${{ matrix.vcpkg_triplet }}/bin" + echo "--- bin/ contents ---" + ls -la + echo + for exe in openswmm openswmm-legacy; do + [ -x "$exe" ] || continue + echo "--- $exe linkage ---" + if [[ "${{ runner.os }}" == "macOS" ]]; then + otool -L "$exe" + else + ldd "$exe" + fi + done + + - name: Verify bundled runtime libs (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Set-Location "${{ github.workspace }}/regression-release-${{ matrix.vcpkg_triplet }}/bin" + Write-Host "--- bin/ contents ---" + Get-ChildItem | Format-Table Mode,Length,Name + + - name: Upload Release install artifact + uses: actions/upload-artifact@v5 + with: + name: regression-release-${{ matrix.vcpkg_triplet }} + path: regression-release-${{ matrix.vcpkg_triplet }}/ + + - name: Upload regression results if: always() uses: actions/upload-artifact@v5 with: name: regression-${{ matrix.vcpkg_triplet }} path: tests/regression_testing/results/ if-no-files-found: ignore + + # ────────────────────────────────────────────────────────────────────── + # Python source distribution (sdist) — platform-independent, built once. + # ────────────────────────────────────────────────────────────────────── + sdist: + name: "Python sdist" + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install build frontend + run: | + python -m pip install --upgrade pip + python -m pip install build + + - name: Build sdist + working-directory: python + run: python -m build --sdist + + - name: Upload sdist + uses: actions/upload-artifact@v5 + with: + name: python-sdist + path: python/dist/*.tar.gz diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index f367f8e47..f2f55eddb 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -8,7 +8,7 @@ on: schedule: - cron: "20 7 * * 1" push: - branches: ["develop"] + branches: ["main"] workflow_dispatch: permissions: read-all diff --git a/.github/workflows/unit_testing.yml b/.github/workflows/unit_testing.yml index 1af901477..0c1029df7 100644 --- a/.github/workflows/unit_testing.yml +++ b/.github/workflows/unit_testing.yml @@ -111,9 +111,10 @@ jobs: cmake --preset=${{ matrix.cmake_preset }}-debug -B build-${{ matrix.vcpkg_triplet }} - -DVCPKG_MANIFEST_FEATURES=tests + -DVCPKG_MANIFEST_FEATURES="tests;geopackage" -DOPENSWMM_BUILD_TESTS=OFF -DOPENSWMM_BUILD_UNIT_TESTS=ON + -DOPENSWMM_WITH_GEOPACKAGE=ON -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_osx_arch }} - name: Build @@ -163,10 +164,12 @@ jobs: cmake --preset=${{ matrix.cmake_preset }} -B build-${{ matrix.vcpkg_triplet }}-release + -DVCPKG_MANIFEST_FEATURES="geopackage" -DOPENSWMM_BUILD_TESTS=OFF -DOPENSWMM_BUILD_UNIT_TESTS=OFF -DOPENSWMM_BUILD_REGRESSION_TESTS=OFF -DOPENSWMM_BUILD_BENCHMARKS=OFF + -DOPENSWMM_WITH_GEOPACKAGE=ON -DCMAKE_OSX_ARCHITECTURES=${{ matrix.cmake_osx_arch }} - name: Build (Release, for Python wheel) @@ -178,6 +181,33 @@ jobs: --config Release --prefix "${{ github.workspace }}/engine-install-${{ matrix.vcpkg_triplet }}" + - name: Verify bundled runtime libs (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + cd "${{ github.workspace }}/engine-install-${{ matrix.vcpkg_triplet }}/bin" + echo "--- bin/ contents ---" + ls -la + echo + for exe in openswmm openswmm-legacy; do + [ -x "$exe" ] || continue + echo "--- $exe linkage ---" + if [[ "${{ runner.os }}" == "macOS" ]]; then + otool -L "$exe" + else + ldd "$exe" + fi + done + + - name: Verify bundled runtime libs (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Set-Location "${{ github.workspace }}/engine-install-${{ matrix.vcpkg_triplet }}/bin" + Write-Host "--- bin/ contents ---" + Get-ChildItem | Format-Table Mode,Length,Name + - name: Upload engine install artifact uses: actions/upload-artifact@v5 with: diff --git a/.github/workflows/unit_testing_python.yml b/.github/workflows/unit_testing_python.yml index f4feea9b7..22313b8a0 100644 --- a/.github/workflows/unit_testing_python.yml +++ b/.github/workflows/unit_testing_python.yml @@ -2,9 +2,9 @@ name: Unit Testing Python on: push: - branches: [master, main, develop] + branches: [main, develop] pull_request: - branches: [master, main, develop] + branches: [main, develop] env: VCPKG_ROOT: ${{ github.workspace }}/vcpkg @@ -15,210 +15,107 @@ env: jobs: # ────────────────────────────────────────────────────────────────────── - # Python bindings — build engine + Cython, test, and package wheel. - # The engine is built from source via add_subdirectory inside the - # cmake configure (no pre-built artifact needed). + # Python bindings — cibuildwheel matrix (one runner per OS/arch). + # + # cibuildwheel's test-command in pyproject.toml runs pytest inside + # each built wheel, so the previous "Run Python tests" and + # "Run smoke test" steps are no longer needed here. + # + # PR runs build only one CPython version (cp312) per OS for speed. + # Pushes to main/develop and manual triggers build the full matrix. + # See docs/CIBUILDWHEEL_REVERT_PLAN.md. # ────────────────────────────────────────────────────────────────────── python: - name: "Python (${{ matrix.alias }})" + name: "Python (${{ matrix.os }})" permissions: contents: read actions: write strategy: fail-fast: false matrix: - include: - - os: ubuntu-latest - alias: Linux-x64 - shell_ext: .sh - vcpkg_triplet: x64-linux - cmake_osx_arch: "" - - - os: macos-latest - alias: macOS-arm64 - shell_ext: .sh - vcpkg_triplet: arm64-osx - cmake_osx_arch: arm64 - - - os: macos-15-intel - alias: macOS-x64 - shell_ext: .sh - vcpkg_triplet: x64-osx - cmake_osx_arch: x86_64 - - - os: windows-latest - alias: Windows-x64 - shell_ext: .bat - vcpkg_triplet: x64-windows - cmake_osx_arch: "" - + os: + - ubuntu-24.04 # Linux x86_64 + - ubuntu-24.04-arm # Linux aarch64 (native, no QEMU) + - windows-2025 # Windows x86_64 + - macos-15 # macOS arm64 + - macos-15-intel # macOS x86_64 runs-on: ${{ matrix.os }} + env: + # Full matrix on push/dispatch; single CPython on PR for fast feedback. + CIBW_BUILD: ${{ github.event_name == 'pull_request' && 'cp312-*' || 'cp310-* cp311-* cp312-* cp313-*' }} steps: - name: Checkout repository uses: actions/checkout@v5 - - name: Checkout vcpkg + - name: Export GitHub Actions cache variables + uses: actions/github-script@v8 + with: + script: | + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + + - name: Checkout vcpkg (host-side, macOS/Windows) + if: runner.os != 'Linux' uses: actions/checkout@v5 with: repository: microsoft/vcpkg ref: 2025.02.14 path: vcpkg - - name: Install OpenMP and Ninja (macOS) + - name: Bootstrap vcpkg (macOS) if: runner.os == 'macOS' - run: brew install libomp ninja - - - name: Install Ninja (Linux) - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y ninja-build + run: ./vcpkg/bootstrap-vcpkg.sh -disableMetrics - name: Bootstrap vcpkg (Windows) if: runner.os == 'Windows' - working-directory: ${{ env.VCPKG_ROOT }} - run: | - .\bootstrap-vcpkg${{ matrix.shell_ext }} - .\vcpkg.exe integrate install - - - name: Bootstrap vcpkg (Unix) - if: runner.os != 'Windows' - working-directory: ${{ env.VCPKG_ROOT }} - run: | - ./bootstrap-vcpkg${{ matrix.shell_ext }} - chmod +x vcpkg + run: .\vcpkg\bootstrap-vcpkg.bat -disableMetrics - - name: Export GitHub Actions cache variables - uses: actions/github-script@v8 + - name: Build wheels + uses: pypa/cibuildwheel@v3.1.4 with: - script: | - core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); - core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + package-dir: ./python + output-dir: ./wheelhouse + env: + # Host-side VCPKG_ROOT consumed by macOS/Windows builds. + # Linux uses /host/vcpkg inside the container (see pyproject.toml). + VCPKG_ROOT: ${{ github.workspace }}/vcpkg + VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" - - name: Cache vcpkg downloads (source tarballs) - uses: actions/cache@v4 + - name: Upload Python wheels + if: always() + uses: actions/upload-artifact@v5 with: - path: ${{ env.VCPKG_ROOT }}/downloads - key: vcpkg-downloads-${{ runner.os }}-${{ matrix.vcpkg_triplet }}-${{ hashFiles('vcpkg.json') }} - restore-keys: | - vcpkg-downloads-${{ runner.os }}-${{ matrix.vcpkg_triplet }}- - vcpkg-downloads-${{ runner.os }}- + name: python-wheels-${{ matrix.os }} + path: ./wheelhouse/*.whl + + # ────────────────────────────────────────────────────────────────────── + # Python source distribution (sdist) — platform-independent, built once. + # ────────────────────────────────────────────────────────────────────── + sdist: + name: "Python sdist" + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.13" - - name: Install Python requirements - working-directory: python + - name: Install build frontend run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt - - - name: Clean stale scikit-build cache - run: python -c "import shutil; shutil.rmtree('python/_skbuild', ignore_errors=True)" - - # Pre-install vcpkg packages so cmake configure finds SUNDIALS and - # other deps quickly via the GHA binary cache rather than rebuilding. - - name: Pre-install vcpkg packages (Unix) - if: runner.os != 'Windows' - env: - VCPKG_DEFAULT_TRIPLET: ${{ matrix.vcpkg_triplet }} - run: > - "${{ env.VCPKG_ROOT }}/vcpkg" install - --triplet "${{ matrix.vcpkg_triplet }}" - --x-manifest-root "${{ github.workspace }}" - --x-install-root "${{ env.VCPKG_ROOT }}/installed" - - - name: Pre-install vcpkg packages (Windows) - if: runner.os == 'Windows' - env: - VCPKG_DEFAULT_TRIPLET: ${{ matrix.vcpkg_triplet }} - run: > - & "${{ env.VCPKG_ROOT }}\vcpkg.exe" install - --triplet "${{ matrix.vcpkg_triplet }}" - --x-manifest-root "${{ github.workspace }}" - --x-install-root "${{ env.VCPKG_ROOT }}/installed" - - # No OPENSWMM_ENGINE_INSTALL_PREFIX → python/CMakeLists.txt uses - # add_subdirectory(..) to build the engine inline. - - name: Build and install (Windows) - if: runner.os == 'Windows' - working-directory: python - env: - CMAKE_GENERATOR: "Visual Studio 17 2022" - VCPKG_MANIFEST_DIR: ${{ github.workspace }} - VCPKG_DEFAULT_TRIPLET: ${{ matrix.vcpkg_triplet }} - run: python -m pip install . + python -m pip install build - - name: Build and install (Unix) - if: runner.os != 'Windows' + - name: Build sdist working-directory: python - env: - CMAKE_OSX_ARCHITECTURES: ${{ matrix.cmake_osx_arch }} - VCPKG_MANIFEST_DIR: ${{ github.workspace }} - VCPKG_DEFAULT_TRIPLET: ${{ matrix.vcpkg_triplet }} - run: python -m pip install . + run: python -m build --sdist - - name: Run Python tests - run: > - python -m pytest - python/tests/engine python/tests/legacy - -v --import-mode=importlib - --ignore=python/tests/engine/test_integration.py - - - name: Run smoke test - # -P prevents Python from prepending python/ (the script's dir) to - # sys.path, which otherwise lets the source tree shadow the installed - # wheel (the source tree has _solver.pyx, not the compiled .so). - run: python -P python/smoke_test.py - - - name: Build wheel (Windows) - if: runner.os == 'Windows' - working-directory: python - env: - CMAKE_GENERATOR: "Visual Studio 17 2022" - VCPKG_MANIFEST_DIR: ${{ github.workspace }} - VCPKG_DEFAULT_TRIPLET: ${{ matrix.vcpkg_triplet }} - run: python -m build --wheel --no-isolation - - - name: Build wheel (Unix) - if: runner.os != 'Windows' - working-directory: python - env: - CMAKE_OSX_ARCHITECTURES: ${{ matrix.cmake_osx_arch }} - VCPKG_MANIFEST_DIR: ${{ github.workspace }} - VCPKG_DEFAULT_TRIPLET: ${{ matrix.vcpkg_triplet }} - run: python -m build --wheel --no-isolation - - - name: Repair wheel (Windows) - if: runner.os == 'Windows' - working-directory: python - env: - VCPKG_DEFAULT_TRIPLET: ${{ matrix.vcpkg_triplet }} - run: | - python -m pip install delvewheel - python scripts/repair_wheel_windows.py - - - name: Delocate wheel (macOS) - if: runner.os == 'macOS' - working-directory: python - run: | - pip install delocate - python -c " - import glob, subprocess, sys - wheels = glob.glob('dist/*.whl') - if not wheels: - print('No wheel found in dist/', file=sys.stderr); sys.exit(1) - for w in wheels: - subprocess.check_call([ - 'delocate-wheel', - '--require-archs', '${{ matrix.cmake_osx_arch }}', - '-w', 'dist', '-v', w, - ]) - " - - - name: Upload Python wheel - if: always() + - name: Upload sdist uses: actions/upload-artifact@v5 with: - name: python-wheel-${{ matrix.vcpkg_triplet }} - path: python/dist/*.whl + name: python-sdist + path: python/dist/*.tar.gz diff --git a/CMakeLists.txt b/CMakeLists.txt index dddc5b3c0..6fdcc3da3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,6 +99,91 @@ include(CMakePackageConfigHelpers) include(GNUInstallDirs) include(GenerateExportHeader) +# ---- Runtime-dependency bundling ---------------------------------------- +# Regexes shared by every install(RUNTIME_DEPENDENCY_SET …) call to keep the +# OS-provided libraries out of the package (bundling libc / libGL.so.1 / +# opengl32.dll would break dispatch on the target machine and is forbidden +# by Apple's bundle rules). Anything NOT matched here — SUNDIALS, HDF5, +# libomp, sqlite3, and any vcpkg-provided OpenGL-stack wrappers (GLEW, +# GLFW, ANGLE, glbinding) — will be copied next to the executable. +set(OPENSWMM_RUNTIME_DEP_PRE_EXCLUDES + # Windows OS contract DLLs (MSVC redists not in this list — we bundle them) + "api-ms-.*" "ext-ms-.*" + # macOS / Linux system search paths + "^/usr/lib/.*" "^/lib/.*" "^/lib64/.*" + "^/System/Library/.*" "^/usr/lib/system/.*" + # Unix system libs by basename + "^libc\\..*" "^libm\\..*" "^libdl\\..*" + "^libpthread\\..*" "^librt\\..*" "^libutil\\..*" + "^libresolv\\..*" "^libnsl\\..*" + "^libstdc\\+\\+\\..*" "^libgcc_s\\..*" + # OpenGL loader / driver dispatch — must come from the OS, never bundled. + # vcpkg's GLEW/GLFW/ANGLE wrappers do NOT match these (different names). + # `$` end-anchor is dropped on purpose: CMake's variable-reference parser + # (CMP0010) treats stray `$` characters as bad references when these + # regex strings get interpolated into install(CODE "...") bodies. + "^libGL\\..*" "^libGLX\\..*" "^libEGL\\..*" "^libGLdispatch\\..*" + "^opengl32\\.dll" "^glu32\\.dll" +) + +set(OPENSWMM_RUNTIME_DEP_POST_EXCLUDES + ".*[/\\\\][Ss]ystem32[/\\\\].*\\.dll" + ".*[/\\\\][Ss]ys[Ww][Oo][Ww]64[/\\\\].*\\.dll" +) + +# Install runtime files for a list of imported targets into the given +# destination. Skips targets that are not defined in the current +# configuration, which lets callers list optional deps unconditionally. +function(openswmm_install_runtime_deps DESTINATION) + foreach(_target IN LISTS ARGN) + if(TARGET ${_target}) + install(IMPORTED_RUNTIME_ARTIFACTS ${_target} + RUNTIME DESTINATION ${DESTINATION} + LIBRARY DESTINATION ${DESTINATION} + ) + endif() + endforeach() +endfunction() + +# Bundle the full runtime-dependency closure of an installed executable into +# the given subdir of CMAKE_INSTALL_PREFIX. Runs at install time as an +# install(SCRIPT …) — configure_file produces a per-target script from +# cmake/BundleRuntimeDeps.cmake.in. The template approach avoids escape- +# hell with backslashes in the regex strings inside install(CODE "…"). +# +# Why a custom script instead of install(TARGETS … RUNTIME_DEPENDENCY_SET …): +# when openswmm_engine is itself installed by another install(TARGETS …) +# rule, CMake auto-adds the build-tree engine dylib to the set's +# POST_EXCLUDE_FILES_STRICT. That strict exclusion prunes the dep walk +# *through* the engine, so libomp / SUNDIALS / HDF5 — which are deps of +# the engine, not of the CLI — never appear in the resolved set. Probing +# the installed exe via the template sidesteps that auto-exclusion entirely. +function(openswmm_bundle_runtime_deps TARGET_NAME SUBDIR) + # Resolve the executable's installed file name at configure time so the + # template substitution baked into the script is a plain path, not a + # genex (configure_file doesn't evaluate $<…>). + get_target_property(_out_name ${TARGET_NAME} OUTPUT_NAME) + if(NOT _out_name) + set(_out_name "${TARGET_NAME}") + endif() + if(WIN32) + set(_file_name "${_out_name}.exe") + else() + set(_file_name "${_out_name}") + endif() + + set(OPENSWMM_BUNDLE_TARGET_NAME "${TARGET_NAME}") + set(OPENSWMM_BUNDLE_TARGET_FILE_NAME "${_file_name}") + set(OPENSWMM_BUNDLE_SUBDIR "${SUBDIR}") + set(OPENSWMM_BUNDLE_PRE_EXCLUDES "[==[${OPENSWMM_RUNTIME_DEP_PRE_EXCLUDES}]==]") + set(OPENSWMM_BUNDLE_POST_EXCLUDES "[==[${OPENSWMM_RUNTIME_DEP_POST_EXCLUDES}]==]") + + set(_script_in "${CMAKE_SOURCE_DIR}/cmake/BundleRuntimeDeps.cmake.in") + set(_script_out "${CMAKE_CURRENT_BINARY_DIR}/BundleRuntimeDeps-${TARGET_NAME}.cmake") + configure_file("${_script_in}" "${_script_out}" @ONLY) + install(SCRIPT "${_script_out}") +endfunction() + # ---- RPATH / runtime-library search-path policy ------------------------- # Separate build-tree and install rpaths so developers can run binaries # directly from the build dir without extra environment variables, while @@ -179,17 +264,16 @@ install( FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp" ) -# Add testing subdirectories conditionally +# Add testing subdirectories conditionally. +# Note: testing/benchmarks subdirs are added AFTER add_subdirectory(src) below +# so that imported third-party targets (OpenMP::OpenMP_CXX, SUNDIALS::cvode, +# hdf5::hdf5-shared, sqlite3) created during the engine configure are visible +# to the test-tree dependency-staging helper (openswmm_stage_test_runtime_deps). +# CMake permits forward target references in target_link_libraries, so this +# reorder is safe. if(OPENSWMM_BUILD_UNIT_TESTS OR OPENSWMM_BUILD_REGRESSION_TESTS) enable_testing() find_package(GTest CONFIG REQUIRED) - add_subdirectory(tests) -endif() - -# Add benchmarks subdirectory conditionally -if(OPENSWMM_BUILD_BENCHMARKS) - find_package(benchmark CONFIG REQUIRED) - add_subdirectory(tests/benchmarks) endif() # Modern package configuration @@ -231,6 +315,17 @@ endif() # Add subdirectories add_subdirectory(src) +# Test / benchmark subdirs are added here (after src/) so the staging helper +# sees the imported runtime targets created during the engine configure. +if(OPENSWMM_BUILD_UNIT_TESTS OR OPENSWMM_BUILD_REGRESSION_TESTS) + add_subdirectory(tests) +endif() + +if(OPENSWMM_BUILD_BENCHMARKS) + find_package(benchmark CONFIG REQUIRED) + add_subdirectory(tests/benchmarks) +endif() + # Optional Python bindings (Cython extensions compiled via scikit-build). # Typically invoked indirectly by `pip install ./python`, but can also be # enabled as part of a top-level CMake build: diff --git a/CMakePresets.json b/CMakePresets.json index 633360e22..10cc99172 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -175,34 +175,73 @@ { "name": "Windows-tests", "inherits": "Windows-debug", - "description": "Windows debug build with unit + regression tests enabled", + "description": "Windows debug build with unit + regression tests + geopackage", "binaryDir": "${sourceDir}/build/windows-tests", "cacheVariables": { - "VCPKG_MANIFEST_FEATURES": "tests", + "VCPKG_MANIFEST_FEATURES": "tests;geopackage", "OPENSWMM_BUILD_UNIT_TESTS": "ON", - "OPENSWMM_BUILD_REGRESSION_TESTS": "ON" + "OPENSWMM_BUILD_REGRESSION_TESTS": "ON", + "OPENSWMM_WITH_GEOPACKAGE": "ON" } }, { "name": "Linux-tests", "inherits": "Linux-debug", - "description": "Linux debug build with unit + regression tests enabled", + "description": "Linux debug build with unit + regression tests + geopackage", "binaryDir": "${sourceDir}/build/linux-tests", "cacheVariables": { - "VCPKG_MANIFEST_FEATURES": "tests", + "VCPKG_MANIFEST_FEATURES": "tests;geopackage", "OPENSWMM_BUILD_UNIT_TESTS": "ON", - "OPENSWMM_BUILD_REGRESSION_TESTS": "ON" + "OPENSWMM_BUILD_REGRESSION_TESTS": "ON", + "OPENSWMM_WITH_GEOPACKAGE": "ON" } }, { "name": "Darwin-tests", "inherits": "Darwin-debug", - "description": "macOS debug build with unit + regression tests enabled", + "description": "macOS debug build with unit + regression tests + geopackage", "binaryDir": "${sourceDir}/build/darwin-tests", "cacheVariables": { - "VCPKG_MANIFEST_FEATURES": "tests", + "VCPKG_MANIFEST_FEATURES": "tests;geopackage", "OPENSWMM_BUILD_UNIT_TESTS": "ON", - "OPENSWMM_BUILD_REGRESSION_TESTS": "ON" + "OPENSWMM_BUILD_REGRESSION_TESTS": "ON", + "OPENSWMM_WITH_GEOPACKAGE": "ON" + } + }, + { + "name": "Windows-tests-release", + "inherits": "Windows", + "description": "Windows Release build with unit + regression tests + geopackage", + "binaryDir": "${sourceDir}/build/windows-tests-release", + "cacheVariables": { + "VCPKG_MANIFEST_FEATURES": "tests;geopackage", + "OPENSWMM_BUILD_UNIT_TESTS": "ON", + "OPENSWMM_BUILD_REGRESSION_TESTS": "ON", + "OPENSWMM_WITH_GEOPACKAGE": "ON" + } + }, + { + "name": "Linux-tests-release", + "inherits": "Linux", + "description": "Linux Release build with unit + regression tests + geopackage", + "binaryDir": "${sourceDir}/build/linux-tests-release", + "cacheVariables": { + "VCPKG_MANIFEST_FEATURES": "tests;geopackage", + "OPENSWMM_BUILD_UNIT_TESTS": "ON", + "OPENSWMM_BUILD_REGRESSION_TESTS": "ON", + "OPENSWMM_WITH_GEOPACKAGE": "ON" + } + }, + { + "name": "Darwin-tests-release", + "inherits": "Darwin", + "description": "macOS Release build with unit + regression tests + geopackage", + "binaryDir": "${sourceDir}/build/darwin-tests-release", + "cacheVariables": { + "VCPKG_MANIFEST_FEATURES": "tests;geopackage", + "OPENSWMM_BUILD_UNIT_TESTS": "ON", + "OPENSWMM_BUILD_REGRESSION_TESTS": "ON", + "OPENSWMM_WITH_GEOPACKAGE": "ON" } } ], @@ -267,6 +306,21 @@ "name": "Darwin-tests", "description": "Build unit and regression tests on macOS", "configurePreset": "Darwin-tests" + }, + { + "name": "Windows-tests-release", + "description": "Build unit and regression tests in Release on Windows", + "configurePreset": "Windows-tests-release" + }, + { + "name": "Linux-tests-release", + "description": "Build unit and regression tests in Release on Linux", + "configurePreset": "Linux-tests-release" + }, + { + "name": "Darwin-tests-release", + "description": "Build unit and regression tests in Release on macOS", + "configurePreset": "Darwin-tests-release" } ] } \ No newline at end of file diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 6b09afc03..0fc6cafb4 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -11,6 +11,40 @@ cmake_minimum_required(VERSION 3.24) +# ============================================================================ +# Source-distribution guard: the sdist on PyPI is metadata-only and does NOT +# carry the OpenSWMM C/C++ engine source tree. Detect that we have neither a +# pre-built engine prefix nor the parent project's CMakeLists.txt reachable +# via add_subdirectory(..) and fail with a clear, actionable error rather +# than letting vcpkg or the C compiler emit a confusing one. Without this +# guard, a stray VCPKG_ROOT in the user's environment causes the auto-load +# block below to fire and vcpkg then tries to read a vcpkg.json next to the +# unpacked sdist that does not exist. +# ============================================================================ +if(NOT DEFINED OPENSWMM_ENGINE_INSTALL_PREFIX + AND NOT DEFINED ENV{OPENSWMM_ENGINE_INSTALL_PREFIX} + AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/../CMakeLists.txt") + message(FATAL_ERROR + "openswmm cannot be built from this source distribution: the sdist " + "does not bundle the OpenSWMM C/C++ engine sources, and no pre-built " + "engine was supplied via -DOPENSWMM_ENGINE_INSTALL_PREFIX=.\n" + "\n" + "Install a pre-built wheel instead:\n" + " python -m pip install openswmm\n" + "\n" + "Wheels are published for Python 3.9-3.13 on Linux x86_64, macOS " + "(arm64 and x86_64), and Windows x64. If pip fell back to this " + "sdist, no wheel matched your platform/interpreter -- check the " + "available files at https://pypi.org/project/openswmm/#files and, " + "if needed, switch to a supported Python version.\n" + "\n" + "Source builds are supported only from a full git checkout of the " + "openswmm.engine repository:\n" + " git clone https://github.com/hydrocouple/openswmm.engine\n" + " cd openswmm.engine/python\n" + " pip install . --no-build-isolation") +endif() + # ============================================================================ # vcpkg manifest directory: the vcpkg.json lives one directory above python/. # Set VCPKG_MANIFEST_DIR before project() so vcpkg's toolchain file sees it. diff --git a/python/pyproject.toml b/python/pyproject.toml index aed13378f..a08b87d74 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -44,9 +44,9 @@ build-backend = "scikit_build_core.build" # ============================================================================ [project] name = "openswmm" -version = "6.0.0.dev1" +version = "6.0.0a1" description = "Python bindings for the OpenSWMM stormwater modelling engine." -requires-python = ">=3.9" +requires-python = ">=3.10" readme = { file = "README.md", content-type = "text/markdown" } license = { file = "LICENSE" } authors = [{ name = "Caleb Buahin", email = "caleb.buahin@gmail.com" }] @@ -68,7 +68,6 @@ classifiers = [ "Operating System :: POSIX :: Linux", "Operating System :: MacOS", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -192,17 +191,45 @@ cmake.build-type = "Debug" # ============================================================================ # cibuildwheel (release-wheel matrix) # ============================================================================ +# See docs/CIBUILDWHEEL_REVERT_PLAN.md for the rationale behind each setting. +# +# Linux: vcpkg is git-cloned and bootstrapped INSIDE the manylinux container +# via `before-all`. The vcpkg GHA binary cache (x-gha) is reused across runs +# so SUNDIALS / HDF5 / etc. are not rebuilt every time. The workflow forwards +# ACTIONS_CACHE_URL / ACTIONS_RUNTIME_TOKEN into the container so x-gha works. +# macOS / Windows: no container; vcpkg lives on the host runner and +# VCPKG_ROOT is exported by the workflow. [tool.cibuildwheel] -build = "cp39-* cp310-* cp311-* cp312-* cp313-*" -skip = "*-musllinux_* *-win32 *-manylinux_i686" +build = "cp310-* cp311-* cp312-* cp313-*" +skip = "*-musllinux_* *-win32 *-manylinux_i686 cp3??t-*" +archs = "native" +build-verbosity = 1 + +# Modern glibc baseline (RHEL 9 / Ubuntu 20.04+ / Debian 11+). +manylinux-x86_64-image = "manylinux_2_28" +manylinux-aarch64-image = "manylinux_2_28" test-requires = ["pytest", "numpy"] test-command = "pytest {package}/tests/engine -v --import-mode=importlib --ignore={package}/tests/engine/test_integration.py" [tool.cibuildwheel.linux] -before-all = "yum install -y ninja-build libgomp || apt-get install -y ninja-build libgomp1" +# Bootstrap vcpkg inside the manylinux container. The host's $VCPKG_ROOT is +# not visible here (this is what broke the previous cibuildwheel attempt). +before-all = """ +set -euo pipefail +(yum install -y curl zip unzip tar git ninja-build libgomp) || \ + (apt-get update && apt-get install -y curl zip unzip tar git ninja-build libgomp1) +git clone --depth 1 --branch 2025.02.14 https://github.com/microsoft/vcpkg.git /host/vcpkg +/host/vcpkg/bootstrap-vcpkg.sh -disableMetrics +/host/vcpkg/vcpkg install \ + --triplet "$(uname -m)-linux" \ + --x-manifest-root /project \ + --x-install-root /host/vcpkg/installed +""" before-build = "pip install ninja" -environment = { VCPKG_ROOT = "$VCPKG_ROOT", CMAKE_ARGS = "$CMAKE_ARGS" } +environment = { VCPKG_ROOT = "/host/vcpkg" } +# Forward GHA cache creds + vcpkg binary cache config into the container. +environment-pass = ["ACTIONS_CACHE_URL", "ACTIONS_RUNTIME_TOKEN", "VCPKG_BINARY_SOURCES"] [tool.cibuildwheel.macos] # OpenMP on macOS: @@ -219,12 +246,12 @@ environment = { VCPKG_ROOT = "$VCPKG_ROOT", CMAKE_ARGS = "$CMAKE_ARGS" } before-all = "brew install ninja libomp" before-build = "pip install delocate" repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}" -environment = { VCPKG_ROOT = "$VCPKG_ROOT", CMAKE_ARGS = "$CMAKE_ARGS" } +environment = { VCPKG_ROOT = "$VCPKG_ROOT", MACOSX_DEPLOYMENT_TARGET = "11.0" } [tool.cibuildwheel.windows] before-build = "pip install delvewheel" repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}" -environment = { VCPKG_ROOT = "$VCPKG_ROOT", CMAKE_ARGS = "$CMAKE_ARGS" } +environment = { VCPKG_ROOT = "$VCPKG_ROOT" } # ============================================================================ # pytest diff --git a/python/scripts/repair_wheel_windows.py b/python/scripts/repair_wheel_windows.py deleted file mode 100644 index eb959ab76..000000000 --- a/python/scripts/repair_wheel_windows.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Run delvewheel on every wheel in dist/ with the right --add-path hints. - -delvewheel needs to locate the engine DLL (openswmm.engine.dll) and its -transitive runtime dependencies (vcpkg-installed libs, OpenMP, etc.) so it -can bundle them into the repaired wheel. By default delvewheel only looks -at PATH and the wheel's own directories, which is not enough here: the -runtime deps live under VCPKG_ROOT/installed//bin, and the engine -DLL itself is most reliably found via the already-installed package from -the preceding `pip install .` step. -""" -from __future__ import annotations - -import glob -import os -import subprocess -import sys -from pathlib import Path - - -def _site_packages_openswmm() -> Path | None: - # -P would be cleaner but we're already running with the source tree - # absent from sys.path (this script is invoked from python/, not from - # python/openswmm/). Import the installed package and use its __path__. - try: - import openswmm # type: ignore - except ImportError: - return None - return Path(openswmm.__path__[0]) - - -def _vcpkg_bin() -> Path | None: - vcpkg_root = os.environ.get("VCPKG_ROOT") - triplet = os.environ.get("VCPKG_DEFAULT_TRIPLET", "x64-windows") - if not vcpkg_root: - return None - candidate = Path(vcpkg_root) / "installed" / triplet / "bin" - return candidate if candidate.is_dir() else None - - -def collect_add_paths() -> list[str]: - paths: set[str] = set() - - pkg_root = _site_packages_openswmm() - if pkg_root is not None: - for dll in pkg_root.rglob("*.dll"): - paths.add(str(dll.parent)) - - vcpkg_bin = _vcpkg_bin() - if vcpkg_bin is not None: - paths.add(str(vcpkg_bin)) - - return sorted(paths) - - -def main() -> int: - wheels = glob.glob("dist/*.whl") - if not wheels: - print("No wheel found in dist/", file=sys.stderr) - return 1 - - add_paths = collect_add_paths() - print("delvewheel --add-path entries:") - for p in add_paths: - print(f" {p}") - - base_cmd = [sys.executable, "-m", "delvewheel", "repair", "-w", "dist"] - for p in add_paths: - base_cmd += ["--add-path", p] - - for wheel in wheels: - subprocess.check_call(base_cmd + [wheel]) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 7e3ad8ed5..3e17aa86f 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -42,10 +42,32 @@ if(NOT WIN32) ) endif() -install(TARGETS openswmm DESTINATION bin) +# Install the executable plus the full closure of its runtime dependencies +# (SUNDIALS, HDF5, libomp, sqlite3, GLEW/GLFW/ANGLE etc.) into bin/ so the +# packaged tarball is self-contained on download. The bundling step probes +# the already-installed executable so it follows the same RPATH the loader +# uses at runtime — see openswmm_bundle_runtime_deps() at top-level for why +# we can't use install(TARGETS ... RUNTIME_DEPENDENCY_SET ...) here. +install(TARGETS openswmm + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) +openswmm_bundle_runtime_deps(openswmm ${CMAKE_INSTALL_BINDIR}) add_custom_command(TARGET openswmm POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_BINARY_DIR}/bin/$/$ ) + +# Stage runtime DLLs / dylibs next to the build-tree copy of the exe so +# `./build/bin//openswmm` runs without LD/DYLD/PATH overrides. +if(WIN32) + add_custom_command(TARGET openswmm POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + $ + $ + COMMAND_EXPAND_LISTS + ) +endif() diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index 04a081368..2638ace57 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -222,9 +222,15 @@ endif() # build-tree binary uses CMake's automatically-computed build rpath (link dirs) # rather than the wheel-layout path that does not exist in the build tree. if(APPLE) - set(LIB_ROOT "@loader_path/../.dylibs") + # @loader_path/../.dylibs : wheel layout (delocate places bundled deps there) + # @loader_path : sibling-co-located deps when consumer puts the + # engine + its deps in the same dir + # @loader_path/../bin : standalone install (cmake --install) — deps are + # bundled into bin/, engine .dylib lives in lib/ + # @loader_path/../lib : sibling lib dir (engine.dylib's lib/) + set(LIB_ROOT "@loader_path/../.dylibs;@loader_path;@loader_path/../bin;@loader_path/../lib") elseif(UNIX) - set(LIB_ROOT "$ORIGIN/../.dylibs") + set(LIB_ROOT "$ORIGIN/../.dylibs;$ORIGIN;$ORIGIN/../bin;$ORIGIN/../lib") else() set(LIB_ROOT "") endif() @@ -245,6 +251,24 @@ install( RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) +# Bundle imported third-party runtime libraries next to the executables that +# consume openswmm_engine. The CLI install (src/cli/CMakeLists.txt) already +# captures the full closure via RUNTIME_DEPENDENCIES; this block exists for +# downstream consumers who install openswmm_engine without an executable +# (e.g. a Python wheel built standalone outside cibuildwheel, or a sibling +# C++ project that links the engine). Co-locating in bin/ matches the RPATH +# already configured on the engine ("@loader_path/../.dylibs" stays for the +# wheel path; "@loader_path" + "$ORIGIN" cover the standalone-install path). +openswmm_install_runtime_deps(${CMAKE_INSTALL_BINDIR} + SUNDIALS::cvode + SUNDIALS::nvecserial + SUNDIALS::sunmatrixdense + SUNDIALS::sunlinsoldense + hdf5::hdf5-shared + unofficial::sqlite3::sqlite3 + SQLite::SQLite3 +) + install( DIRECTORY ${PROJECT_SOURCE_DIR}/include/openswmm/engine DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/openswmm diff --git a/src/legacy/cli/CMakeLists.txt b/src/legacy/cli/CMakeLists.txt index 982411e06..3e7d58a53 100644 --- a/src/legacy/cli/CMakeLists.txt +++ b/src/legacy/cli/CMakeLists.txt @@ -30,10 +30,23 @@ if(NOT WIN32) set_target_properties(openswmm_legacy PROPERTIES INSTALL_RPATH "${LIB_ROOT}") endif() -install(TARGETS openswmm_legacy DESTINATION bin) +install(TARGETS openswmm_legacy + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) +openswmm_bundle_runtime_deps(openswmm_legacy ${CMAKE_INSTALL_BINDIR}) add_custom_command(TARGET openswmm_legacy POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy $ ${CMAKE_BINARY_DIR}/bin/$/$ ) + +if(WIN32) + add_custom_command(TARGET openswmm_legacy POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + $ + COMMAND_EXPAND_LISTS + ) +endif() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b6e4213bf..e4ad7f819 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -41,6 +41,73 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(TEST_BIN_DIRECTORY ${CMAKE_BINARY_DIR}/bin/$) set(TEST_DATA_ROOT ${CMAKE_CURRENT_SOURCE_DIR}) +# Stage every shared-library dependency next to a test executable so ctest +# can launch it without DYLD/LD/PATH gymnastics. Generalises the Windows-only +# recipe that previously lived in tests/regression/CMakeLists.txt — it was +# necessary there because the loader searches the EXE's directory, not the +# importing DLL's directory, for transitive imports. +# +# Why three TARGET_RUNTIME_DLLS lookups on Windows: $ +# walks PRIVATE deps of T itself but only INTERFACE deps when recursing. +# openswmm_engine links SUNDIALS / HDF5 / sqlite3 PRIVATELY, so without the +# extra lookups against each engine target the third-party DLLs are missed. +# +# On Unix the test exes already have $ORIGIN / @loader_path in RPATH (see +# the LIB_ROOT blocks in every test CMakeLists), so colocating dylibs by +# copying the imported targets' $ is enough. +function(openswmm_stage_test_runtime_deps TARGET_NAME) + if(WIN32) + add_custom_command(TARGET ${TARGET_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + $ + $ + COMMAND_EXPAND_LISTS + ) + else() + # Unix: copy each imported third-party shared lib (when defined) + # next to the exe. Targets that don't exist in this configuration + # are silently skipped via $. + set(_imported_deps + SUNDIALS::cvode + SUNDIALS::nvecserial + SUNDIALS::sunmatrixdense + SUNDIALS::sunlinsoldense + hdf5::hdf5-shared + OpenMP::OpenMP_CXX + unofficial::sqlite3::sqlite3 + SQLite::SQLite3 + ) + foreach(_dep IN LISTS _imported_deps) + if(TARGET ${_dep}) + # Wrap each copy in its own command so a missing IMPORTED_LOCATION + # on one target doesn't break the whole step (e.g. interface + # libraries that point to OpenMP_CXX_FLAGS only). + get_target_property(_dep_type ${_dep} TYPE) + if(_dep_type STREQUAL "SHARED_LIBRARY") + add_custom_command(TARGET ${TARGET_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + ) + endif() + endif() + endforeach() + + # Apple Homebrew libomp is exposed via OpenMP::OpenMP_CXX as an + # INTERFACE library, not a SHARED imported target — its dylib path + # lives in the OpenMP_omp_LIBRARY cache variable. + if(APPLE AND OpenMP_omp_LIBRARY AND EXISTS "${OpenMP_omp_LIBRARY}") + add_custom_command(TARGET ${TARGET_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${OpenMP_omp_LIBRARY}" + $ + ) + endif() + endif() +endfunction() + # ---- Unit tests (legacy + new engine) ---- if(OPENSWMM_BUILD_UNIT_TESTS) add_subdirectory(unit) diff --git a/tests/regression/CMakeLists.txt b/tests/regression/CMakeLists.txt index ec7dca6bc..77596719c 100644 --- a/tests/regression/CMakeLists.txt +++ b/tests/regression/CMakeLists.txt @@ -62,28 +62,11 @@ add_custom_command(TARGET test_engine_regression POST_BUILD ${CMAKE_BINARY_DIR}/bin/$/$ ) -# Windows: stage every runtime DLL the test depends on (project libs + -# vcpkg imported targets like SUNDIALS / HDF5 / sqlite3) next to the exe. -# Without this the loader fails with STATUS_DLL_NOT_FOUND (0xc0000135) -# at test launch, since the engine DLLs are built in src/engine/$/ -# and src/legacy/engine/$/ rather than alongside the exe, and -# vcpkg's applocal step only stages vcpkg-managed DLLs. -# -# Why three TARGET_RUNTIME_DLLS lookups: the genex walks PRIVATE deps of the -# named target itself, but only INTERFACE deps when recursing through deps-of- -# deps. openswmm_engine links SUNDIALS::cvode and hdf5::hdf5-shared PRIVATELY, -# so $ yields the two engine DLLs -# but NOT sundials_cvode.dll / hdf5.dll. The Windows loader searches the EXE's -# directory (not the importing DLL's directory) for transitive imports, so we -# must also ask each shared engine target for its own runtime DLLs and stage -# them alongside the test exe. -if(WIN32) - add_custom_command(TARGET test_engine_regression POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - $ - $ - $ - $ - COMMAND_EXPAND_LISTS - ) -endif() +# Stage every runtime shared library the test depends on (project libs + +# vcpkg imported targets like SUNDIALS / HDF5 / sqlite3 / libomp) next to +# the exe. On Windows, the loader fails with STATUS_DLL_NOT_FOUND otherwise. +# On Unix the rpath finds dylibs in lib/ at install time, but ctest runs +# from the build tree where the engine dylibs live in a sibling dir; the +# helper colocates them next to the test exe so $/@loader_path +# resolves them. +openswmm_stage_test_runtime_deps(test_engine_regression) diff --git a/tests/unit/engine/CMakeLists.txt b/tests/unit/engine/CMakeLists.txt index 65f378aaa..285f69562 100644 --- a/tests/unit/engine/CMakeLists.txt +++ b/tests/unit/engine/CMakeLists.txt @@ -62,6 +62,8 @@ function(add_gtest_unit TEST_NAME SOURCE_FILE) INSTALL_RPATH "${LIB_ROOT}" ) + openswmm_stage_test_runtime_deps(${TEST_NAME}) + add_test( NAME ${TEST_NAME} COMMAND $ @@ -163,6 +165,7 @@ set_target_properties(test_engine_2d_surface PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${TEST_BIN_DIRECTORY} INSTALL_RPATH "${LIB_ROOT}" ) +openswmm_stage_test_runtime_deps(test_engine_2d_surface) add_test( NAME test_engine_2d_surface COMMAND $ @@ -191,6 +194,7 @@ if(OPENSWMM_WITH_GEOPACKAGE) RUNTIME_OUTPUT_DIRECTORY ${TEST_BIN_DIRECTORY} INSTALL_RPATH "${LIB_ROOT}" ) + openswmm_stage_test_runtime_deps(test_engine_geopackage) add_test( NAME test_engine_geopackage COMMAND $ diff --git a/tests/unit/legacy/engine/CMakeLists.txt b/tests/unit/legacy/engine/CMakeLists.txt index 8ff6ac9b9..f53654c0d 100644 --- a/tests/unit/legacy/engine/CMakeLists.txt +++ b/tests/unit/legacy/engine/CMakeLists.txt @@ -49,6 +49,8 @@ function(add_solver_test TEST_NAME SOURCE_FILE) RUNTIME_OUTPUT_DIRECTORY ${TEST_BIN_DIRECTORY} INSTALL_RPATH "${LIB_ROOT}" ) + + openswmm_stage_test_runtime_deps(${TEST_NAME}) endfunction() add_solver_test(test_solver_api test_solver_api.cpp) diff --git a/tests/unit/legacy/engine/data/hotstart/_api_test.rpt b/tests/unit/legacy/engine/data/hotstart/_api_test.rpt index 06a082e62..fb4bbdebc 100644 --- a/tests/unit/legacy/engine/data/hotstart/_api_test.rpt +++ b/tests/unit/legacy/engine/data/hotstart/_api_test.rpt @@ -311,6 +311,6 @@ C11 0.000 - Analysis begun on: Sat May 23 11:17:39 2026 - Analysis ended on: Sat May 23 11:17:40 2026 - Total elapsed time: 00:00:01 \ No newline at end of file + Analysis begun on: Sat May 23 20:06:20 2026 + Analysis ended on: Sat May 23 20:06:20 2026 + Total elapsed time: < 1 sec \ No newline at end of file diff --git a/tests/unit/legacy/engine/data/hotstart/_err_test.rpt b/tests/unit/legacy/engine/data/hotstart/_err_test.rpt index 6fa5e96d0..8e75b42c6 100644 --- a/tests/unit/legacy/engine/data/hotstart/_err_test.rpt +++ b/tests/unit/legacy/engine/data/hotstart/_err_test.rpt @@ -5,6 +5,6 @@ A site surface drainage model. See Site_Drainage_Model.txt for more details. - Analysis begun on: Sat May 23 11:17:40 2026 - Analysis ended on: Sat May 23 11:17:40 2026 + Analysis begun on: Sat May 23 20:06:20 2026 + Analysis ended on: Sat May 23 20:06:20 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/tests/unit/legacy/engine/data/hotstart/_expanded_api_test.rpt b/tests/unit/legacy/engine/data/hotstart/_expanded_api_test.rpt index 0d545adf2..7b15f2ce4 100644 --- a/tests/unit/legacy/engine/data/hotstart/_expanded_api_test.rpt +++ b/tests/unit/legacy/engine/data/hotstart/_expanded_api_test.rpt @@ -311,6 +311,6 @@ C11 0.000 - Analysis begun on: Sat May 23 11:17:56 2026 - Analysis ended on: Sat May 23 11:17:56 2026 + Analysis begun on: Sat May 23 20:06:28 2026 + Analysis ended on: Sat May 23 20:06:28 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/tests/unit/legacy/engine/data/hotstart/_shap_test.rpt b/tests/unit/legacy/engine/data/hotstart/_shap_test.rpt index 02f2284e1..2f49778dd 100644 --- a/tests/unit/legacy/engine/data/hotstart/_shap_test.rpt +++ b/tests/unit/legacy/engine/data/hotstart/_shap_test.rpt @@ -5,6 +5,6 @@ A site surface drainage model. See Site_Drainage_Model.txt for more details. - Analysis begun on: Sat May 23 11:17:41 2026 - Analysis ended on: Sat May 23 11:17:41 2026 + Analysis begun on: Sat May 23 20:06:21 2026 + Analysis ended on: Sat May 23 20:06:21 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/tests/unit/legacy/engine/data/hotstart/hotstart_end.hsf b/tests/unit/legacy/engine/data/hotstart/hotstart_end.hsf index 9bf07e95c5c110399de751cd9d9939da0ced029f..df7609797df27dd1fd80169db39500bd3995fc7f 100644 GIT binary patch delta 125 zcmX@keVluOJIA?GLhGgPJ#(0Rk;!6Wdjbl7vN|K0z;SeebS4xPPLl Date: Sat, 23 May 2026 20:36:01 -0400 Subject: [PATCH 04/26] Building wheels and packaging dependencies Signed-off-by: cbuahin --- CMakeLists.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6fdcc3da3..57530a5f7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -178,7 +178,12 @@ function(openswmm_bundle_runtime_deps TARGET_NAME SUBDIR) set(OPENSWMM_BUNDLE_PRE_EXCLUDES "[==[${OPENSWMM_RUNTIME_DEP_PRE_EXCLUDES}]==]") set(OPENSWMM_BUNDLE_POST_EXCLUDES "[==[${OPENSWMM_RUNTIME_DEP_POST_EXCLUDES}]==]") - set(_script_in "${CMAKE_SOURCE_DIR}/cmake/BundleRuntimeDeps.cmake.in") + # Use PROJECT_SOURCE_DIR (not CMAKE_SOURCE_DIR): the engine has its own + # project() at line 28, so this resolves to the engine root even when the + # engine is pulled in via add_subdirectory(..) from python/CMakeLists.txt. + # With CMAKE_SOURCE_DIR, the lookup would (incorrectly) start at the + # top-level CMakeLists — which under scikit-build-core is python/. + set(_script_in "${PROJECT_SOURCE_DIR}/cmake/BundleRuntimeDeps.cmake.in") set(_script_out "${CMAKE_CURRENT_BINARY_DIR}/BundleRuntimeDeps-${TARGET_NAME}.cmake") configure_file("${_script_in}" "${_script_out}" @ONLY) install(SCRIPT "${_script_out}") From d64e8175531087ddef66aefa51c6a64b1863ca3d Mon Sep 17 00:00:00 2001 From: cbuahin Date: Sat, 23 May 2026 20:43:11 -0400 Subject: [PATCH 05/26] Packaging and bundling dependencies Signed-off-by: cbuahin --- cmake/BundleRuntimeDeps.cmake.in | 124 +++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 cmake/BundleRuntimeDeps.cmake.in diff --git a/cmake/BundleRuntimeDeps.cmake.in b/cmake/BundleRuntimeDeps.cmake.in new file mode 100644 index 000000000..6e44e1a13 --- /dev/null +++ b/cmake/BundleRuntimeDeps.cmake.in @@ -0,0 +1,124 @@ +# +# BundleRuntimeDeps.cmake.in +# +# Configured per-target by openswmm_bundle_runtime_deps() at top-level +# CMakeLists.txt. Runs at install time as part of the per-directory +# cmake_install.cmake script. Probes the already-installed executable +# (so RPATH rewrites from install_name_tool are in effect) and copies +# every shared library it depends on — except OS-provided ones filtered +# by the PRE/POST_EXCLUDE_REGEXES — next to the exe. +# +# @-substituted placeholders: see configure_file() call at the helper. +# + +cmake_policy(PUSH) +cmake_policy(SET CMP0011 NEW) # POLICY scope, regular variable scope rules +# NB: regexes contain '\.' and '\+' which CMake's quoted-string parser would +# otherwise flag under CMP0010 (bad variable reference syntax). Using a +# template file keeps the literal backslashes out of the install(CODE) +# interpolation path. + +set(_exe "${CMAKE_INSTALL_PREFIX}/@OPENSWMM_BUNDLE_SUBDIR@/@OPENSWMM_BUNDLE_TARGET_FILE_NAME@") +if(NOT EXISTS "${_exe}") + message(WARNING "openswmm_bundle_runtime_deps: ${_exe} does not exist yet — install ordering bug?") + cmake_policy(POP) + return() +endif() + +message(STATUS "Bundling runtime deps for @OPENSWMM_BUNDLE_TARGET_NAME@ into @OPENSWMM_BUNDLE_SUBDIR@") + +file(GET_RUNTIME_DEPENDENCIES + RESOLVED_DEPENDENCIES_VAR _resolved + UNRESOLVED_DEPENDENCIES_VAR _unresolved + EXECUTABLES "${_exe}" + DIRECTORIES + "${CMAKE_INSTALL_PREFIX}/@CMAKE_INSTALL_BINDIR@" + "${CMAKE_INSTALL_PREFIX}/@CMAKE_INSTALL_LIBDIR@" + PRE_EXCLUDE_REGEXES @OPENSWMM_BUNDLE_PRE_EXCLUDES@ + POST_EXCLUDE_REGEXES @OPENSWMM_BUNDLE_POST_EXCLUDES@ +) + +file(REAL_PATH "${CMAKE_INSTALL_PREFIX}" _prefix_real) +set(_bundled_basename_to_oldpath "") # map of basename → original absolute path +foreach(_dep IN LISTS _resolved) + file(REAL_PATH "${_dep}" _dep_real) + # Skip anything that already lives under the install prefix — those + # files are installed by their own install(TARGETS …) rule and copying + # again would just duplicate them. + string(FIND "${_dep_real}" "${_prefix_real}/" _in_prefix) + if(_in_prefix EQUAL 0) + continue() + endif() + + if(_dep MATCHES "\\.framework/") + # Copy the whole .framework directory, not just the inner binary. + string(REGEX REPLACE "(.*\\.framework)/.*" "\\1" _fwk "${_dep}") + file(INSTALL DESTINATION "${CMAKE_INSTALL_PREFIX}/@OPENSWMM_BUNDLE_SUBDIR@" + TYPE DIRECTORY FILES "${_fwk}" USE_SOURCE_PERMISSIONS) + message(STATUS " bundled framework: ${_fwk}") + else() + file(INSTALL DESTINATION "${CMAKE_INSTALL_PREFIX}/@OPENSWMM_BUNDLE_SUBDIR@" + TYPE SHARED_LIBRARY FILES "${_dep}" FOLLOW_SYMLINK_CHAIN) + message(STATUS " bundled: ${_dep}") + get_filename_component(_bn "${_dep}" NAME) + list(APPEND _bundled_basename_to_oldpath "${_bn}=${_dep}") + endif() +endforeach() + +foreach(_dep IN LISTS _unresolved) + message(WARNING " unresolved runtime dep: ${_dep}") +endforeach() + +# ---- macOS install_name rewrite ----------------------------------------- +# On macOS, bundling a dylib doesn't help if consumers still reference its +# ORIGINAL absolute path (LC_LOAD_DYLIB). Each bundled lib gets its install +# name flipped to @rpath/, and every dylib / executable in the +# install tree gets its references to the original path rewritten the same +# way. The dyld loader then resolves @rpath via each binary's LC_RPATH set, +# which we've configured to include @loader_path / @loader_path/../bin / +# @loader_path/../lib (see project-wide RPATH config in src/engine). +if(APPLE) + find_program(_install_name_tool install_name_tool REQUIRED) + + # 1. Set each bundled lib's own install_name to @rpath/ so any + # binary that links it after rewriting picks the @rpath entry. + foreach(_entry IN LISTS _bundled_basename_to_oldpath) + string(REGEX REPLACE "^([^=]+)=.*$" "\\1" _bn "${_entry}") + set(_installed "${CMAKE_INSTALL_PREFIX}/@OPENSWMM_BUNDLE_SUBDIR@/${_bn}") + if(EXISTS "${_installed}") + execute_process( + COMMAND "${_install_name_tool}" -id "@rpath/${_bn}" "${_installed}" + RESULT_VARIABLE _rc + ) + if(NOT _rc EQUAL 0) + message(WARNING " install_name_tool -id failed on ${_installed}") + endif() + endif() + endforeach() + + # 2. Walk every Mach-O in the install tree and rewrite LC_LOAD_DYLIB + # references that still point at a bundled lib's original abs path. + file(GLOB_RECURSE _machos + "${CMAKE_INSTALL_PREFIX}/@CMAKE_INSTALL_BINDIR@/*" + "${CMAKE_INSTALL_PREFIX}/@CMAKE_INSTALL_LIBDIR@/*" + ) + foreach(_macho IN LISTS _machos) + if(IS_SYMLINK "${_macho}") + continue() + endif() + if(_macho MATCHES "\\.(a|h|hpp|cmake)$") + continue() + endif() + foreach(_entry IN LISTS _bundled_basename_to_oldpath) + string(REGEX REPLACE "^([^=]+)=(.*)$" "\\1" _bn "${_entry}") + string(REGEX REPLACE "^([^=]+)=(.*)$" "\\2" _oldpath "${_entry}") + execute_process( + COMMAND "${_install_name_tool}" -change "${_oldpath}" "@rpath/${_bn}" "${_macho}" + RESULT_VARIABLE _rc + ERROR_QUIET + ) + endforeach() + endforeach() +endif() + +cmake_policy(POP) From dec538d4cffec3a26ac891e06cf89d6ea8de63c9 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Sat, 23 May 2026 21:45:07 -0400 Subject: [PATCH 06/26] Packaging and bundling dependencies Signed-off-by: cbuahin --- .github/workflows/deployment.yml | 6 ++++++ .github/workflows/unit_testing_python.yml | 4 ++++ python/pyproject.toml | 25 +++++++++++++++++++--- src/engine/input/geopackage/CMakeLists.txt | 9 ++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 2bd382fb7..071206b7f 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -193,6 +193,12 @@ jobs: # the container by pyproject.toml's before-all hook). VCPKG_ROOT: ${{ github.workspace }}/vcpkg VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" + # Force the macOS deployment target via cibuildwheel's env channel. + # Setting it ONLY in pyproject.toml [tool.cibuildwheel.macos].environment + # was not propagating to the x86_64 build's wheel-tag computation + # (wheel ended up tagged macosx_10_9 despite the toml override). + # CIBW_ENVIRONMENT_MACOS is read early and applied uniformly. + CIBW_ENVIRONMENT_MACOS: MACOSX_DEPLOYMENT_TARGET=15.0 VCPKG_ROOT=${{ github.workspace }}/vcpkg - name: Upload Python wheels if: always() diff --git a/.github/workflows/unit_testing_python.yml b/.github/workflows/unit_testing_python.yml index 22313b8a0..af97e94bc 100644 --- a/.github/workflows/unit_testing_python.yml +++ b/.github/workflows/unit_testing_python.yml @@ -80,6 +80,10 @@ jobs: # Linux uses /host/vcpkg inside the container (see pyproject.toml). VCPKG_ROOT: ${{ github.workspace }}/vcpkg VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" + # See deployment.yml for rationale — pyproject.toml's + # [tool.cibuildwheel.macos].environment doesn't reach the + # x86_64 wheel-tag computation reliably; CIBW_ENVIRONMENT_MACOS does. + CIBW_ENVIRONMENT_MACOS: MACOSX_DEPLOYMENT_TARGET=15.0 VCPKG_ROOT=${{ github.workspace }}/vcpkg - name: Upload Python wheels if: always() diff --git a/python/pyproject.toml b/python/pyproject.toml index a08b87d74..f0cd25f71 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -215,14 +215,23 @@ test-command = "pytest {package}/tests/engine -v --import-mode=importlib --igno [tool.cibuildwheel.linux] # Bootstrap vcpkg inside the manylinux container. The host's $VCPKG_ROOT is # not visible here (this is what broke the previous cibuildwheel attempt). +# +# Triplet naming: vcpkg uses x64/arm64, not uname -m's x86_64/aarch64. +# Mapping is done explicitly to avoid silently picking the wrong triplet +# if vcpkg ever adds e.g. an x86_64-linux community alias. before-all = """ set -euo pipefail (yum install -y curl zip unzip tar git ninja-build libgomp) || \ (apt-get update && apt-get install -y curl zip unzip tar git ninja-build libgomp1) +case "$(uname -m)" in + x86_64) VCPKG_TRIPLET=x64-linux ;; + aarch64) VCPKG_TRIPLET=arm64-linux ;; + *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; +esac git clone --depth 1 --branch 2025.02.14 https://github.com/microsoft/vcpkg.git /host/vcpkg /host/vcpkg/bootstrap-vcpkg.sh -disableMetrics /host/vcpkg/vcpkg install \ - --triplet "$(uname -m)-linux" \ + --triplet "$VCPKG_TRIPLET" \ --x-manifest-root /project \ --x-install-root /host/vcpkg/installed """ @@ -246,11 +255,21 @@ environment-pass = ["ACTIONS_CACHE_URL", "ACTIONS_RUNTIME_TOKEN", "VCPKG_BINARY_ before-all = "brew install ninja libomp" before-build = "pip install delocate" repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel}" -environment = { VCPKG_ROOT = "$VCPKG_ROOT", MACOSX_DEPLOYMENT_TARGET = "11.0" } +# Deployment target = 15.0 because Homebrew's libomp bottle on the +# macos-15 runners is built with min-target 15.0; delocate refuses to +# bundle a dylib with a higher minimum than the wheel's stated target. +# Lowering this requires either building libomp from source (slow) or +# pinning to older runners. macOS 11–14 users build from sdist. +environment = { VCPKG_ROOT = "$VCPKG_ROOT", MACOSX_DEPLOYMENT_TARGET = "15.0" } [tool.cibuildwheel.windows] before-build = "pip install delvewheel" -repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}" +# Custom repair: delvewheel's default search (PATH only) misses both +# openswmm.engine.dll (installed inside the wheel at openswmm/engine/) +# and the vcpkg-provided runtime DLLs (SUNDIALS, HDF5, …). The helper +# script extracts the wheel, collects every DLL subdirectory, adds +# $VCPKG_ROOT/installed/x64-windows/bin, and passes them as --add-path. +repair-wheel-command = "python {project}/python/scripts/cibw_repair_windows.py {wheel} {dest_dir}" environment = { VCPKG_ROOT = "$VCPKG_ROOT" } # ============================================================================ diff --git a/src/engine/input/geopackage/CMakeLists.txt b/src/engine/input/geopackage/CMakeLists.txt index f0222fdcc..31b925972 100644 --- a/src/engine/input/geopackage/CMakeLists.txt +++ b/src/engine/input/geopackage/CMakeLists.txt @@ -59,6 +59,15 @@ target_link_libraries(openswmm_geopackage target_compile_definitions(openswmm_geopackage PUBLIC OPENSWMM_HAS_GEOPACKAGE=1 + # openswmm_geopackage is a STATIC library, but its implementation file + # decorates the swmm_gpkg_* function definitions with SWMM_ENGINE_API + # (from openswmm_engine.h). On MSVC, CMake only auto-defines + # openswmm_engine_EXPORTS for the engine SHARED target, so when this + # TU is compiled, SWMM_ENGINE_API expands to __declspec(dllimport) — + # and you cannot define a function as dllimport (C2491). Defining + # OPENSWMM_ENGINE_STATIC here short-circuits the macro to empty for + # this target only, which is correct: geopackage is statically linked. + PRIVATE OPENSWMM_ENGINE_STATIC ) # Install From 068ade3c23d91546924ef1ab93bd5895d3fa331d Mon Sep 17 00:00:00 2001 From: cbuahin Date: Sun, 24 May 2026 05:04:44 -0400 Subject: [PATCH 07/26] Add Windows system-DLL excludes + cibuildwheel fixes (libomp target, vcpkg triplet/cmake pin, Windows repair helper) --- CMakeLists.txt | 11 ++++ python/pyproject.toml | 14 +++- python/scripts/cibw_repair_windows.py | 94 +++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 python/scripts/cibw_repair_windows.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 57530a5f7..9dd79840f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -109,6 +109,17 @@ include(GenerateExportHeader) set(OPENSWMM_RUNTIME_DEP_PRE_EXCLUDES # Windows OS contract DLLs (MSVC redists not in this list — we bundle them) "api-ms-.*" "ext-ms-.*" + # Windows system DLLs by basename. These get pulled in transitively + # by Win32 API surface (shell, ACL UI, networking, theming, etc.) and + # must NOT be bundled — Windows always provides them. Critically, + # excluding them via POST is too late: one bundle pass copies them + # into the staging bin/ dir, the next pass then finds them in TWO + # places (system32 + staging/bin) and file(GET_RUNTIME_DEPENDENCIES) + # errors with "Multiple conflicting paths" before POST filters run. + # Add to this list when a new system DLL surfaces in CI warnings. + # NB: must be ONE quoted string — CMake parses each "" as a separate + # list element, so splitting across lines breaks the alternation. + "^(aclui|advapi32|bcrypt|bcryptprimitives|combase|comctl32|comdlg32|crypt32|cryptbase|cryptsp|dbgcore|dbghelp|dnsapi|dwmapi|dwrite|fwpuclnt|gdi32|gdi32full|imagehlp|imm32|iphlpapi|kernel32|kernelbase|mpr|msasn1|mscoree|msi|msvcp_win|msvcrt|mswsock|netapi32|netutils|normaliz|ntdll|ntmarta|ole32|oleacc|oleaut32|powrprof|profapi|propsys|psapi|rpcrt4|samcli|sechost|secur32|setupapi|sfc|sfc_os|shcore|shell32|shlwapi|srvcli|user32|userenv|usp10|uxtheme|version|wer|win32u|wininet|winmm|winnsi|wintrust|wkscli|wldap32|ws2_32|wsock32|wtsapi32|zlibwapi)\\.dll" # macOS / Linux system search paths "^/usr/lib/.*" "^/lib/.*" "^/lib64/.*" "^/System/Library/.*" "^/usr/lib/system/.*" diff --git a/python/pyproject.toml b/python/pyproject.toml index f0cd25f71..7cb964e0f 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -223,6 +223,18 @@ before-all = """ set -euo pipefail (yum install -y curl zip unzip tar git ninja-build libgomp) || \ (apt-get update && apt-get install -y curl zip unzip tar git ninja-build libgomp1) + +# Pin CMake < 4 inside the manylinux container, ahead of the system cmake. +# Why: manylinux_2_28_aarch64:2025.08.15-1 ships CMake 4.x. vcpkg-tool's +# bundled cmakerc dep has cmake_minimum_required(VERSION 3.3), which CMake +# 4.x rejects with "Compatibility with CMake < 3.5 has been removed". On +# x86_64, bootstrap-vcpkg.sh downloads a pre-built binary and skips this +# CMake compile entirely — the bug only fires on arm64. We apply the pin +# unconditionally so the same fix protects any vcpkg port (SUNDIALS, HDF5, +# ...) that might hit the same compatibility removal on either arch. +/opt/python/cp312-cp312/bin/pip install --quiet 'cmake<4' +export PATH="/opt/python/cp312-cp312/bin:$PATH" + case "$(uname -m)" in x86_64) VCPKG_TRIPLET=x64-linux ;; aarch64) VCPKG_TRIPLET=arm64-linux ;; @@ -269,7 +281,7 @@ before-build = "pip install delvewheel" # and the vcpkg-provided runtime DLLs (SUNDIALS, HDF5, …). The helper # script extracts the wheel, collects every DLL subdirectory, adds # $VCPKG_ROOT/installed/x64-windows/bin, and passes them as --add-path. -repair-wheel-command = "python {project}/python/scripts/cibw_repair_windows.py {wheel} {dest_dir}" +repair-wheel-command = "python {package}/scripts/cibw_repair_windows.py {wheel} {dest_dir}" environment = { VCPKG_ROOT = "$VCPKG_ROOT" } # ============================================================================ diff --git a/python/scripts/cibw_repair_windows.py b/python/scripts/cibw_repair_windows.py new file mode 100644 index 000000000..7e199028c --- /dev/null +++ b/python/scripts/cibw_repair_windows.py @@ -0,0 +1,94 @@ +"""cibuildwheel repair-wheel hook for Windows. + +Why this script exists +---------------------- +cibuildwheel's default repair command is + + delvewheel repair -w {dest_dir} {wheel} + +which only searches PATH for the wheel's DLL dependencies. That is not +enough for openswmm because: + + 1. ``openswmm.engine.dll`` is installed INTO the wheel at + ``openswmm/engine/openswmm.engine.dll`` (see + ``python/openswmm/CMakeLists.txt``). delvewheel does not recursively + scan wheel subdirectories for DLL search. + 2. Runtime deps installed by vcpkg (SUNDIALS, HDF5, sqlite3, …) live + under ``%VCPKG_ROOT%/installed/x64-windows/bin``, which is not on + PATH inside the cibuildwheel build venv. + +What it does +------------ +- Extracts the unrepaired wheel to a temp dir. +- Collects the directory of every ``*.dll`` found inside, recursively + — that handles ``openswmm/engine/`` (and any future moves). +- Adds ``$VCPKG_ROOT/installed//bin`` if VCPKG_ROOT is set. +- Invokes ``delvewheel repair`` with all collected dirs as + ``--add-path`` arguments. + +Wired in from ``python/pyproject.toml``: + + [tool.cibuildwheel.windows] + repair-wheel-command = "python {package}/scripts/cibw_repair_windows.py {wheel} {dest_dir}" + +Note: cibuildwheel substitutions are ``{package}`` (the --package-dir, +which is ``./python``), ``{wheel}``, and ``{dest_dir}``. There is NO +``{project}`` token — using it leaves the literal string in the command. +""" +from __future__ import annotations + +import os +import sys +import glob +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +def _wheel_dll_dirs(wheel_path: Path, extract_to: Path) -> list[Path]: + """Extract the wheel and return the dirs containing any .dll files.""" + with zipfile.ZipFile(wheel_path) as zf: + zf.extractall(extract_to) + dirs: set[Path] = set() + for dll in extract_to.rglob("*.dll"): + dirs.add(dll.parent.resolve()) + return sorted(dirs) + + +def _vcpkg_bin() -> Path | None: + vcpkg_root = os.environ.get("VCPKG_ROOT") + if not vcpkg_root: + return None + # x64-windows is the only Windows triplet we currently build for; if + # we ever add ARM Windows or static triplets, parametrise this. + triplet = os.environ.get("VCPKG_DEFAULT_TRIPLET", "x64-windows") + candidate = Path(vcpkg_root) / "installed" / triplet / "bin" + return candidate if candidate.is_dir() else None + + +def main(argv: list[str]) -> int: + if len(argv) != 3: + print(f"usage: {argv[0]} ", file=sys.stderr) + return 2 + wheel = Path(argv[1]).resolve() + dest = Path(argv[2]).resolve() + dest.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(prefix="cibw_repair_") as td: + add_paths = _wheel_dll_dirs(wheel, Path(td)) + vbin = _vcpkg_bin() + if vbin is not None: + add_paths.append(vbin) + + cmd: list[str] = ["delvewheel", "repair", "-w", str(dest)] + for p in add_paths: + cmd += ["--add-path", str(p)] + cmd.append(str(wheel)) + + print(">>> " + " ".join(cmd), flush=True) + return subprocess.call(cmd) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 8ef35014832054a671e3073495d8cb002adafe78 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Sun, 24 May 2026 05:55:02 -0400 Subject: [PATCH 08/26] Fix Windows DLL bundling + repair-wheel-command, bump version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CMakeLists.txt: PRE_EXCLUDE_REGEXES alternation converted to char-class form. CMake regex is case-sensitive but PE imports report system DLLs uppercase (ACLUI.dll, KERNEL32.DLL), so the earlier lowercase pattern silently failed to filter them. Verified with cmake -P that all case variants now match and engine/SUNDIALS/HDF5 DLLs still pass through. - python/pyproject.toml [tool.cibuildwheel.windows]: - repair-wheel-command: replaced {package}/scripts/... with the literal relative path python/scripts/cibw_repair_windows.py. cibuildwheel only substitutes {wheel} and {dest_dir} for repair — verified by reading cibuildwheel source. {package} and {project} pass through literally and Python fails with [Errno 2] No such file. - version: 6.0.0a1 -> 6.0.0.dev2 - python/scripts/cibw_repair_windows.py: docstring documents the verified placeholder rules so this trap doesn't recur. --- CMakeLists.txt | 10 +++++++--- python/pyproject.toml | 4 ++-- python/scripts/cibw_repair_windows.py | 14 +++++++++----- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9dd79840f..687ad55d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -117,9 +117,13 @@ set(OPENSWMM_RUNTIME_DEP_PRE_EXCLUDES # places (system32 + staging/bin) and file(GET_RUNTIME_DEPENDENCIES) # errors with "Multiple conflicting paths" before POST filters run. # Add to this list when a new system DLL surfaces in CI warnings. - # NB: must be ONE quoted string — CMake parses each "" as a separate - # list element, so splitting across lines breaks the alternation. - "^(aclui|advapi32|bcrypt|bcryptprimitives|combase|comctl32|comdlg32|crypt32|cryptbase|cryptsp|dbgcore|dbghelp|dnsapi|dwmapi|dwrite|fwpuclnt|gdi32|gdi32full|imagehlp|imm32|iphlpapi|kernel32|kernelbase|mpr|msasn1|mscoree|msi|msvcp_win|msvcrt|mswsock|netapi32|netutils|normaliz|ntdll|ntmarta|ole32|oleacc|oleaut32|powrprof|profapi|propsys|psapi|rpcrt4|samcli|sechost|secur32|setupapi|sfc|sfc_os|shcore|shell32|shlwapi|srvcli|user32|userenv|usp10|uxtheme|version|wer|win32u|wininet|winmm|winnsi|wintrust|wkscli|wldap32|ws2_32|wsock32|wtsapi32|zlibwapi)\\.dll" + # NB1: must be ONE quoted string — CMake parses each "" as a separate + # list element, so splitting across lines breaks the alternation. + # NB2: char-class form is intentional. CMake regex is case-SENSITIVE, + # and PE import tables typically store system DLL names UPPERCASE + # (e.g. ACLUI.dll, KERNEL32.dll). A plain lowercase alternation + # silently fails to match — verified by cmake -P testing. + "^([Aa][Cc][Ll][Uu][Ii]|[Aa][Dd][Vv][Aa][Pp][Ii]32|[Bb][Cc][Rr][Yy][Pp][Tt]|[Bb][Cc][Rr][Yy][Pp][Tt][Pp][Rr][Ii][Mm][Ii][Tt][Ii][Vv][Ee][Ss]|[Cc][Oo][Mm][Bb][Aa][Ss][Ee]|[Cc][Oo][Mm][Cc][Tt][Ll]32|[Cc][Oo][Mm][Dd][Ll][Gg]32|[Cc][Rr][Yy][Pp][Tt]32|[Cc][Rr][Yy][Pp][Tt][Bb][Aa][Ss][Ee]|[Cc][Rr][Yy][Pp][Tt][Ss][Pp]|[Dd][Bb][Gg][Cc][Oo][Rr][Ee]|[Dd][Bb][Gg][Hh][Ee][Ll][Pp]|[Dd][Nn][Ss][Aa][Pp][Ii]|[Dd][Ww][Mm][Aa][Pp][Ii]|[Dd][Ww][Rr][Ii][Tt][Ee]|[Ff][Ww][Pp][Uu][Cc][Ll][Nn][Tt]|[Gg][Dd][Ii]32|[Gg][Dd][Ii]32[Ff][Uu][Ll][Ll]|[Ii][Mm][Aa][Gg][Ee][Hh][Ll][Pp]|[Ii][Mm][Mm]32|[Ii][Pp][Hh][Ll][Pp][Aa][Pp][Ii]|[Kk][Ee][Rr][Nn][Ee][Ll]32|[Kk][Ee][Rr][Nn][Ee][Ll][Bb][Aa][Ss][Ee]|[Mm][Pp][Rr]|[Mm][Ss][Aa][Ss][Nn]1|[Mm][Ss][Cc][Oo][Rr][Ee][Ee]|[Mm][Ss][Ii]|[Mm][Ss][Vv][Cc][Pp]_[Ww][Ii][Nn]|[Mm][Ss][Vv][Cc][Rr][Tt]|[Mm][Ss][Ww][Ss][Oo][Cc][Kk]|[Nn][Ee][Tt][Aa][Pp][Ii]32|[Nn][Ee][Tt][Uu][Tt][Ii][Ll][Ss]|[Nn][Oo][Rr][Mm][Aa][Ll][Ii][Zz]|[Nn][Tt][Dd][Ll][Ll]|[Nn][Tt][Mm][Aa][Rr][Tt][Aa]|[Oo][Ll][Ee]32|[Oo][Ll][Ee][Aa][Cc][Cc]|[Oo][Ll][Ee][Aa][Uu][Tt]32|[Pp][Oo][Ww][Rr][Pp][Rr][Oo][Ff]|[Pp][Rr][Oo][Ff][Aa][Pp][Ii]|[Pp][Rr][Oo][Pp][Ss][Yy][Ss]|[Pp][Ss][Aa][Pp][Ii]|[Rr][Pp][Cc][Rr][Tt]4|[Ss][Aa][Mm][Cc][Ll][Ii]|[Ss][Ee][Cc][Hh][Oo][Ss][Tt]|[Ss][Ee][Cc][Uu][Rr]32|[Ss][Ee][Tt][Uu][Pp][Aa][Pp][Ii]|[Ss][Ff][Cc]|[Ss][Ff][Cc]_[Oo][Ss]|[Ss][Hh][Cc][Oo][Rr][Ee]|[Ss][Hh][Ee][Ll][Ll]32|[Ss][Hh][Ll][Ww][Aa][Pp][Ii]|[Ss][Rr][Vv][Cc][Ll][Ii]|[Uu][Ss][Ee][Rr]32|[Uu][Ss][Ee][Rr][Ee][Nn][Vv]|[Uu][Ss][Pp]10|[Uu][Xx][Tt][Hh][Ee][Mm][Ee]|[Vv][Ee][Rr][Ss][Ii][Oo][Nn]|[Ww][Ee][Rr]|[Ww][Ii][Nn]32[Uu]|[Ww][Ii][Nn][Ii][Nn][Ee][Tt]|[Ww][Ii][Nn][Mm][Mm]|[Ww][Ii][Nn][Nn][Ss][Ii]|[Ww][Ii][Nn][Tt][Rr][Uu][Ss][Tt]|[Ww][Kk][Ss][Cc][Ll][Ii]|[Ww][Ll][Dd][Aa][Pp]32|[Ww][Ss]2_32|[Ww][Ss][Oo][Cc][Kk]32|[Ww][Tt][Ss][Aa][Pp][Ii]32|[Zz][Ll][Ii][Bb][Ww][Aa][Pp][Ii])\\.[Dd][Ll][Ll]" # macOS / Linux system search paths "^/usr/lib/.*" "^/lib/.*" "^/lib64/.*" "^/System/Library/.*" "^/usr/lib/system/.*" diff --git a/python/pyproject.toml b/python/pyproject.toml index 7cb964e0f..482947e3d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -44,7 +44,7 @@ build-backend = "scikit_build_core.build" # ============================================================================ [project] name = "openswmm" -version = "6.0.0a1" +version = "6.0.0.dev2" description = "Python bindings for the OpenSWMM stormwater modelling engine." requires-python = ">=3.10" readme = { file = "README.md", content-type = "text/markdown" } @@ -281,7 +281,7 @@ before-build = "pip install delvewheel" # and the vcpkg-provided runtime DLLs (SUNDIALS, HDF5, …). The helper # script extracts the wheel, collects every DLL subdirectory, adds # $VCPKG_ROOT/installed/x64-windows/bin, and passes them as --add-path. -repair-wheel-command = "python {package}/scripts/cibw_repair_windows.py {wheel} {dest_dir}" +repair-wheel-command = "python python/scripts/cibw_repair_windows.py {wheel} {dest_dir}" environment = { VCPKG_ROOT = "$VCPKG_ROOT" } # ============================================================================ diff --git a/python/scripts/cibw_repair_windows.py b/python/scripts/cibw_repair_windows.py index 7e199028c..06c51c61f 100644 --- a/python/scripts/cibw_repair_windows.py +++ b/python/scripts/cibw_repair_windows.py @@ -29,11 +29,15 @@ Wired in from ``python/pyproject.toml``: [tool.cibuildwheel.windows] - repair-wheel-command = "python {package}/scripts/cibw_repair_windows.py {wheel} {dest_dir}" - -Note: cibuildwheel substitutions are ``{package}`` (the --package-dir, -which is ``./python``), ``{wheel}``, and ``{dest_dir}``. There is NO -``{project}`` token — using it leaves the literal string in the command. + repair-wheel-command = "python python/scripts/cibw_repair_windows.py {wheel} {dest_dir}" + +Note: cibuildwheel's ``repair-wheel-command`` only substitutes ``{wheel}`` +and ``{dest_dir}`` (plus the default ``{python}``/``{pip}``). It does NOT +substitute ``{project}`` or ``{package}`` — those would be passed through +literally and break the command. Verified by reading cibuildwheel's source +(util.py::prepare_command and windows.py's call site). The script path is +therefore relative to cibuildwheel's cwd (workspace root, where the +repository was checked out), not the package dir. """ from __future__ import annotations From 9c3f9d418285a82592821e5a0271cd434e48dd35 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Sun, 24 May 2026 06:48:49 -0400 Subject: [PATCH 09/26] Fix PRE_EXCLUDE_REGEXES never being applied in BundleRuntimeDeps The bundle template substituted OPENSWMM_BUNDLE_PRE_EXCLUDES (a bracket- quoted list with semicolons) directly into file(GET_RUNTIME_DEPENDENCIES PRE_EXCLUDE_REGEXES ...). CMake passed the entire bracketed string as ONE argument, treating it as a single giant regex that matched nothing. Result: api-ms-*, libc.*, all Windows system DLL excludes have been silently inactive since the bundle script was written. This is why 'unresolved runtime dep: api-ms-...' kept appearing in CI even though api-ms-.* was in the exclude list, and why the Windows aclui.dll 'Multiple conflicting paths' error persisted across multiple regex fix attempts. Fix: in the template, capture the @-substituted value into a CMake variable first, then dereference with ${...} when passing to file(). CMake's list-aware expansion at the dereference site splits on ; into separate arguments, which file() then applies as individual regexes. Verified end-to-end by running the configure_file'd generated script against a binary with libc as a dep PLUS a duplicate libc in the staging dir (mimicking the exact aclui-in-staging conflict scenario). Bracket-quoted form: libc resolves through, no filter. Fixed form: libc filtered, no conflict. --- cmake/BundleRuntimeDeps.cmake.in | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/cmake/BundleRuntimeDeps.cmake.in b/cmake/BundleRuntimeDeps.cmake.in index 6e44e1a13..a931071c2 100644 --- a/cmake/BundleRuntimeDeps.cmake.in +++ b/cmake/BundleRuntimeDeps.cmake.in @@ -27,6 +27,18 @@ endif() message(STATUS "Bundling runtime deps for @OPENSWMM_BUNDLE_TARGET_NAME@ into @OPENSWMM_BUNDLE_SUBDIR@") +# Why the set + ${...} dance: +# The @-substituted value is wrapped as [==[a;b;c]==] (bracket-quoted to +# preserve literal backslashes in the regexes through configure_file). +# Passing [==[a;b;c]==] DIRECTLY to file(GET_RUNTIME_DEPENDENCIES ... +# PRE_EXCLUDE_REGEXES ...) passes it as ONE giant single regex that +# matches nothing — verified by direct testing of file(GET_RUNTIME_DEPENDENCIES). +# Capturing into a regular CMake variable first, then dereferencing with +# ${...}, triggers CMake's list-aware expansion that splits on `;` into +# separate arguments. Each regex is then applied individually as intended. +set(_pre_excludes @OPENSWMM_BUNDLE_PRE_EXCLUDES@) +set(_post_excludes @OPENSWMM_BUNDLE_POST_EXCLUDES@) + file(GET_RUNTIME_DEPENDENCIES RESOLVED_DEPENDENCIES_VAR _resolved UNRESOLVED_DEPENDENCIES_VAR _unresolved @@ -34,8 +46,8 @@ file(GET_RUNTIME_DEPENDENCIES DIRECTORIES "${CMAKE_INSTALL_PREFIX}/@CMAKE_INSTALL_BINDIR@" "${CMAKE_INSTALL_PREFIX}/@CMAKE_INSTALL_LIBDIR@" - PRE_EXCLUDE_REGEXES @OPENSWMM_BUNDLE_PRE_EXCLUDES@ - POST_EXCLUDE_REGEXES @OPENSWMM_BUNDLE_POST_EXCLUDES@ + PRE_EXCLUDE_REGEXES ${_pre_excludes} + POST_EXCLUDE_REGEXES ${_post_excludes} ) file(REAL_PATH "${CMAKE_INSTALL_PREFIX}" _prefix_real) From 450e8a6f540a0331a3e7cf923fe9871394f1d5b1 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Sun, 24 May 2026 07:24:15 -0400 Subject: [PATCH 10/26] Bundle vcpkg runtime DLLs (SUNDIALS, HDF5) into Windows cpack package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle template's file(GET_RUNTIME_DEPENDENCIES) only searched ${CMAKE_INSTALL_PREFIX}/{bin,lib}. Transitive deps of openswmm.engine.dll (sundials_cvode.dll, sundials_core.dll, hdf5.dll, plus their own runtime deps) live in vcpkg's installed//bin and were never findable, so they showed up as 'unresolved runtime dep' warnings and never made it into the cpack zip's bin/. Fix: capture VCPKG_INSTALLED_DIR//bin at configure time and substitute it into the bundle template's DIRECTORIES via a new @OPENSWMM_BUNDLE_EXTRA_DIRS@ placeholder, dereferenced through a CMake list variable so the path gets passed as its own argument to file(). Fallback to $ENV{VCPKG_ROOT}/installed//bin if manifest-mode vars aren't set. Empty list (no-op) for non-vcpkg builds — Linux/macOS typically resolve these via rpath anyway. --- CMakeLists.txt | 24 ++++++++++++++++++++++++ cmake/BundleRuntimeDeps.cmake.in | 5 +++++ 2 files changed, 29 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 687ad55d0..72b353a48 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -193,6 +193,30 @@ function(openswmm_bundle_runtime_deps TARGET_NAME SUBDIR) set(OPENSWMM_BUNDLE_PRE_EXCLUDES "[==[${OPENSWMM_RUNTIME_DEP_PRE_EXCLUDES}]==]") set(OPENSWMM_BUNDLE_POST_EXCLUDES "[==[${OPENSWMM_RUNTIME_DEP_POST_EXCLUDES}]==]") + # Extra DIRECTORIES that file(GET_RUNTIME_DEPENDENCIES) should search + # when resolving transitive dependencies of the executable. Critically + # this is where we point at vcpkg's installed//bin so SUNDIALS, + # HDF5, sqlite3, etc. (linked by openswmm_engine) get found and copied + # into the package. Without this, those DLLs show up in the bundle's + # unresolved-deps warnings on Windows and never get included in the + # cpack zip. Linux/macOS usually resolve them via rpath, but the extra + # dir is harmless on those platforms. + set(OPENSWMM_BUNDLE_EXTRA_DIRS "") + if(DEFINED VCPKG_INSTALLED_DIR AND DEFINED VCPKG_TARGET_TRIPLET) + list(APPEND OPENSWMM_BUNDLE_EXTRA_DIRS + "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin") + elseif(DEFINED ENV{VCPKG_ROOT}) + # Fallback: use the env-var-rooted classic-mode layout. + set(_triplet "${VCPKG_TARGET_TRIPLET}") + if(NOT _triplet AND WIN32) + set(_triplet "x64-windows") + endif() + if(_triplet) + list(APPEND OPENSWMM_BUNDLE_EXTRA_DIRS + "$ENV{VCPKG_ROOT}/installed/${_triplet}/bin") + endif() + endif() + # Use PROJECT_SOURCE_DIR (not CMAKE_SOURCE_DIR): the engine has its own # project() at line 28, so this resolves to the engine root even when the # engine is pulled in via add_subdirectory(..) from python/CMakeLists.txt. diff --git a/cmake/BundleRuntimeDeps.cmake.in b/cmake/BundleRuntimeDeps.cmake.in index a931071c2..3d174ca6b 100644 --- a/cmake/BundleRuntimeDeps.cmake.in +++ b/cmake/BundleRuntimeDeps.cmake.in @@ -38,6 +38,10 @@ message(STATUS "Bundling runtime deps for @OPENSWMM_BUNDLE_TARGET_NAME@ into @OP # separate arguments. Each regex is then applied individually as intended. set(_pre_excludes @OPENSWMM_BUNDLE_PRE_EXCLUDES@) set(_post_excludes @OPENSWMM_BUNDLE_POST_EXCLUDES@) +# Extra search paths captured at configure time — typically vcpkg's +# installed//bin where SUNDIALS, HDF5, etc. live. The list is +# `;`-separated; ${_extra_dirs} dereference splits it into separate args. +set(_extra_dirs "@OPENSWMM_BUNDLE_EXTRA_DIRS@") file(GET_RUNTIME_DEPENDENCIES RESOLVED_DEPENDENCIES_VAR _resolved @@ -46,6 +50,7 @@ file(GET_RUNTIME_DEPENDENCIES DIRECTORIES "${CMAKE_INSTALL_PREFIX}/@CMAKE_INSTALL_BINDIR@" "${CMAKE_INSTALL_PREFIX}/@CMAKE_INSTALL_LIBDIR@" + ${_extra_dirs} PRE_EXCLUDE_REGEXES ${_pre_excludes} POST_EXCLUDE_REGEXES ${_post_excludes} ) From 694264bbf92ac1ba6c0e9af9cfdf82165a64f0e4 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Mon, 25 May 2026 05:19:03 -0400 Subject: [PATCH 11/26] Expanding api for ui Signed-off-by: cbuahin --- CMakeLists.txt | 26 +- include/openswmm/engine/openswmm_inflows.h | 231 +++++++++++- include/openswmm/engine/openswmm_links.h | 12 + include/openswmm/engine/openswmm_nodes.h | 46 +++ .../openswmm/engine/openswmm_subcatchments.h | 12 + include/openswmm/engine/openswmm_tables.h | 33 ++ src/cli/CMakeLists.txt | 1 + src/cli/main.cpp | 7 +- src/engine/core/InpWriter.cpp | 41 ++ src/engine/core/SimulationContext.hpp | 20 +- src/engine/core/openswmm_inflows_impl.cpp | 353 ++++++++++++++++++ src/engine/core/openswmm_links_impl.cpp | 29 ++ src/engine/core/openswmm_nodes_impl.cpp | 54 +++ .../core/openswmm_subcatchments_impl.cpp | 30 ++ src/engine/core/openswmm_tables_impl.cpp | 29 ++ src/engine/data/InflowData.hpp | 37 +- src/engine/data/LinkData.hpp | 13 +- src/engine/data/NodeData.hpp | 18 +- src/engine/data/SubcatchData.hpp | 13 +- src/engine/edit/ObjectDeleter.cpp | 9 +- .../input/geopackage/GeoPackageReader.cpp | 21 +- .../input/geopackage/GeoPackageWriter.cpp | 22 +- src/engine/input/handlers/SpatialHandler.cpp | 24 +- src/engine/input/handlers/SpatialHandler.hpp | 4 +- src/legacy/cli/main.c | 16 +- tests/unit/engine/CMakeLists.txt | 3 + tests/unit/engine/test_geopackage.cpp | 39 +- .../legacy/engine/data/hotstart/_api_test.rpt | 4 +- .../legacy/engine/data/hotstart/_err_test.rpt | 4 +- .../data/hotstart/_expanded_api_test.rpt | 4 +- .../engine/data/hotstart/_shap_test.rpt | 4 +- .../engine/data/hotstart/hotstart_end.hsf | Bin 1479 -> 1479 bytes .../site_drainage_model_use_hotstart_v1.rpt | 6 +- 33 files changed, 1084 insertions(+), 81 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 72b353a48..525135811 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -149,14 +149,30 @@ set(OPENSWMM_RUNTIME_DEP_POST_EXCLUDES # Install runtime files for a list of imported targets into the given # destination. Skips targets that are not defined in the current # configuration, which lets callers list optional deps unconditionally. +# +# `IMPORTED_RUNTIME_ARTIFACTS` requires SHARED_LIBRARY / MODULE_LIBRARY / +# EXECUTABLE targets. We allowlist those explicitly and skip everything +# else — covers STATIC_LIBRARY (vcpkg static triplets), INTERFACE_LIBRARY +# (header-only deps), OBJECT_LIBRARY, and notably UNKNOWN_LIBRARY (the +# type reported by `FindSQLite3.cmake` and other classic Find modules +# that don't classify the artifact). Without this filter, configure +# aborts with "given target X which is not an executable, library, or +# module." function(openswmm_install_runtime_deps DESTINATION) foreach(_target IN LISTS ARGN) - if(TARGET ${_target}) - install(IMPORTED_RUNTIME_ARTIFACTS ${_target} - RUNTIME DESTINATION ${DESTINATION} - LIBRARY DESTINATION ${DESTINATION} - ) + if(NOT TARGET ${_target}) + continue() + endif() + get_target_property(_type ${_target} TYPE) + if(NOT (_type STREQUAL "SHARED_LIBRARY" OR + _type STREQUAL "MODULE_LIBRARY" OR + _type STREQUAL "EXECUTABLE")) + continue() endif() + install(IMPORTED_RUNTIME_ARTIFACTS ${_target} + RUNTIME DESTINATION ${DESTINATION} + LIBRARY DESTINATION ${DESTINATION} + ) endforeach() endfunction() diff --git a/include/openswmm/engine/openswmm_inflows.h b/include/openswmm/engine/openswmm_inflows.h index a3252f4c6..86dc7aac3 100644 --- a/include/openswmm/engine/openswmm_inflows.h +++ b/include/openswmm/engine/openswmm_inflows.h @@ -49,6 +49,50 @@ SWMM_ENGINE_API int swmm_ext_inflow_add(SWMM_Engine engine, int node_idx, const double m_factor, double s_factor, double baseline, const char* pattern); +/** + * @brief Read back an external inflow entry by index. + * + * @details The model stores external inflows as a flat SoA, indexed + * 0..swmm_ext_inflow_count()-1. To list a single node's inflows, + * iterate all entries and filter by @p node_idx. + * + * @param engine Engine handle. + * @param entry_idx Zero-based entry index. + * @param node_idx [out] Receiving node index. + * @param constituent_buf [out] Constituent name buffer (NUL-terminated). + * @param constituent_buflen Size of @p constituent_buf. + * @param ts_buf [out] Time series name buffer (NUL-terminated; empty if none). + * @param ts_buflen Size of @p ts_buf. + * @param type_buf [out] Inflow type buffer (NUL-terminated; "FLOW"/"CONCEN"/"MASS"). + * @param type_buflen Size of @p type_buf. + * @param m_factor [out] Multiplier factor. + * @param s_factor [out] Scale factor. + * @param baseline [out] Baseline value. + * @param pattern_buf [out] Pattern name buffer (NUL-terminated; empty if none). + * @param pattern_buflen Size of @p pattern_buf. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_ext_inflow_get(SWMM_Engine engine, int entry_idx, + int* node_idx, + char* constituent_buf, int constituent_buflen, + char* ts_buf, int ts_buflen, + char* type_buf, int type_buflen, + double* m_factor, double* s_factor, double* baseline, + char* pattern_buf, int pattern_buflen); + +/** + * @brief Remove an external inflow entry by index. + * + * @details Entries with index > @p entry_idx shift down by one. Callers + * holding cached indices must re-resolve via swmm_ext_inflow_count() + * / iteration. + * + * @param engine Engine handle. + * @param entry_idx Zero-based entry index (0..swmm_ext_inflow_count()-1). + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_ext_inflow_remove(SWMM_Engine engine, int entry_idx); + /* ========================================================================= * Dry weather flow * ========================================================================= */ @@ -73,6 +117,42 @@ SWMM_ENGINE_API int swmm_dwf_add(SWMM_Engine engine, int node_idx, const char* c double avg_value, const char* pat1, const char* pat2, const char* pat3, const char* pat4); +/** + * @brief Read back a dry weather flow entry by index. + * + * @param engine Engine handle. + * @param entry_idx Zero-based entry index (0..swmm_dwf_count()-1). + * @param node_idx [out] Receiving node index. + * @param constituent_buf [out] Constituent name (NUL-terminated). + * @param constituent_buflen Size of @p constituent_buf. + * @param avg_value [out] Average value. + * @param pat1_buf [out] Monthly pattern name (NUL-terminated; empty if none). + * @param pat1_buflen Size of @p pat1_buf. + * @param pat2_buf [out] Daily pattern name. + * @param pat2_buflen Size of @p pat2_buf. + * @param pat3_buf [out] Hourly pattern name. + * @param pat3_buflen Size of @p pat3_buf. + * @param pat4_buf [out] Weekend pattern name. + * @param pat4_buflen Size of @p pat4_buf. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_dwf_get(SWMM_Engine engine, int entry_idx, + int* node_idx, + char* constituent_buf, int constituent_buflen, + double* avg_value, + char* pat1_buf, int pat1_buflen, + char* pat2_buf, int pat2_buflen, + char* pat3_buf, int pat3_buflen, + char* pat4_buf, int pat4_buflen); + +/** + * @brief Remove a DWF entry by index. Subsequent entries shift down. + * @param engine Engine handle. + * @param entry_idx Zero-based entry index (0..swmm_dwf_count()-1). + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_dwf_remove(SWMM_Engine engine, int entry_idx); + /* ========================================================================= * RDII (Rainfall-Dependent Infiltration/Inflow) * ========================================================================= */ @@ -106,6 +186,14 @@ SWMM_ENGINE_API int swmm_rdii_get(SWMM_Engine engine, int entry_idx, int* node_idx, char* uh_buf, int buflen, double* area); +/** + * @brief Remove an RDII entry by index. Subsequent entries shift down. + * @param engine Engine handle. + * @param entry_idx Zero-based entry index (0..swmm_rdii_count()-1). + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_rdii_remove(SWMM_Engine engine, int entry_idx); + /* ========================================================================= * Unit hydrographs ([HYDROGRAPHS] section) * ========================================================================= @@ -132,7 +220,8 @@ SWMM_ENGINE_API int swmm_rdii_get(SWMM_Engine engine, int entry_idx, * @param response 0 = SHORT, 1 = MEDIUM, 2 = LONG. * @param r Fraction of rainfall volume that becomes RDII. * @param t Time to peak (hours). - * @param k Ratio of base time to peak time (>= 1). + * @param k Ratio of recession-limb time to time-to-peak (>= 0). + * Base time = t * (1 + k); the falling limb spans k * t hours. * @param dmax Maximum initial-abstraction depth (project depth units; 0 if unused). * @param drecov Linear-model IA recovery rate (project depth/day; 0 if unused or if [RDII_DECAY] is configured). * @param dinit Initial IA already used at start of simulation (project depth units; 0 if unused). @@ -235,6 +324,146 @@ SWMM_ENGINE_API int swmm_hydrograph_group_count(SWMM_Engine engine); SWMM_ENGINE_API int swmm_hydrograph_group_id(SWMM_Engine engine, int idx, char* buf, int buflen); +/* ========================================================================= + * Mutation surface (BS-02) — upsert + key-based remove + rename + * ========================================================================= + * + * The legacy `swmm_hydrograph_add` / `swmm_hydrograph_add_gage` surface above + * is append-only, which prevents in-place edits from a UI. The following + * setters/removers support the `HydrographGroupEditor` MVC layer in + * openswmm.gui: every mutation in the editor routes through one of these + * symbols, and the GUI's `SWMMModelLayer` emits a `hydrographChanged(uhName)` + * signal so all subscribed views (Object Browser, property panel, picker + * combos, etc.) refresh in lock-step. + * + * Upsert contract (set_rtk, set_ia, decay_set): + * - If an entry matching the supplied key exists, update only the fields + * this setter owns and leave the rest untouched. + * - Otherwise append a new entry with the supplied fields set and the + * unspecified fields zeroed (so set_rtk followed by set_ia on the same + * key composes correctly). + * + * Remove contract (remove_entry, remove_group, decay_remove): + * - Key-based, not index-based — indices shift as entries are removed and + * a UI cannot keep them in sync. Idempotent: returns SWMM_OK if no + * match is found. + * + * Group remove cascades to: [HYDROGRAPHS] parameter rows, gage assignments, + * [RDII_DECAY] rows, AND any [RDII] node assignments referencing the group + * (so the engine never sees a dangling UH-name reference after a delete). + * ========================================================================= */ + +/** + * @brief Upsert R/T/K parameters for one (group, month, response) row. + * + * @param engine Engine handle. + * @param uh_name Unit hydrograph group name (non-null, non-empty). + * @param month 0..11 = JAN..DEC, or -1 for ALL. + * @param response 0 = SHORT, 1 = MEDIUM, 2 = LONG. + * @param r Rainfall fraction. + * @param t Time to peak (hours). + * @param k Recession-limb-to-peak-time ratio (>= 0). Base time = t * (1 + k). + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_hydrograph_set_rtk(SWMM_Engine engine, const char* uh_name, + int month, int response, + double r, double t, double k); + +/** + * @brief Upsert linear-IA parameters for one (group, month, response) row. + * + * @param engine Engine handle. + * @param uh_name Unit hydrograph group name (non-null, non-empty). + * @param month 0..11 = JAN..DEC, or -1 for ALL. + * @param response 0 = SHORT, 1 = MEDIUM, 2 = LONG. + * @param dmax Maximum initial-abstraction depth. + * @param drecov Linear IA recovery rate. + * @param dinit Initial IA already used. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_hydrograph_set_ia(SWMM_Engine engine, const char* uh_name, + int month, int response, + double dmax, double drecov, double dinit); + +/** + * @brief Remove one parameter entry by (group, month, response). + * + * @details Idempotent — returns SWMM_OK whether or not a matching row exists. + * Indices shift, so any UI must look up entries by key rather than + * caching indices across calls. + * + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_hydrograph_remove_entry(SWMM_Engine engine, const char* uh_name, + int month, int response); + +/** + * @brief Remove an entire UH group: parameter rows + gage assignment + + * [RDII_DECAY] rows + [RDII] node assignments referencing the group. + * + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_hydrograph_remove_group(SWMM_Engine engine, const char* uh_name); + +/** + * @brief Bulk-clear every per-month parameter row for a group, leaving any + * existing month=-1 (ALL) row intact. + * + * @details Used by the editor when the user switches from per-season to ALL. + * Single pass — avoids the O(n^2) cost of repeated + * `swmm_hydrograph_remove_entry` calls. + * + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_hydrograph_clear_group_months(SWMM_Engine engine, const char* uh_name); + +/** + * @brief Set, replace, or clear the rain gage assigned to a UH group. + * + * @param engine Engine handle. + * @param uh_name Unit hydrograph group name (non-null, non-empty). + * @param gage_name Rain gage name, or NULL/empty to clear an existing + * assignment. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_hydrograph_set_gage(SWMM_Engine engine, const char* uh_name, + const char* gage_name); + +/** + * @brief Rename a UH group, propagating the new name to parameter rows, + * gage assignments, [RDII_DECAY] rows, and [RDII] node assignments. + * + * @param engine Engine handle. + * @param idx Zero-based group index (0..swmm_hydrograph_group_count()-1). + * @param new_id New group name (non-null, non-empty, must not already exist). + * @returns SWMM_OK on success, SWMM_ERR_BADPARAM if new_id is invalid or + * duplicates an existing group, or another error code. + */ +SWMM_ENGINE_API int swmm_hydrograph_group_rename(SWMM_Engine engine, int idx, const char* new_id); + +/** + * @brief Upsert exponential-decay parameters for one (group, response) row. + * + * @details Same upsert contract as `swmm_hydrograph_set_rtk`. Use + * `swmm_rdii_decay_remove` to delete a row (existence of a row + * IS the "active" flag in the engine model). + * + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_rdii_decay_set(SWMM_Engine engine, const char* uh_name, + int response, + double k_dep, double k_0, double k_T, + double T_ref, double theta_rec, double T_freeze); + +/** + * @brief Remove the exponential-decay row for one (group, response) pair. + * + * @details Idempotent — returns SWMM_OK whether or not a matching row exists. + * + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_rdii_decay_remove(SWMM_Engine engine, const char* uh_name, int response); + /* ========================================================================= * Exponential IA decay ([RDII_DECAY] section) * ========================================================================= diff --git a/include/openswmm/engine/openswmm_links.h b/include/openswmm/engine/openswmm_links.h index 330e5f715..bf9a29bda 100644 --- a/include/openswmm/engine/openswmm_links.h +++ b/include/openswmm/engine/openswmm_links.h @@ -761,6 +761,18 @@ SWMM_ENGINE_API int swmm_link_get_hyd_power(SWMM_Engine engine, int idx, double* * idx is out of range. */ SWMM_ENGINE_API int swmm_link_rename(SWMM_Engine engine, int idx, const char* newId); +/* ========================================================================= + * Tag — free-form string label from the INP `[TAGS]` section + * ========================================================================= */ + +/** @brief Read the link's tag into `buf` (NUL-terminated, truncated if too small). */ +SWMM_ENGINE_API int swmm_link_get_tag(SWMM_Engine engine, int idx, + char* buf, int buflen); + +/** @brief Set or clear the link's tag. Null/empty clears. Persists across rename. */ +SWMM_ENGINE_API int swmm_link_set_tag(SWMM_Engine engine, int idx, + const char* tag); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/include/openswmm/engine/openswmm_nodes.h b/include/openswmm/engine/openswmm_nodes.h index c68f5e826..338758d0d 100644 --- a/include/openswmm/engine/openswmm_nodes.h +++ b/include/openswmm/engine/openswmm_nodes.h @@ -487,6 +487,36 @@ SWMM_ENGINE_API int swmm_node_set_outfall_timeseries(SWMM_Engine engine, int idx */ SWMM_ENGINE_API int swmm_node_get_outfall_param(SWMM_Engine engine, int idx, double* param); +/** + * @brief Get the tidal curve index assigned to a TIDAL outfall. + * + * @details The outfall parameter slot is union-typed across stage / tidal-idx / + * ts-idx; this accessor returns the slot interpreted as a curve index + * only when the outfall is currently of TIDAL type. Returns + * @ref SWMM_ERR_BADPARAM if the outfall type is not TIDAL, so the + * caller can distinguish "unassigned" from a genuine index of 0. + * + * @param engine Engine handle. + * @param idx Zero-based node index. + * @param[out] curve_idx Receives the zero-based curve index. + * @returns SWMM_OK on success; SWMM_ERR_BADPARAM if outfall type != TIDAL. + */ +SWMM_ENGINE_API int swmm_node_get_outfall_tidal(SWMM_Engine engine, int idx, int* curve_idx); + +/** + * @brief Get the time-series index assigned to a TIMESERIES outfall. + * + * @details Symmetric to @ref swmm_node_get_outfall_tidal. Returns + * @ref SWMM_ERR_BADPARAM unless the outfall is currently of + * TIMESERIES type. + * + * @param engine Engine handle. + * @param idx Zero-based node index. + * @param[out] ts_idx Receives the zero-based time-series index. + * @returns SWMM_OK on success; SWMM_ERR_BADPARAM if outfall type != TIMESERIES. + */ +SWMM_ENGINE_API int swmm_node_get_outfall_timeseries(SWMM_Engine engine, int idx, int* ts_idx); + /** * @brief Set whether a flap gate exists at the outfall. * @@ -753,6 +783,22 @@ SWMM_ENGINE_API int swmm_node_get_depth_from_volume(SWMM_Engine engine, int idx, * idx is out of range. */ SWMM_ENGINE_API int swmm_node_rename(SWMM_Engine engine, int idx, const char* newId); +/* ========================================================================= + * Tag — free-form string label from the INP `[TAGS]` section + * ========================================================================= */ + +/** @brief Read the tag string into `buf` (NUL-terminated, truncated to + * `buflen-1` chars if necessary). Returns empty string when the node has + * no tag. */ +SWMM_ENGINE_API int swmm_node_get_tag(SWMM_Engine engine, int idx, + char* buf, int buflen); + +/** @brief Set or clear the node's tag. Pass null or empty string to clear. + * Tag persists across `swmm_node_rename` (it is keyed by index, not name). + * Writes are honoured in any engine lifecycle state. */ +SWMM_ENGINE_API int swmm_node_set_tag(SWMM_Engine engine, int idx, + const char* tag); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/include/openswmm/engine/openswmm_subcatchments.h b/include/openswmm/engine/openswmm_subcatchments.h index 0f3e1b616..44d78866b 100644 --- a/include/openswmm/engine/openswmm_subcatchments.h +++ b/include/openswmm/engine/openswmm_subcatchments.h @@ -614,6 +614,18 @@ SWMM_ENGINE_API const char* swmm_snowpack_id(SWMM_Engine engine, int idx); */ SWMM_ENGINE_API int swmm_snowpack_add(SWMM_Engine engine, const char* id); +/* ========================================================================= + * Tag — free-form string label from the INP `[TAGS]` section + * ========================================================================= */ + +/** @brief Read the subcatchment's tag into `buf` (NUL-terminated, truncated if too small). */ +SWMM_ENGINE_API int swmm_subcatch_get_tag(SWMM_Engine engine, int idx, + char* buf, int buflen); + +/** @brief Set or clear the subcatchment's tag. Null/empty clears. Persists across rename. */ +SWMM_ENGINE_API int swmm_subcatch_set_tag(SWMM_Engine engine, int idx, + const char* tag); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/include/openswmm/engine/openswmm_tables.h b/include/openswmm/engine/openswmm_tables.h index d4b2b97ad..44fde414b 100644 --- a/include/openswmm/engine/openswmm_tables.h +++ b/include/openswmm/engine/openswmm_tables.h @@ -210,6 +210,39 @@ SWMM_ENGINE_API int swmm_pattern_index(SWMM_Engine engine, const char* id); */ SWMM_ENGINE_API const char* swmm_pattern_id(SWMM_Engine engine, int idx); +/** + * @brief Get a pattern's type code. + * + * @details Pattern types: 0=MONTHLY, 1=DAILY, 2=HOURLY, 3=WEEKEND. Used by + * the GUI to filter pattern pickers by pattern kind (e.g. the + * four DWF picker rows each accept a specific type). + * + * @param engine Engine handle. + * @param idx Zero-based pattern index. + * @param[out] type Receives the pattern type code. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_pattern_get_type(SWMM_Engine engine, int idx, int* type); + +/** + * @brief Get the number of multiplier factors stored for a pattern. + * @param engine Engine handle. + * @param idx Zero-based pattern index. + * @param[out] count Receives the factor count (typically 12, 7, or 24). + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_pattern_get_factor_count(SWMM_Engine engine, int idx, int* count); + +/** + * @brief Get one multiplier factor from a pattern. + * @param engine Engine handle. + * @param idx Zero-based pattern index. + * @param i Zero-based factor index within the pattern. + * @param[out] v Receives the multiplier value. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_pattern_get_factor(SWMM_Engine engine, int idx, int i, double* v); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/src/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 3e17aa86f..70bc7d204 100644 --- a/src/cli/CMakeLists.txt +++ b/src/cli/CMakeLists.txt @@ -14,6 +14,7 @@ add_executable(openswmm target_link_libraries(openswmm PRIVATE openswmm_engine + openswmm::common ) target_include_directories(openswmm diff --git a/src/cli/main.cpp b/src/cli/main.cpp index 438eba763..05fc87d68 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -15,10 +15,11 @@ #include #include "openswmm_engine.h" +#include "version.h" static void print_help() { std::printf("\n"); - std::printf("OpenSWMM Engine 6.0 — Storm Water Management Model\n"); + std::printf("Open Source Storm Water Management Model %s\n", OPENSWMM_VERSION_FULL); std::printf("===================================================\n\n"); std::printf("USAGE:\n"); std::printf(" openswmm [output.out]\n\n"); @@ -33,7 +34,7 @@ static void print_help() { } static void print_version() { - std::printf("openswmm.engine 6.0.0-alpha.1\n"); + std::printf("openswmm.engine %s\n", OPENSWMM_VERSION_FULL); } int main(int argc, char* argv[]) { @@ -63,7 +64,7 @@ int main(int argc, char* argv[]) { const char* rpt_file = argv[2]; const char* out_file = (argc > 3) ? argv[3] : ""; - std::printf("\n... OpenSWMM Engine 6.0.0-alpha.1\n"); + std::printf("\n... OpenSWMM Engine %s\n", OPENSWMM_VERSION_FULL); std::printf("... Input: %s\n", inp_file); std::printf("... Report: %s\n", rpt_file); std::printf("... Output: %s\n", out_file); diff --git a/src/engine/core/InpWriter.cpp b/src/engine/core/InpWriter.cpp index fee08db1f..56e9db79b 100644 --- a/src/engine/core/InpWriter.cpp +++ b/src/engine/core/InpWriter.cpp @@ -1162,6 +1162,47 @@ int writeInpFile(const SimulationContext& ctx, const std::string& path) { }} } + // [TAGS] — per-object free-form labels. Tags are stored per-SoA + // index (NodeData::tags / LinkData::tags / SubcatchData::tags) so + // they survive swmm_*_rename. Only objects with a non-empty tag + // are emitted; section is skipped entirely if nothing's tagged. + { + auto has_any = [](const std::vector& v){ + for (const auto& s : v) if (!s.empty()) return true; + return false; + }; + const bool any = + has_any(ctx.nodes.tags) || + has_any(ctx.links.tags) || + has_any(ctx.subcatches.tags); + if (any) { + sec(f,"TAGS"); + std::fprintf(f,";;%-10s %-16s %s\n","Type","Name","Tag"); + std::fprintf(f,";;%-10s %-16s %s\n","----------","----------------","---"); + for (int j = 0; j < ctx.n_nodes(); ++j) { + const auto u = static_cast(j); + if (u < ctx.nodes.tags.size() && !ctx.nodes.tags[u].empty()) + std::fprintf(f,"%-10s %-16s %s\n", "Node", + ctx.node_names.name_of(j).c_str(), + ctx.nodes.tags[u].c_str()); + } + for (int j = 0; j < ctx.n_links(); ++j) { + const auto u = static_cast(j); + if (u < ctx.links.tags.size() && !ctx.links.tags[u].empty()) + std::fprintf(f,"%-10s %-16s %s\n", "Link", + ctx.link_names.name_of(j).c_str(), + ctx.links.tags[u].c_str()); + } + for (int j = 0; j < ctx.n_subcatches(); ++j) { + const auto u = static_cast(j); + if (u < ctx.subcatches.tags.size() && !ctx.subcatches.tags[u].empty()) + std::fprintf(f,"%-10s %-16s %s\n", "Subcatch", + ctx.subcatch_names.name_of(j).c_str(), + ctx.subcatches.tags[u].c_str()); + } + } + } + // [USER_FLAGS] if(ctx.user_flags.def_count()>0){sec(f,"USER_FLAGS"); std::fprintf(f,";;%-20s %-10s %s\n","Name","Type","Description"); diff --git a/src/engine/core/SimulationContext.hpp b/src/engine/core/SimulationContext.hpp index b1e36822e..0aaa11020 100644 --- a/src/engine/core/SimulationContext.hpp +++ b/src/engine/core/SimulationContext.hpp @@ -553,15 +553,10 @@ struct SimulationContext { // Object tags (from [TAGS] section) // ========================================================================= - /** - * @brief Tags assigned to objects for categorization/filtering. - * @details Parsed from the [TAGS] section. Format: ObjectType Name Tag - * Used by GUI tools for filtering; preserved through read/write. - * @see Legacy: s_TAG section in enums.h (GUI-only in legacy SWMM) - */ - std::unordered_map node_tags; - std::unordered_map link_tags; - std::unordered_map subcatch_tags; + // Tags from the [TAGS] section now live per-index on + // NodeData::tags / LinkData::tags / SubcatchData::tags. Storing + // them name-keyed here was a latent rename bug — a tagged node + // would lose its tag the moment `swmm_node_rename` was called. // ========================================================================= // Runtime forcing data @@ -1060,12 +1055,11 @@ struct SimulationContext { // Clear daily climate state (re-initialized by SWMMEngine on next run) climate_state = climate::ClimateState{}; - // Clear spatial, flags, tags, events, and forcing + // Clear spatial, flags, events, and forcing. Per-object tags + // are owned by NodeData/LinkData/SubcatchData and cleared when + // those SoAs are resized/cleared by the wider reset path. spatial = SpatialFrame{}; user_flags.clear(); - node_tags.clear(); - link_tags.clear(); - subcatch_tags.clear(); events.clear(); std::fill(std::begin(adjust_temp), std::end(adjust_temp), 0.0); std::fill(std::begin(adjust_evap), std::end(adjust_evap), 1.0); diff --git a/src/engine/core/openswmm_inflows_impl.cpp b/src/engine/core/openswmm_inflows_impl.cpp index 8c91b1cfd..093c8ead9 100644 --- a/src/engine/core/openswmm_inflows_impl.cpp +++ b/src/engine/core/openswmm_inflows_impl.cpp @@ -78,6 +78,45 @@ SWMM_ENGINE_API int swmm_ext_inflow_add(SWMM_Engine engine, int node_idx, const return SWMM_OK; } +SWMM_ENGINE_API int swmm_ext_inflow_get(SWMM_Engine engine, int entry_idx, + int* node_idx, + char* constituent_buf, int constituent_buflen, + char* ts_buf, int ts_buflen, + char* type_buf, int type_buflen, + double* m_factor, double* s_factor, double* baseline, + char* pattern_buf, int pattern_buflen) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(entry_idx >= 0 && entry_idx < ctx.ext_inflows.count()); + if (!node_idx || + !constituent_buf || constituent_buflen <= 0 || + !ts_buf || ts_buflen <= 0 || + !type_buf || type_buflen <= 0 || + !m_factor || !s_factor || !baseline || + !pattern_buf || pattern_buflen <= 0) + return SWMM_ERR_BADPARAM; + + const auto u = static_cast(entry_idx); + const auto& ei = ctx.ext_inflows; + *node_idx = ei.node_idx[u]; + *m_factor = ei.m_factor[u]; + *s_factor = ei.s_factor[u]; + *baseline = ei.baseline[u]; + copy_to_buf(ei.constituent[u], constituent_buf, constituent_buflen); + copy_to_buf(ei.ts_name[u], ts_buf, ts_buflen); + copy_to_buf(ei.inflow_type[u], type_buf, type_buflen); + copy_to_buf(ei.pattern_name[u], pattern_buf, pattern_buflen); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_ext_inflow_remove(SWMM_Engine engine, int entry_idx) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(entry_idx >= 0 && entry_idx < ctx.ext_inflows.count()); + ctx.ext_inflows.erase(entry_idx); + return SWMM_OK; +} + // ============================================================================ // Dry weather flow // ============================================================================ @@ -103,6 +142,46 @@ SWMM_ENGINE_API int swmm_dwf_add(SWMM_Engine engine, int node_idx, const char* c return SWMM_OK; } +SWMM_ENGINE_API int swmm_dwf_get(SWMM_Engine engine, int entry_idx, + int* node_idx, + char* constituent_buf, int constituent_buflen, + double* avg_value, + char* pat1_buf, int pat1_buflen, + char* pat2_buf, int pat2_buflen, + char* pat3_buf, int pat3_buflen, + char* pat4_buf, int pat4_buflen) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(entry_idx >= 0 && entry_idx < ctx.dwf_inflows.count()); + if (!node_idx || + !constituent_buf || constituent_buflen <= 0 || + !avg_value || + !pat1_buf || pat1_buflen <= 0 || + !pat2_buf || pat2_buflen <= 0 || + !pat3_buf || pat3_buflen <= 0 || + !pat4_buf || pat4_buflen <= 0) + return SWMM_ERR_BADPARAM; + + const auto u = static_cast(entry_idx); + const auto& dw = ctx.dwf_inflows; + *node_idx = dw.node_idx[u]; + *avg_value = dw.avg_value[u]; + copy_to_buf(dw.constituent[u], constituent_buf, constituent_buflen); + copy_to_buf(dw.pat1[u], pat1_buf, pat1_buflen); + copy_to_buf(dw.pat2[u], pat2_buf, pat2_buflen); + copy_to_buf(dw.pat3[u], pat3_buf, pat3_buflen); + copy_to_buf(dw.pat4[u], pat4_buf, pat4_buflen); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_dwf_remove(SWMM_Engine engine, int entry_idx) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(entry_idx >= 0 && entry_idx < ctx.dwf_inflows.count()); + ctx.dwf_inflows.erase(entry_idx); + return SWMM_OK; +} + // ============================================================================ // RDII // ============================================================================ @@ -133,6 +212,14 @@ SWMM_ENGINE_API int swmm_rdii_get(SWMM_Engine engine, int entry_idx, return SWMM_OK; } +SWMM_ENGINE_API int swmm_rdii_remove(SWMM_Engine engine, int entry_idx) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(entry_idx >= 0 && entry_idx < ctx.rdii_assigns.count()); + ctx.rdii_assigns.erase(entry_idx); + return SWMM_OK; +} + // ============================================================================ // Unit hydrographs ([HYDROGRAPHS]) // ============================================================================ @@ -297,6 +384,272 @@ SWMM_ENGINE_API int swmm_rdii_decay_count(SWMM_Engine engine) { return to_engine(engine)->context().rdii_decay.count(); } +// ============================================================================ +// Mutation surface (BS-02) — upsert + key-based remove + rename +// ============================================================================ +// +// Helpers live here rather than in the anonymous namespace at the top of the +// file because they need to mutate `openswmm::UnitHydData` / `RDIIDecayData` +// / `RDIIAssignData` and reading those types adds noise to the file header. + +namespace { + +inline int find_uh_entry(const openswmm::UnitHydData& uh, + const std::string& name, int month, int response) { + const auto n = uh.entries.size(); + for (std::size_t i = 0; i < n; ++i) { + const auto& e = uh.entries[i]; + if (e.name == name && e.month == month && e.response == response) + return static_cast(i); + } + return -1; +} + +inline int find_uh_gage_assignment(const openswmm::UnitHydData& uh, + const std::string& name) { + const auto n = uh.gage_assignments.size(); + for (std::size_t i = 0; i < n; ++i) { + if (uh.gage_assignments[i] == name) return static_cast(i); + } + return -1; +} + +inline void erase_uh_gage_assignment(openswmm::UnitHydData& uh, std::size_t i) { + uh.gage_assignments.erase(uh.gage_assignments.begin() + static_cast(i)); + uh.gage_names.erase(uh.gage_names.begin() + static_cast(i)); +} + +inline int find_decay_entry(const openswmm::RDIIDecayData& dd, + const std::string& name, int response) { + const auto n = dd.entries.size(); + for (std::size_t i = 0; i < n; ++i) { + const auto& e = dd.entries[i]; + if (e.uh_name == name && e.response == response) return static_cast(i); + } + return -1; +} + +} // namespace + +SWMM_ENGINE_API int swmm_hydrograph_set_rtk(SWMM_Engine engine, const char* uh_name, + int month, int response, + double r, double t, double k) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + if (!uh_name || !*uh_name) return SWMM_ERR_BADPARAM; + if (response < 0 || response > 2) return SWMM_ERR_BADPARAM; + if (month < -1 || month > 11) return SWMM_ERR_BADPARAM; + + const std::string name(uh_name); + const int existing = find_uh_entry(ctx.unit_hyds, name, month, response); + if (existing >= 0) { + auto& e = ctx.unit_hyds.entries[static_cast(existing)]; + e.r = r; e.t = t; e.k = k; + return SWMM_OK; + } + + openswmm::UnitHydEntry e{}; + e.name = name; + e.month = month; + e.response = response; + e.r = r; e.t = t; e.k = k; + e.dmax = 0.0; e.drecov = 0.0; e.dinit = 0.0; + ctx.unit_hyds.add(e); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_hydrograph_set_ia(SWMM_Engine engine, const char* uh_name, + int month, int response, + double dmax, double drecov, double dinit) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + if (!uh_name || !*uh_name) return SWMM_ERR_BADPARAM; + if (response < 0 || response > 2) return SWMM_ERR_BADPARAM; + if (month < -1 || month > 11) return SWMM_ERR_BADPARAM; + + const std::string name(uh_name); + const int existing = find_uh_entry(ctx.unit_hyds, name, month, response); + if (existing >= 0) { + auto& e = ctx.unit_hyds.entries[static_cast(existing)]; + e.dmax = dmax; e.drecov = drecov; e.dinit = dinit; + return SWMM_OK; + } + + openswmm::UnitHydEntry e{}; + e.name = name; + e.month = month; + e.response = response; + e.r = 0.0; e.t = 0.0; e.k = 0.0; + e.dmax = dmax; e.drecov = drecov; e.dinit = dinit; + ctx.unit_hyds.add(e); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_hydrograph_remove_entry(SWMM_Engine engine, const char* uh_name, + int month, int response) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + if (!uh_name || !*uh_name) return SWMM_ERR_BADPARAM; + if (response < 0 || response > 2) return SWMM_ERR_BADPARAM; + if (month < -1 || month > 11) return SWMM_ERR_BADPARAM; + + const int i = find_uh_entry(ctx.unit_hyds, std::string(uh_name), month, response); + if (i < 0) return SWMM_OK; + auto& v = ctx.unit_hyds.entries; + v.erase(v.begin() + i); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_hydrograph_remove_group(SWMM_Engine engine, const char* uh_name) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + if (!uh_name || !*uh_name) return SWMM_ERR_BADPARAM; + const std::string name(uh_name); + + auto& uh = ctx.unit_hyds; + uh.entries.erase( + std::remove_if(uh.entries.begin(), uh.entries.end(), + [&](const openswmm::UnitHydEntry& e) { return e.name == name; }), + uh.entries.end()); + + for (std::size_t i = uh.gage_assignments.size(); i-- > 0;) { + if (uh.gage_assignments[i] == name) erase_uh_gage_assignment(uh, i); + } + + auto& dd = ctx.rdii_decay; + dd.entries.erase( + std::remove_if(dd.entries.begin(), dd.entries.end(), + [&](const openswmm::RDIIDecayEntry& e) { return e.uh_name == name; }), + dd.entries.end()); + + auto& ra = ctx.rdii_assigns; + for (int i = ra.count() - 1; i >= 0; --i) { + if (ra.uh_name[static_cast(i)] == name) ra.erase(i); + } + + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_hydrograph_clear_group_months(SWMM_Engine engine, const char* uh_name) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + if (!uh_name || !*uh_name) return SWMM_ERR_BADPARAM; + const std::string name(uh_name); + + auto& v = ctx.unit_hyds.entries; + v.erase( + std::remove_if(v.begin(), v.end(), + [&](const openswmm::UnitHydEntry& e) { + return e.name == name && e.month != -1; + }), + v.end()); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_hydrograph_set_gage(SWMM_Engine engine, const char* uh_name, + const char* gage_name) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + if (!uh_name || !*uh_name) return SWMM_ERR_BADPARAM; + const std::string name(uh_name); + const std::string gage = (gage_name && *gage_name) ? gage_name : ""; + + auto& uh = ctx.unit_hyds; + const int existing = find_uh_gage_assignment(uh, name); + + if (gage.empty()) { + if (existing >= 0) erase_uh_gage_assignment(uh, static_cast(existing)); + return SWMM_OK; + } + + if (existing >= 0) { + uh.gage_names[static_cast(existing)] = gage; + } else { + uh.add_gage(name, gage); + } + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_hydrograph_group_rename(SWMM_Engine engine, int idx, + const char* new_id) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + if (!new_id || !*new_id) return SWMM_ERR_BADPARAM; + + auto& uh = ctx.unit_hyds; + const auto names = unique_uh_group_names(uh); + CHECK_INDEX(idx >= 0 && idx < static_cast(names.size())); + + const std::string old_name = names[static_cast(idx)]; + const std::string new_name(new_id); + if (old_name == new_name) return SWMM_OK; + + for (const auto& s : names) { + if (s == new_name) return SWMM_ERR_BADPARAM; + } + + for (auto& e : uh.entries) { + if (e.name == old_name) e.name = new_name; + } + for (auto& g : uh.gage_assignments) { + if (g == old_name) g = new_name; + } + for (auto& e : ctx.rdii_decay.entries) { + if (e.uh_name == old_name) e.uh_name = new_name; + } + auto& ra = ctx.rdii_assigns; + for (std::size_t i = 0; i < ra.uh_name.size(); ++i) { + if (ra.uh_name[i] == old_name) ra.uh_name[i] = new_name; + } + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_rdii_decay_set(SWMM_Engine engine, const char* uh_name, + int response, + double k_dep, double k_0, double k_T, + double T_ref, double theta_rec, double T_freeze) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + if (!uh_name || !*uh_name) return SWMM_ERR_BADPARAM; + if (response < 0 || response > 2) return SWMM_ERR_BADPARAM; + if (k_dep < 0.0 || k_0 < 0.0 || k_T < 0.0) return SWMM_ERR_BADPARAM; + + const std::string name(uh_name); + const int existing = find_decay_entry(ctx.rdii_decay, name, response); + if (existing >= 0) { + auto& e = ctx.rdii_decay.entries[static_cast(existing)]; + e.k_dep = k_dep; e.k_0 = k_0; e.k_T = k_T; + e.T_ref = T_ref; e.theta_rec = theta_rec; e.T_freeze = T_freeze; + return SWMM_OK; + } + + openswmm::RDIIDecayEntry e{}; + e.uh_name = name; + e.response = response; + e.k_dep = k_dep; + e.k_0 = k_0; + e.k_T = k_T; + e.T_ref = T_ref; + e.theta_rec = theta_rec; + e.T_freeze = T_freeze; + ctx.rdii_decay.add(e); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_rdii_decay_remove(SWMM_Engine engine, const char* uh_name, + int response) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + if (!uh_name || !*uh_name) return SWMM_ERR_BADPARAM; + if (response < 0 || response > 2) return SWMM_ERR_BADPARAM; + + const int i = find_decay_entry(ctx.rdii_decay, std::string(uh_name), response); + if (i < 0) return SWMM_OK; + auto& v = ctx.rdii_decay.entries; + v.erase(v.begin() + i); + return SWMM_OK; +} + // ============================================================================ // Count queries // ============================================================================ diff --git a/src/engine/core/openswmm_links_impl.cpp b/src/engine/core/openswmm_links_impl.cpp index 1993aca4e..3370c6493 100644 --- a/src/engine/core/openswmm_links_impl.cpp +++ b/src/engine/core/openswmm_links_impl.cpp @@ -13,7 +13,10 @@ #include "openswmm_api_common.hpp" #include "../../../include/openswmm/engine/openswmm_links.h" +#include #include +#include +#include #ifndef M_PI #define M_PI 3.14159265358979323846 @@ -785,4 +788,30 @@ SWMM_ENGINE_API int swmm_link_rename(SWMM_Engine engine, int idx, const char* ne return ctx.link_names.rename(idx, newId) ? SWMM_OK : SWMM_ERR_BADPARAM; } +SWMM_ENGINE_API int swmm_link_get_tag(SWMM_Engine engine, int idx, + char* buf, int buflen) { + CHECK_HANDLE(engine); + if (!buf || buflen <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto u = static_cast(idx); + const std::string& s = (u < ctx.links.tags.size()) ? ctx.links.tags[u] + : std::string{}; + const int copy_len = std::min(static_cast(s.size()), buflen - 1); + if (copy_len > 0) std::memcpy(buf, s.c_str(), static_cast(copy_len)); + buf[copy_len] = '\0'; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_set_tag(SWMM_Engine engine, int idx, + const char* tag) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto u = static_cast(idx); + if (u >= ctx.links.tags.size()) ctx.links.tags.resize(u + 1); + ctx.links.tags[u] = (tag != nullptr) ? std::string(tag) : std::string{}; + return SWMM_OK; +} + } /* extern "C" */ diff --git a/src/engine/core/openswmm_nodes_impl.cpp b/src/engine/core/openswmm_nodes_impl.cpp index 49c231df2..a3f8e3847 100644 --- a/src/engine/core/openswmm_nodes_impl.cpp +++ b/src/engine/core/openswmm_nodes_impl.cpp @@ -15,6 +15,10 @@ #include "../../../include/openswmm/engine/openswmm_nodes.h" #include "../hydraulics/Node.hpp" +#include +#include +#include + using openswmm::c_to_internal_node_type; using openswmm::internal_to_c_node_type; @@ -539,6 +543,30 @@ SWMM_ENGINE_API int swmm_node_get_outfall_param(SWMM_Engine engine, int idx, dou return SWMM_OK; } +SWMM_ENGINE_API int swmm_node_get_outfall_tidal(SWMM_Engine engine, int idx, int* curve_idx) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_nodes()); + if (!curve_idx) return SWMM_ERR_BADPARAM; + const auto uidx = static_cast(idx); + if (ctx.nodes.outfall_type[uidx] != openswmm::OutfallType::TIDAL) + return SWMM_ERR_BADPARAM; + *curve_idx = static_cast(ctx.nodes.outfall_param[uidx]); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_node_get_outfall_timeseries(SWMM_Engine engine, int idx, int* ts_idx) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_nodes()); + if (!ts_idx) return SWMM_ERR_BADPARAM; + const auto uidx = static_cast(idx); + if (ctx.nodes.outfall_type[uidx] != openswmm::OutfallType::TIMESERIES) + return SWMM_ERR_BADPARAM; + *ts_idx = static_cast(ctx.nodes.outfall_param[uidx]); + return SWMM_OK; +} + SWMM_ENGINE_API int swmm_node_set_outfall_flap_gate(SWMM_Engine engine, int idx, int has_gate) { CHECK_HANDLE(engine); auto& ctx = to_engine(engine)->context(); @@ -726,4 +754,30 @@ SWMM_ENGINE_API int swmm_node_rename(SWMM_Engine engine, int idx, const char* ne return ctx.node_names.rename(idx, newId) ? SWMM_OK : SWMM_ERR_BADPARAM; } +SWMM_ENGINE_API int swmm_node_get_tag(SWMM_Engine engine, int idx, + char* buf, int buflen) { + CHECK_HANDLE(engine); + if (!buf || buflen <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_nodes()); + const auto u = static_cast(idx); + const std::string& s = (u < ctx.nodes.tags.size()) ? ctx.nodes.tags[u] + : std::string{}; + const int copy_len = std::min(static_cast(s.size()), buflen - 1); + if (copy_len > 0) std::memcpy(buf, s.c_str(), static_cast(copy_len)); + buf[copy_len] = '\0'; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_node_set_tag(SWMM_Engine engine, int idx, + const char* tag) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_nodes()); + const auto u = static_cast(idx); + if (u >= ctx.nodes.tags.size()) ctx.nodes.tags.resize(u + 1); + ctx.nodes.tags[u] = (tag != nullptr) ? std::string(tag) : std::string{}; + return SWMM_OK; +} + } /* extern "C" */ diff --git a/src/engine/core/openswmm_subcatchments_impl.cpp b/src/engine/core/openswmm_subcatchments_impl.cpp index f1bacd448..2537558eb 100644 --- a/src/engine/core/openswmm_subcatchments_impl.cpp +++ b/src/engine/core/openswmm_subcatchments_impl.cpp @@ -13,6 +13,10 @@ #include "openswmm_api_common.hpp" #include "../../../include/openswmm/engine/openswmm_subcatchments.h" +#include +#include +#include + extern "C" { // ============================================================================ @@ -576,6 +580,32 @@ SWMM_ENGINE_API int swmm_subcatch_rename(SWMM_Engine engine, int idx, const char return ctx.subcatch_names.rename(idx, newId) ? SWMM_OK : SWMM_ERR_BADPARAM; } +SWMM_ENGINE_API int swmm_subcatch_get_tag(SWMM_Engine engine, int idx, + char* buf, int buflen) { + CHECK_HANDLE(engine); + if (!buf || buflen <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_subcatches()); + const auto u = static_cast(idx); + const std::string& s = (u < ctx.subcatches.tags.size()) ? ctx.subcatches.tags[u] + : std::string{}; + const int copy_len = std::min(static_cast(s.size()), buflen - 1); + if (copy_len > 0) std::memcpy(buf, s.c_str(), static_cast(copy_len)); + buf[copy_len] = '\0'; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_subcatch_set_tag(SWMM_Engine engine, int idx, + const char* tag) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_subcatches()); + const auto u = static_cast(idx); + if (u >= ctx.subcatches.tags.size()) ctx.subcatches.tags.resize(u + 1); + ctx.subcatches.tags[u] = (tag != nullptr) ? std::string(tag) : std::string{}; + return SWMM_OK; +} + // ============================================================================ // Aquifers ([AQUIFERS] section) — Slice BM.0 list + add; setters land with BP // ============================================================================ diff --git a/src/engine/core/openswmm_tables_impl.cpp b/src/engine/core/openswmm_tables_impl.cpp index 57e4698eb..610af040d 100644 --- a/src/engine/core/openswmm_tables_impl.cpp +++ b/src/engine/core/openswmm_tables_impl.cpp @@ -191,4 +191,33 @@ SWMM_ENGINE_API const char* swmm_pattern_id(SWMM_Engine engine, int idx) { return names[static_cast(idx)].c_str(); } +SWMM_ENGINE_API int swmm_pattern_get_type(SWMM_Engine engine, int idx, int* type) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.patterns.count()); + if (!type) return SWMM_ERR_BADPARAM; + *type = ctx.patterns.types[static_cast(idx)]; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_pattern_get_factor_count(SWMM_Engine engine, int idx, int* count) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.patterns.count()); + if (!count) return SWMM_ERR_BADPARAM; + *count = static_cast(ctx.patterns.factors[static_cast(idx)].size()); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_pattern_get_factor(SWMM_Engine engine, int idx, int i, double* v) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.patterns.count()); + const auto& f = ctx.patterns.factors[static_cast(idx)]; + CHECK_INDEX(i >= 0 && i < static_cast(f.size())); + if (!v) return SWMM_ERR_BADPARAM; + *v = f[static_cast(i)]; + return SWMM_OK; +} + } /* extern "C" */ diff --git a/src/engine/data/InflowData.hpp b/src/engine/data/InflowData.hpp index 0f82dc167..b5f857e00 100644 --- a/src/engine/data/InflowData.hpp +++ b/src/engine/data/InflowData.hpp @@ -44,6 +44,21 @@ struct ExtInflowData { m_factor.push_back(mf); s_factor.push_back(sf); baseline.push_back(base); pattern_name.push_back(pat); } + + /// Remove the entry at @p idx. No-op if out of range. Subsequent entries + /// shift down by one — callers that hold cached indices must re-resolve. + void erase(int idx) { + if (idx < 0 || idx >= count()) return; + const auto u = static_cast(idx); + node_idx.erase(node_idx.begin() + u); + constituent.erase(constituent.begin() + u); + ts_name.erase(ts_name.begin() + u); + inflow_type.erase(inflow_type.begin() + u); + m_factor.erase(m_factor.begin() + u); + s_factor.erase(s_factor.begin() + u); + baseline.erase(baseline.begin() + u); + pattern_name.erase(pattern_name.begin() + u); + } }; // ============================================================================ @@ -69,6 +84,18 @@ struct DwfData { pat1.push_back(p1); pat2.push_back(p2); pat3.push_back(p3); pat4.push_back(p4); } + + void erase(int idx) { + if (idx < 0 || idx >= count()) return; + const auto u = static_cast(idx); + node_idx.erase(node_idx.begin() + u); + constituent.erase(constituent.begin() + u); + avg_value.erase(avg_value.begin() + u); + pat1.erase(pat1.begin() + u); + pat2.erase(pat2.begin() + u); + pat3.erase(pat3.begin() + u); + pat4.erase(pat4.begin() + u); + } }; // ============================================================================ @@ -86,6 +113,14 @@ struct RDIIAssignData { node_idx.push_back(ni); uh_name.push_back(uh); sewer_area.push_back(area); } + + void erase(int idx) { + if (idx < 0 || idx >= count()) return; + const auto u = static_cast(idx); + node_idx.erase(node_idx.begin() + u); + uh_name.erase(uh_name.begin() + u); + sewer_area.erase(sewer_area.begin() + u); + } }; // ============================================================================ @@ -99,7 +134,7 @@ struct UnitHydEntry { int response; ///< 0=SHORT, 1=MEDIUM, 2=LONG double r; ///< Fraction of rainfall volume double t; ///< Time to peak (hours) - double k; ///< Recession limb ratio (tBase/tPeak) + double k; ///< Recession-limb-to-peak-time ratio (tBase = t*(1+k); k >= 0) double dmax; ///< Max initial abstraction depth double drecov; ///< IA recovery rate double dinit; ///< Initial IA used diff --git a/src/engine/data/LinkData.hpp b/src/engine/data/LinkData.hpp index d06da9155..6ab04e045 100644 --- a/src/engine/data/LinkData.hpp +++ b/src/engine/data/LinkData.hpp @@ -472,6 +472,14 @@ struct LinkData { */ std::vector comments; + /** + * @brief Per-object tag from the INP `[TAGS]` section. + * + * @details Free-form string label. Index-keyed so `swmm_link_rename` + * keeps the tag attached. + */ + std::vector tags; + // ----------------------------------------------------------------------- // Report flag — per-object output filter // ----------------------------------------------------------------------- @@ -682,6 +690,7 @@ struct LinkData { old_volume.assign(un, 0.0); comments.assign(un, std::string{}); + tags.assign(un, std::string{}); rpt_flag.assign(un, 0); @@ -744,6 +753,7 @@ struct LinkData { g(full_state, int8_t{0}); g(old_flow, 0.0); g(old_depth, 0.0); g(old_volume, 0.0); comments.resize(un, std::string{}); + tags.resize(un, std::string{}); g(rpt_flag, static_cast(0)); g(stat_vol_flow, 0.0); g(stat_max_flow, 0.0); @@ -795,7 +805,7 @@ struct LinkData { e(flow); e(depth); e(volume); e(froude); e(flow_class); e(is_closed); e(full_state); e(old_flow); e(old_depth); e(old_volume); - e(comments); e(rpt_flag); + e(comments); e(tags); e(rpt_flag); e(stat_vol_flow); e(stat_max_flow); e(stat_max_veloc); e(stat_max_filling); e(stat_time_surcharged); e(stat_norm_ltd); e(stat_inlet_ctrl); @@ -939,6 +949,7 @@ struct LinkData { conc_old.shrink_to_fit(); comments.shrink_to_fit(); + tags.shrink_to_fit(); rpt_flag.shrink_to_fit(); diff --git a/src/engine/data/NodeData.hpp b/src/engine/data/NodeData.hpp index 4bbfb8076..c905677c6 100644 --- a/src/engine/data/NodeData.hpp +++ b/src/engine/data/NodeData.hpp @@ -467,6 +467,19 @@ struct NodeData { */ std::vector comments; + /** + * @brief Per-object tag from the INP `[TAGS]` section. + * + * @details Free-form string label, used by GUIs for filtering and grouping + * (e.g. catchment-name labels, asset IDs, user-defined groups). + * Empty string means no tag. Written back to INP by InpWriter as + * a `Node ` row in `[TAGS]`. Index-keyed (per-`NodeData` + * field) so `swmm_node_rename` keeps the tag attached — the + * earlier name-keyed `SimulationContext::node_tags` map lost + * tags on rename. + */ + std::vector tags; + // ----------------------------------------------------------------------- // Report flag — per-object output filter // ----------------------------------------------------------------------- @@ -675,6 +688,7 @@ struct NodeData { old_lat_flow.assign(un, 0.0); comments.assign(un, std::string{}); + tags.assign(un, std::string{}); rpt_flag.assign(un, 0); @@ -740,6 +754,7 @@ struct NodeData { g(old_net_inflow, 0.0); g(full_volume, 0.0); g(old_depth, 0.0); g(old_volume, 0.0); g(old_lat_flow, 0.0); comments.resize(un, std::string{}); + tags.resize(un, std::string{}); g(rpt_flag, static_cast(0)); g(stat_vol_flooded, 0.0); g(stat_time_flooded, 0.0); @@ -793,7 +808,7 @@ struct NodeData { e(inflow); e(outflow); e(overflow); e(losses); e(crown_elev); e(degree); e(old_net_inflow); e(full_volume); e(old_depth); e(old_volume); e(old_lat_flow); - e(comments); e(rpt_flag); + e(comments); e(tags); e(rpt_flag); e(stat_vol_flooded); e(stat_time_flooded); e(stat_max_depth); e(stat_max_overflow); e(stat_max_overflow_date); e(stat_sum_depth); e(stat_max_depth_date); @@ -932,6 +947,7 @@ struct NodeData { old_lat_flow.shrink_to_fit(); comments.shrink_to_fit(); + tags.shrink_to_fit(); rpt_flag.shrink_to_fit(); diff --git a/src/engine/data/SubcatchData.hpp b/src/engine/data/SubcatchData.hpp index 2eccc7d66..f1c0f43e8 100644 --- a/src/engine/data/SubcatchData.hpp +++ b/src/engine/data/SubcatchData.hpp @@ -299,6 +299,14 @@ struct SubcatchData { */ std::vector comments; + /** + * @brief Per-object tag from the INP `[TAGS]` section. + * + * @details Free-form string label. Index-keyed so `swmm_subcatch_rename` + * keeps the tag attached. + */ + std::vector tags; + // ----------------------------------------------------------------------- // Report flag — per-object output filter // ----------------------------------------------------------------------- @@ -533,6 +541,7 @@ struct SubcatchData { gw_max_infil_vol.assign(un, std::numeric_limits::max()); comments.assign(un, std::string{}); + tags.assign(un, std::string{}); rpt_flag.assign(un, 0); @@ -599,6 +608,7 @@ struct SubcatchData { g(gw_max_infil_vol, std::numeric_limits::max()); g(outfall_runon_vol, 0.0); comments.resize(un, std::string{}); + tags.resize(un, std::string{}); g(rpt_flag, static_cast(0)); g(stat_precip_vol, 0.0); g(stat_evap_vol, 0.0); @@ -647,7 +657,7 @@ struct SubcatchData { e(gw_flow); e(old_runoff); e(old_gw_flow); e(runon_inflow); e(old_runon_inflow); e(outfall_runon_vol); e(gw_sw_head); e(gw_node_avail_flow); e(gw_max_infil_vol); - e(comments); e(rpt_flag); + e(comments); e(tags); e(rpt_flag); e(stat_precip_vol); e(stat_evap_vol); e(stat_infil_vol); e(stat_imperv_vol); e(stat_perv_vol); e(stat_runoff_vol); e(stat_max_runoff); @@ -763,6 +773,7 @@ struct SubcatchData { gw_max_infil_vol.shrink_to_fit(); comments.shrink_to_fit(); + tags.shrink_to_fit(); rpt_flag.shrink_to_fit(); diff --git a/src/engine/edit/ObjectDeleter.cpp b/src/engine/edit/ObjectDeleter.cpp index c17b21df7..a26af5529 100644 --- a/src/engine/edit/ObjectDeleter.cpp +++ b/src/engine/edit/ObjectDeleter.cpp @@ -28,14 +28,13 @@ static void renumber_refs(std::vector& refs, int deleted_idx) { } // Erase all spatial arrays for a node at idx (if they exist). +// Tags are erased by NodeData::erase_at via the index-shift path; +// no name-keyed map cleanup needed. static void erase_node_spatial(SimulationContext& ctx, int idx) { const auto ui = static_cast(idx); auto e = [&](auto& v) { if (ui < v.size()) v.erase(v.begin() + static_cast(idx)); }; e(ctx.spatial.node_x); e(ctx.spatial.node_y); - // Remove tag keyed by the node name (looked up before erase) - const std::string& name = ctx.node_names.name_of(idx); - ctx.node_tags.erase(name); } static void erase_link_spatial(SimulationContext& ctx, int idx) { @@ -47,8 +46,6 @@ static void erase_link_spatial(SimulationContext& ctx, int idx) { ctx.spatial.link_vertices_x.erase(ctx.spatial.link_vertices_x.begin() + static_cast(idx)); if (ui < ctx.spatial.link_vertices_y.size()) ctx.spatial.link_vertices_y.erase(ctx.spatial.link_vertices_y.begin() + static_cast(idx)); - const std::string& name = ctx.link_names.name_of(idx); - ctx.link_tags.erase(name); } static void erase_subcatch_spatial(SimulationContext& ctx, int idx) { @@ -60,8 +57,6 @@ static void erase_subcatch_spatial(SimulationContext& ctx, int idx) { ctx.spatial.subcatch_polygon_x.erase(ctx.spatial.subcatch_polygon_x.begin() + static_cast(idx)); if (ui < ctx.spatial.subcatch_polygon_y.size()) ctx.spatial.subcatch_polygon_y.erase(ctx.spatial.subcatch_polygon_y.begin() + static_cast(idx)); - const std::string& name = ctx.subcatch_names.name_of(idx); - ctx.subcatch_tags.erase(name); } static void erase_gage_spatial(SimulationContext& ctx, int idx) { diff --git a/src/engine/input/geopackage/GeoPackageReader.cpp b/src/engine/input/geopackage/GeoPackageReader.cpp index 2359b0963..e02f4594a 100644 --- a/src/engine/input/geopackage/GeoPackageReader.cpp +++ b/src/engine/input/geopackage/GeoPackageReader.cpp @@ -290,8 +290,11 @@ static void read_nodes(sqlite3* db, SimulationContext& ctx, const std::string& s ctx.nodes.storage_c[idx] = column_double(stmt.get(), 17); } - if (!column_is_null(stmt.get(), 18)) - ctx.node_tags[name] = column_text(stmt.get(), 18); + if (!column_is_null(stmt.get(), 18)) { + const auto u = static_cast(idx); + if (u >= ctx.nodes.tags.size()) ctx.nodes.tags.resize(u + 1); + ctx.nodes.tags[u] = column_text(stmt.get(), 18); + } } } @@ -413,8 +416,11 @@ static void read_links(sqlite3* db, SimulationContext& ctx, const std::string& s ctx.links.crest_height[idx] = column_double(stmt.get(), 28); ctx.links.cd[idx] = column_double(stmt.get(), 29); - if (!column_is_null(stmt.get(), 30)) - ctx.link_tags[name] = column_text(stmt.get(), 30); + if (!column_is_null(stmt.get(), 30)) { + const auto u = static_cast(idx); + if (u >= ctx.links.tags.size()) ctx.links.tags.resize(u + 1); + ctx.links.tags[u] = column_text(stmt.get(), 30); + } } } @@ -488,8 +494,11 @@ static void read_subcatchments(sqlite3* db, SimulationContext& ctx, const std::s ctx.subcatches.infil_p4[idx] = column_double(stmt.get(), 21); ctx.subcatches.infil_p5[idx] = column_double(stmt.get(), 22); - if (!column_is_null(stmt.get(), 23)) - ctx.subcatch_tags[name] = column_text(stmt.get(), 23); + if (!column_is_null(stmt.get(), 23)) { + const auto u = static_cast(idx); + if (u >= ctx.subcatches.tags.size()) ctx.subcatches.tags.resize(u + 1); + ctx.subcatches.tags[u] = column_text(stmt.get(), 23); + } } } diff --git a/src/engine/input/geopackage/GeoPackageWriter.cpp b/src/engine/input/geopackage/GeoPackageWriter.cpp index 1fd235b43..0841276c4 100644 --- a/src/engine/input/geopackage/GeoPackageWriter.cpp +++ b/src/engine/input/geopackage/GeoPackageWriter.cpp @@ -339,10 +339,10 @@ static void write_nodes(sqlite3* db, const SimulationContext& ctx, bind_null(stmt.get(), 19); } - // Tag - auto tag_it = ctx.node_tags.find(name); - if (tag_it != ctx.node_tags.end()) - bind_text(stmt.get(), 20, tag_it->second); + // Tag — pulled from per-NodeData field (was: ctx.node_tags map). + const auto utag = static_cast(i); + if (utag < ctx.nodes.tags.size() && !ctx.nodes.tags[utag].empty()) + bind_text(stmt.get(), 20, ctx.nodes.tags[utag]); else bind_null(stmt.get(), 20); @@ -453,10 +453,10 @@ static void write_links(sqlite3* db, const SimulationContext& ctx, bind_double(stmt.get(), 30, safe_dbl(ctx.links.crest_height, i)); bind_double(stmt.get(), 31, safe_dbl(ctx.links.cd, i)); - // Tag - auto tag_it = ctx.link_tags.find(name); - if (tag_it != ctx.link_tags.end()) - bind_text(stmt.get(), 32, tag_it->second); + // Tag — per-LinkData field. + const auto utag = static_cast(i); + if (utag < ctx.links.tags.size() && !ctx.links.tags[utag].empty()) + bind_text(stmt.get(), 32, ctx.links.tags[utag]); else bind_null(stmt.get(), 32); @@ -532,9 +532,9 @@ static void write_subcatchments(sqlite3* db, const SimulationContext& ctx, bind_double(stmt.get(), 23, safe_dbl(ctx.subcatches.infil_p4, i)); bind_double(stmt.get(), 24, safe_dbl(ctx.subcatches.infil_p5, i)); - auto tag_it = ctx.subcatch_tags.find(name); - if (tag_it != ctx.subcatch_tags.end()) - bind_text(stmt.get(), 25, tag_it->second); + const auto utag = static_cast(i); + if (utag < ctx.subcatches.tags.size() && !ctx.subcatches.tags[utag].empty()) + bind_text(stmt.get(), 25, ctx.subcatches.tags[utag]); else bind_null(stmt.get(), 25); diff --git a/src/engine/input/handlers/SpatialHandler.cpp b/src/engine/input/handlers/SpatialHandler.cpp index 56b2487f5..32b818572 100644 --- a/src/engine/input/handlers/SpatialHandler.cpp +++ b/src/engine/input/handlers/SpatialHandler.cpp @@ -179,14 +179,32 @@ void handle_tags(SimulationContext& ctx, const std::vector& lines) const std::string& name = tok[1]; const std::string& tag = tok[2]; + // Resolve name → index against the SoA stores. Tags are stored + // per-NodeData/LinkData/SubcatchData index, not name-keyed, so + // they survive a subsequent swmm_*_rename. if (obj_type == "NODE") { - ctx.node_tags[name] = tag; + const int idx = ctx.node_names.find(name); + if (idx >= 0) { + const auto u = static_cast(idx); + if (u >= ctx.nodes.tags.size()) ctx.nodes.tags.resize(u + 1); + ctx.nodes.tags[u] = tag; + } } else if (obj_type == "LINK") { - ctx.link_tags[name] = tag; + const int idx = ctx.link_names.find(name); + if (idx >= 0) { + const auto u = static_cast(idx); + if (u >= ctx.links.tags.size()) ctx.links.tags.resize(u + 1); + ctx.links.tags[u] = tag; + } } else if (obj_type == "SUBCATCH") { - ctx.subcatch_tags[name] = tag; + const int idx = ctx.subcatch_names.find(name); + if (idx >= 0) { + const auto u = static_cast(idx); + if (u >= ctx.subcatches.tags.size()) ctx.subcatches.tags.resize(u + 1); + ctx.subcatches.tags[u] = tag; + } } } } diff --git a/src/engine/input/handlers/SpatialHandler.hpp b/src/engine/input/handlers/SpatialHandler.hpp index b9926b7e8..cba0c1f0f 100644 --- a/src/engine/input/handlers/SpatialHandler.hpp +++ b/src/engine/input/handlers/SpatialHandler.hpp @@ -31,7 +31,9 @@ void handle_polygons(SimulationContext& ctx, const std::vector& lin /** @brief Parse [SYMBOLS] — fills spatial.gage_x/y. */ void handle_symbols(SimulationContext& ctx, const std::vector& lines); -/** @brief Parse [TAGS] — fills node_tags, link_tags, subcatch_tags. */ +/** @brief Parse [TAGS] — writes per-index tags onto ctx.nodes.tags / + * ctx.links.tags / ctx.subcatches.tags (resolves name → idx via the + * matching NameIndex; unresolved lines are skipped). */ void handle_tags(SimulationContext& ctx, const std::vector& lines); } /* namespace openswmm::input */ diff --git a/src/legacy/cli/main.c b/src/legacy/cli/main.c index 79497d9ac..01ff2c532 100644 --- a/src/legacy/cli/main.c +++ b/src/legacy/cli/main.c @@ -11,6 +11,7 @@ #include #include #include "openswmm_solver.h" +#include "legacy_version.h" /*! * \brief Main function for the command line version of EPA SWMM 5.3 @@ -34,16 +35,11 @@ int main(int argc, char *argv[]) char *binaryFile; char *arg1; char blank[] = ""; - int version, vMajor, vMinor, vRelease; char errMsg[128]; int msgLen = 127; time_t start; double runTime; - - version = swmm_getVersion(); - vMajor = version / 10000; - vMinor = (version - 10000 * vMajor) / 1000; - vRelease = (version - 10000 * vMajor - 1000 * vMinor); + start = time(0); // --- check for proper number of command line arguments @@ -69,7 +65,8 @@ int main(int argc, char *argv[]) else if (strcmp(arg1, "--version") == 0 || strcmp(arg1, "-v") == 0) { // Output version number - printf("\n%d.%d.%0d\n\n", vMajor, vMinor, vRelease); + printf("\n%s (openswmm.legacy %s)\n\n", + LEGACY_SWMM_VERSION_FULL, OPENSWMM_LEGACY_FULL_VERSION); } else { @@ -83,8 +80,9 @@ int main(int argc, char *argv[]) reportFile = argv[2]; if (argc > 3) binaryFile = argv[3]; else binaryFile = blank; - printf("\n... EPA SWMM %d.%d (Build %d.%d.%0d)\n", vMajor, vMinor, - vMajor, vMinor, vRelease); + printf("\n... EPA SWMM %d.%d (Build %s)\n", + LEGACY_SWMM_VERSION_MAJOR, LEGACY_SWMM_VERSION_MINOR, + LEGACY_SWMM_VERSION_FULL); // --- run SWMM swmm_run(inputFile, reportFile, binaryFile); diff --git a/tests/unit/engine/CMakeLists.txt b/tests/unit/engine/CMakeLists.txt index 285f69562..fc6d5fade 100644 --- a/tests/unit/engine/CMakeLists.txt +++ b/tests/unit/engine/CMakeLists.txt @@ -106,12 +106,15 @@ add_gtest_unit(test_engine_routing test_routing.cpp) add_gtest_unit(test_engine_quality_routing test_quality_routing.cpp) add_gtest_unit(test_engine_treatment test_treatment.cpp) add_gtest_unit(test_engine_rdii test_rdii.cpp) +add_gtest_unit(test_engine_hydrograph_mutation test_hydrograph_mutation_api.cpp) +add_gtest_unit(test_engine_da4_api test_da4_engine_api.cpp) add_gtest_unit(test_engine_controls test_controls.cpp) add_gtest_unit(test_engine_gap_fixes test_gap_fixes.cpp) add_gtest_unit(test_engine_report_section test_report_section.cpp) add_gtest_unit(test_engine_concurrent test_concurrent_engines.cpp) add_gtest_unit(test_engine_object_deletion test_object_deletion.cpp) add_gtest_unit(test_engine_type_conversion test_type_conversion.cpp) +add_gtest_unit(test_engine_tags test_tags.cpp) # 2D surface routing tests — geometry, gradients, flux, parsing. # Non-CVODE portions can be built without SUNDIALS by compiling the needed diff --git a/tests/unit/engine/test_geopackage.cpp b/tests/unit/engine/test_geopackage.cpp index 63fa3dc03..c3e583bfd 100644 --- a/tests/unit/engine/test_geopackage.cpp +++ b/tests/unit/engine/test_geopackage.cpp @@ -307,10 +307,27 @@ class GeoPackageTest : public ::testing::Test { 0.7, 0.8, 0.9, 1.0, 1.1, 1.2}); } - // --- TAGS --- - ctx.node_tags["J1"] = "upstream"; - ctx.link_tags["C1"] = "trunk"; - ctx.subcatch_tags["S1"] = "residential"; + // --- TAGS --- (now per-index on the SoA; resolve name → idx) + { + const int j1 = ctx.node_names.find("J1"); + if (j1 >= 0) { + const auto u = static_cast(j1); + if (u >= ctx.nodes.tags.size()) ctx.nodes.tags.resize(u + 1); + ctx.nodes.tags[u] = "upstream"; + } + const int c1 = ctx.link_names.find("C1"); + if (c1 >= 0) { + const auto u = static_cast(c1); + if (u >= ctx.links.tags.size()) ctx.links.tags.resize(u + 1); + ctx.links.tags[u] = "trunk"; + } + const int s1 = ctx.subcatch_names.find("S1"); + if (s1 >= 0) { + const auto u = static_cast(s1); + if (u >= ctx.subcatches.tags.size()) ctx.subcatches.tags.resize(u + 1); + ctx.subcatches.tags[u] = "residential"; + } + } return ctx; } @@ -696,9 +713,17 @@ TEST_F(GeoPackageTest, TagsRoundTrip) { SimulationContext ctx_in{}; ASSERT_EQ(read_from_file(db_path_, ctx_in, "test_run"), 0); - EXPECT_EQ(ctx_in.node_tags["J1"], "upstream"); - EXPECT_EQ(ctx_in.link_tags["C1"], "trunk"); - EXPECT_EQ(ctx_in.subcatch_tags["S1"], "residential"); + { + const int j1 = ctx_in.node_names.find("J1"); + ASSERT_GE(j1, 0); + EXPECT_EQ(ctx_in.nodes.tags[static_cast(j1)], "upstream"); + const int c1 = ctx_in.link_names.find("C1"); + ASSERT_GE(c1, 0); + EXPECT_EQ(ctx_in.links.tags[static_cast(c1)], "trunk"); + const int s1 = ctx_in.subcatch_names.find("S1"); + ASSERT_GE(s1, 0); + EXPECT_EQ(ctx_in.subcatches.tags[static_cast(s1)], "residential"); + } } // ============================================================================ diff --git a/tests/unit/legacy/engine/data/hotstart/_api_test.rpt b/tests/unit/legacy/engine/data/hotstart/_api_test.rpt index fb4bbdebc..15717bb5c 100644 --- a/tests/unit/legacy/engine/data/hotstart/_api_test.rpt +++ b/tests/unit/legacy/engine/data/hotstart/_api_test.rpt @@ -311,6 +311,6 @@ C11 0.000 - Analysis begun on: Sat May 23 20:06:20 2026 - Analysis ended on: Sat May 23 20:06:20 2026 + Analysis begun on: Sun May 24 20:35:21 2026 + Analysis ended on: Sun May 24 20:35:21 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/tests/unit/legacy/engine/data/hotstart/_err_test.rpt b/tests/unit/legacy/engine/data/hotstart/_err_test.rpt index 8e75b42c6..8582a498e 100644 --- a/tests/unit/legacy/engine/data/hotstart/_err_test.rpt +++ b/tests/unit/legacy/engine/data/hotstart/_err_test.rpt @@ -5,6 +5,6 @@ A site surface drainage model. See Site_Drainage_Model.txt for more details. - Analysis begun on: Sat May 23 20:06:20 2026 - Analysis ended on: Sat May 23 20:06:20 2026 + Analysis begun on: Sun May 24 20:35:22 2026 + Analysis ended on: Sun May 24 20:35:22 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/tests/unit/legacy/engine/data/hotstart/_expanded_api_test.rpt b/tests/unit/legacy/engine/data/hotstart/_expanded_api_test.rpt index 7b15f2ce4..5f82f03b1 100644 --- a/tests/unit/legacy/engine/data/hotstart/_expanded_api_test.rpt +++ b/tests/unit/legacy/engine/data/hotstart/_expanded_api_test.rpt @@ -311,6 +311,6 @@ C11 0.000 - Analysis begun on: Sat May 23 20:06:28 2026 - Analysis ended on: Sat May 23 20:06:28 2026 + Analysis begun on: Sun May 24 20:35:39 2026 + Analysis ended on: Sun May 24 20:35:39 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/tests/unit/legacy/engine/data/hotstart/_shap_test.rpt b/tests/unit/legacy/engine/data/hotstart/_shap_test.rpt index 2f49778dd..89b089fe2 100644 --- a/tests/unit/legacy/engine/data/hotstart/_shap_test.rpt +++ b/tests/unit/legacy/engine/data/hotstart/_shap_test.rpt @@ -5,6 +5,6 @@ A site surface drainage model. See Site_Drainage_Model.txt for more details. - Analysis begun on: Sat May 23 20:06:21 2026 - Analysis ended on: Sat May 23 20:06:21 2026 + Analysis begun on: Sun May 24 20:35:23 2026 + Analysis ended on: Sun May 24 20:35:23 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/tests/unit/legacy/engine/data/hotstart/hotstart_end.hsf b/tests/unit/legacy/engine/data/hotstart/hotstart_end.hsf index df7609797df27dd1fd80169db39500bd3995fc7f..9bf07e95c5c110399de751cd9d9939da0ced029f 100644 GIT binary patch delta 125 zcmX@keVluOJI94nLhGgPJ#(0Rk;!6Wdjbl7vN|K0z;SeebS4xPPLl Date: Mon, 25 May 2026 05:34:38 -0400 Subject: [PATCH 12/26] Adding missing unit test files Signed-off-by: cbuahin --- include/openswmm/engine/openswmm_tables.h | 34 ++ src/engine/core/openswmm_tables_impl.cpp | 64 +++ tests/unit/engine/test_da4_engine_api.cpp | 154 +++++++ .../engine/test_hydrograph_mutation_api.cpp | 434 ++++++++++++++++++ tests/unit/engine/test_tags.cpp | 150 ++++++ 5 files changed, 836 insertions(+) create mode 100644 tests/unit/engine/test_da4_engine_api.cpp create mode 100644 tests/unit/engine/test_hydrograph_mutation_api.cpp create mode 100644 tests/unit/engine/test_tags.cpp diff --git a/include/openswmm/engine/openswmm_tables.h b/include/openswmm/engine/openswmm_tables.h index 44fde414b..011b007ed 100644 --- a/include/openswmm/engine/openswmm_tables.h +++ b/include/openswmm/engine/openswmm_tables.h @@ -243,6 +243,40 @@ SWMM_ENGINE_API int swmm_pattern_get_factor_count(SWMM_Engine engine, int idx, i */ SWMM_ENGINE_API int swmm_pattern_get_factor(SWMM_Engine engine, int idx, int i, double* v); +/** + * @brief Remove a time pattern by index, clearing any reference sites. + * + * @details Walks every place the engine stores a pattern name and clears + * any entry matching the removed pattern: external inflows + * (`ext_inflows.pattern_name`), dry-weather-flow patterns + * (`dwf.pat1..pat4`), aquifer ET patterns + * (`aquifers.upper_evap_pat`), and the evaporation recovery + * option (`options.evap_recovery_pat`). The removal itself + * shifts subsequent pattern indices down by one — callers that + * hold cached pattern indices must re-resolve via + * ::swmm_pattern_index. + * + * @param engine Engine handle (SWMM_STATE_BUILDING or SWMM_STATE_OPENED). + * @param idx Zero-based pattern index. + * @returns SWMM_OK on success (idempotent: returns SWMM_OK if @p idx is + * out of range, matching the GUI's repeated-click expectations); + * SWMM_ERR_LIFECYCLE if not editable. + */ +SWMM_ENGINE_API int swmm_pattern_remove(SWMM_Engine engine, int idx); + +/** + * @brief Rename a time pattern; updates every stored reference to the + * previous name across inflows, DWF, aquifer ET, and options. + * + * @param engine Engine handle (SWMM_STATE_BUILDING or SWMM_STATE_OPENED). + * @param idx Zero-based pattern index. + * @param newId New null-terminated identifier; must not already be in use. + * @returns SWMM_OK on success; SWMM_ERR_BADPARAM if @p newId is empty or + * collides with an existing pattern; SWMM_ERR_LIFECYCLE if not + * editable; SWMM_ERR_BADINDEX if @p idx is out of range. + */ +SWMM_ENGINE_API int swmm_pattern_rename(SWMM_Engine engine, int idx, const char* newId); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/src/engine/core/openswmm_tables_impl.cpp b/src/engine/core/openswmm_tables_impl.cpp index 610af040d..2052227e4 100644 --- a/src/engine/core/openswmm_tables_impl.cpp +++ b/src/engine/core/openswmm_tables_impl.cpp @@ -220,4 +220,68 @@ SWMM_ENGINE_API int swmm_pattern_get_factor(SWMM_Engine engine, int idx, int i, return SWMM_OK; } +namespace { + +// Visit every pattern-name reference site with @p fn(std::string& slot). +// Centralised so remove (clear matching) and rename (rewrite matching) +// share one walk and stay in sync with the data layout. +template +void for_each_pattern_name_ref(openswmm::SimulationContext& ctx, F&& fn) { + for (auto& s : ctx.ext_inflows.pattern_name) fn(s); + for (auto& s : ctx.dwf_inflows.pat1) fn(s); + for (auto& s : ctx.dwf_inflows.pat2) fn(s); + for (auto& s : ctx.dwf_inflows.pat3) fn(s); + for (auto& s : ctx.dwf_inflows.pat4) fn(s); + for (auto& s : ctx.aquifers.upper_evap_pat) fn(s); + fn(ctx.options.evap_recovery_pat); +} + +} // namespace + +SWMM_ENGINE_API int swmm_pattern_remove(SWMM_Engine engine, int idx) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_EDITABLE(ctx); + // Idempotent: silently ignore stale indices so the GUI's "delete then + // delete again on a re-resolved-too-late button" path is safe. + if (idx < 0 || idx >= ctx.patterns.count()) return SWMM_OK; + + const auto u = static_cast(idx); + const std::string removed = ctx.patterns.names[u]; + + ctx.patterns.names.erase(ctx.patterns.names.begin() + u); + ctx.patterns.types.erase(ctx.patterns.types.begin() + u); + ctx.patterns.factors.erase(ctx.patterns.factors.begin() + u); + + for_each_pattern_name_ref(ctx, [&](std::string& slot) { + if (slot == removed) slot.clear(); + }); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_pattern_rename(SWMM_Engine engine, int idx, const char* newId) { + CHECK_HANDLE(engine); + if (!newId || newId[0] == '\0') return SWMM_ERR_BADPARAM; + auto& ctx = to_engine(engine)->context(); + CHECK_EDITABLE(ctx); + CHECK_INDEX(idx >= 0 && idx < ctx.patterns.count()); + + const std::string next = newId; + const auto u = static_cast(idx); + // Renaming to the same name is a no-op (also avoids a false collision). + if (ctx.patterns.names[u] == next) return SWMM_OK; + + for (std::size_t j = 0; j < ctx.patterns.names.size(); ++j) { + if (j != u && ctx.patterns.names[j] == next) return SWMM_ERR_BADPARAM; + } + + const std::string prev = ctx.patterns.names[u]; + ctx.patterns.names[u] = next; + + for_each_pattern_name_ref(ctx, [&](std::string& slot) { + if (slot == prev) slot = next; + }); + return SWMM_OK; +} + } /* extern "C" */ diff --git a/tests/unit/engine/test_da4_engine_api.cpp b/tests/unit/engine/test_da4_engine_api.cpp new file mode 100644 index 000000000..b1c100764 --- /dev/null +++ b/tests/unit/engine/test_da4_engine_api.cpp @@ -0,0 +1,154 @@ +/** + * @file test_da4_engine_api.cpp + * @brief DA.4.0 — Unit tests for the engine prerequisites of GUI Slice DA.4. + * + * @details Covers two C API additions wired for the GUI's outfall stage-data + * picker and the rich pattern editor: + * - swmm_node_get_outfall_tidal / _get_outfall_timeseries + * (read back the union-typed outfall_param slot with a type guard + * so callers can distinguish "unassigned" from a genuine index of 0) + * - swmm_pattern_get_type / _get_factor_count / _get_factor + * (round-trip the multiplier vector that swmm_pattern_set_factors + * writes; needed so the editor and DWF pickers can read the + * current state) + * + * @see docs/GUI_IMPLEMENTATION_PLAN.md slice DA.4 (openswmm.gui) + */ + +#include + +#include +#include +#include + +namespace { + +class OutfallStageDataApiTest : public ::testing::Test { +protected: + SWMM_Engine engine = nullptr; + int out_idx = -1; + int curve_a_idx = -1; + int ts_a_idx = -1; + + void SetUp() override { + engine = swmm_engine_new(); + ASSERT_NE(engine, nullptr); + ASSERT_EQ(swmm_node_add(engine, "OUT1", SWMM_NODE_OUTFALL), SWMM_OK); + out_idx = swmm_node_index(engine, "OUT1"); + ASSERT_GE(out_idx, 0); + + ASSERT_EQ(swmm_curve_add(engine, "TC1", 2 /*TIDAL*/), SWMM_OK); + curve_a_idx = swmm_table_index(engine, "TC1"); + ASSERT_GE(curve_a_idx, 0); + + ASSERT_EQ(swmm_timeseries_add(engine, "TS1"), SWMM_OK); + ts_a_idx = swmm_table_index(engine, "TS1"); + ASSERT_GE(ts_a_idx, 0); + } + + void TearDown() override { if (engine) swmm_engine_destroy(engine); } +}; + +// Case 1 — set TIDAL with curve_a → get_outfall_tidal returns that idx. +TEST_F(OutfallStageDataApiTest, GetTidalRoundtripsAfterSet) { + ASSERT_EQ(swmm_node_set_outfall_tidal(engine, out_idx, curve_a_idx), SWMM_OK); + int got = -999; + ASSERT_EQ(swmm_node_get_outfall_tidal(engine, out_idx, &got), SWMM_OK); + EXPECT_EQ(got, curve_a_idx); +} + +// Case 2 — set TIMESERIES → get_outfall_timeseries returns that idx. +TEST_F(OutfallStageDataApiTest, GetTimeseriesRoundtripsAfterSet) { + ASSERT_EQ(swmm_node_set_outfall_timeseries(engine, out_idx, ts_a_idx), SWMM_OK); + int got = -999; + ASSERT_EQ(swmm_node_get_outfall_timeseries(engine, out_idx, &got), SWMM_OK); + EXPECT_EQ(got, ts_a_idx); +} + +// Case 3 — get_outfall_tidal on a FIXED-typed outfall returns BADPARAM +// (so the GUI can distinguish unassigned from genuine idx-zero). +TEST_F(OutfallStageDataApiTest, GetTidalRejectsFixedType) { + ASSERT_EQ(swmm_node_set_outfall_stage(engine, out_idx, 12.5), SWMM_OK); + int got = -999; + EXPECT_EQ(swmm_node_get_outfall_tidal(engine, out_idx, &got), SWMM_ERR_BADPARAM); + EXPECT_EQ(got, -999); // untouched +} + +// Case 4 — get_outfall_timeseries on a TIDAL-typed outfall returns BADPARAM. +TEST_F(OutfallStageDataApiTest, GetTimeseriesRejectsTidalType) { + ASSERT_EQ(swmm_node_set_outfall_tidal(engine, out_idx, curve_a_idx), SWMM_OK); + int got = -999; + EXPECT_EQ(swmm_node_get_outfall_timeseries(engine, out_idx, &got), SWMM_ERR_BADPARAM); +} + +// ============================================================================ +// Pattern getters +// ============================================================================ + +class PatternGetterApiTest : public ::testing::Test { +protected: + SWMM_Engine engine = nullptr; + + void SetUp() override { + engine = swmm_engine_new(); + ASSERT_NE(engine, nullptr); + } + + void TearDown() override { if (engine) swmm_engine_destroy(engine); } +}; + +// Case 5 — pattern type round-trips across all four kinds. +TEST_F(PatternGetterApiTest, GetTypeRoundtripsAllKinds) { + const int kinds[4][2] = { + {0 /*MONTHLY*/, 12}, {1 /*DAILY*/, 7}, + {2 /*HOURLY*/, 24}, {3 /*WEEKEND*/, 24}, + }; + const char* names[4] = {"PM", "PD", "PH", "PW"}; + + for (int k = 0; k < 4; ++k) { + ASSERT_EQ(swmm_pattern_add(engine, names[k], kinds[k][0]), SWMM_OK); + const int idx = swmm_pattern_index(engine, names[k]); + ASSERT_GE(idx, 0); + int got = -1; + ASSERT_EQ(swmm_pattern_get_type(engine, idx, &got), SWMM_OK); + EXPECT_EQ(got, kinds[k][0]); + } +} + +// Case 6 — factor_count + factor round-trip after set_factors. +TEST_F(PatternGetterApiTest, GetFactorCountAndFactorRoundtrip) { + ASSERT_EQ(swmm_pattern_add(engine, "PH", 2 /*HOURLY*/), SWMM_OK); + const int idx = swmm_pattern_index(engine, "PH"); + ASSERT_GE(idx, 0); + + double f24[24]; + for (int i = 0; i < 24; ++i) f24[i] = 1.0 + 0.1 * i; + ASSERT_EQ(swmm_pattern_set_factors(engine, idx, f24, 24), SWMM_OK); + + int count = -1; + ASSERT_EQ(swmm_pattern_get_factor_count(engine, idx, &count), SWMM_OK); + EXPECT_EQ(count, 24); + + for (int i = 0; i < 24; ++i) { + double v = -1.0; + ASSERT_EQ(swmm_pattern_get_factor(engine, idx, i, &v), SWMM_OK); + EXPECT_DOUBLE_EQ(v, f24[i]); + } +} + +// Case 7 — out-of-range factor index returns an error (the CHECK_INDEX macro +// returns SWMM_ERR_INDEX). Asserts the call fails — caller treats any non-OK +// as "unavailable" without depending on the specific code. +TEST_F(PatternGetterApiTest, GetFactorOutOfRangeIsRejected) { + ASSERT_EQ(swmm_pattern_add(engine, "PM", 0 /*MONTHLY*/), SWMM_OK); + const int idx = swmm_pattern_index(engine, "PM"); + ASSERT_GE(idx, 0); + double f12[12] = { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 }; + ASSERT_EQ(swmm_pattern_set_factors(engine, idx, f12, 12), SWMM_OK); + + double v = -1.0; + EXPECT_NE(swmm_pattern_get_factor(engine, idx, 12, &v), SWMM_OK); + EXPECT_NE(swmm_pattern_get_factor(engine, idx, -1, &v), SWMM_OK); +} + +} // namespace diff --git a/tests/unit/engine/test_hydrograph_mutation_api.cpp b/tests/unit/engine/test_hydrograph_mutation_api.cpp new file mode 100644 index 000000000..b74f1d1ec --- /dev/null +++ b/tests/unit/engine/test_hydrograph_mutation_api.cpp @@ -0,0 +1,434 @@ +/** + * @file test_hydrograph_mutation_api.cpp + * @brief BS-02 — Unit tests for the hydrograph + RDII-decay mutation surface. + * + * @details Covers the BS-02 C API added to support the GUI's + * `HydrographGroupEditor` MVC layer: + * - swmm_hydrograph_set_rtk / _set_ia (upsert + partial-field merge) + * - swmm_hydrograph_remove_entry (key-based, idempotent) + * - swmm_hydrograph_remove_group (cascades to gage / decay / [RDII]) + * - swmm_hydrograph_clear_group_months (preserves ALL row) + * - swmm_hydrograph_set_gage (set / replace / clear) + * - swmm_hydrograph_group_rename (walks all four data containers) + * - swmm_rdii_decay_set / _remove + * + * @see include/openswmm/engine/openswmm_inflows.h + */ + +#include + +#include +#include + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Fixture: bare engine + two junctions so [RDII] node assignments are valid. +// --------------------------------------------------------------------------- + +class HydrographMutationTest : public ::testing::Test { +protected: + SWMM_Engine engine = nullptr; + int j1_idx = -1; + int j2_idx = -1; + + void SetUp() override { + engine = swmm_engine_new(); + ASSERT_NE(engine, nullptr); + ASSERT_EQ(swmm_node_add(engine, "J1", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_node_add(engine, "J2", SWMM_NODE_JUNCTION), SWMM_OK); + j1_idx = swmm_node_index(engine, "J1"); + j2_idx = swmm_node_index(engine, "J2"); + ASSERT_GE(j1_idx, 0); + ASSERT_GE(j2_idx, 0); + } + + void TearDown() override { if (engine) swmm_engine_destroy(engine); } + + // Convenience: return entry index matching (uh, month, response), or -1. + int findEntry(const char* uh, int month, int response) const { + const int n = swmm_hydrograph_count(engine); + for (int i = 0; i < n; ++i) { + char buf[64]; int m = -2, r = -2; + double r_, t_, k_, dmax_, drec_, dinit_; + if (swmm_hydrograph_get(engine, i, buf, sizeof(buf), &m, &r, + &r_, &t_, &k_, &dmax_, &drec_, &dinit_) != SWMM_OK) + continue; + if (std::strcmp(buf, uh) == 0 && m == month && r == response) return i; + } + return -1; + } + + int findDecay(const char* uh, int response) const { + const int n = swmm_rdii_decay_count(engine); + for (int i = 0; i < n; ++i) { + char buf[64]; int r = -2; + double k_dep, k_0, k_T, T_ref, theta, T_freeze; + if (swmm_rdii_decay_get(engine, i, buf, sizeof(buf), &r, + &k_dep, &k_0, &k_T, &T_ref, &theta, &T_freeze) != SWMM_OK) + continue; + if (std::strcmp(buf, uh) == 0 && r == response) return i; + } + return -1; + } +}; + +// --------------------------------------------------------------------------- +// Case 1 — set_rtk on a fresh key appends a new entry with IA = 0. +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, SetRtkAppendsNewEntryWithZeroIa) { + EXPECT_EQ(swmm_hydrograph_count(engine), 0); + + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", -1, 0, 0.3, 1.5, 2.0), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_count(engine), 1); + + char buf[64]; int m = 0, r = 0; + double r_, t_, k_, dmax_, drec_, dinit_; + ASSERT_EQ(swmm_hydrograph_get(engine, 0, buf, sizeof(buf), &m, &r, + &r_, &t_, &k_, &dmax_, &drec_, &dinit_), SWMM_OK); + EXPECT_STREQ(buf, "G1"); + EXPECT_EQ(m, -1); + EXPECT_EQ(r, 0); + EXPECT_DOUBLE_EQ(r_, 0.3); + EXPECT_DOUBLE_EQ(t_, 1.5); + EXPECT_DOUBLE_EQ(k_, 2.0); + EXPECT_DOUBLE_EQ(dmax_, 0.0); + EXPECT_DOUBLE_EQ(drec_, 0.0); + EXPECT_DOUBLE_EQ(dinit_, 0.0); +} + +// --------------------------------------------------------------------------- +// Case 2 — set_rtk on an existing key updates in-place; row count unchanged. +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, SetRtkUpdatesInPlaceWhenKeyMatches) { + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", -1, 1, 0.2, 1.0, 2.5), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_count(engine), 1); + + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", -1, 1, 0.45, 2.5, 3.0), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_count(engine), 1); + + const int i = findEntry("G1", -1, 1); + ASSERT_GE(i, 0); + char buf[64]; int m, r; + double r_, t_, k_, dmax_, drec_, dinit_; + ASSERT_EQ(swmm_hydrograph_get(engine, i, buf, sizeof(buf), &m, &r, + &r_, &t_, &k_, &dmax_, &drec_, &dinit_), SWMM_OK); + EXPECT_DOUBLE_EQ(r_, 0.45); + EXPECT_DOUBLE_EQ(t_, 2.5); + EXPECT_DOUBLE_EQ(k_, 3.0); +} + +// --------------------------------------------------------------------------- +// Case 3 — set_rtk then set_ia on same key preserves both halves (composes). +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, SetRtkThenSetIaPreservesBothFieldGroups) { + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", -1, 2, 0.1, 4.0, 2.0), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_ia(engine, "G1", -1, 2, 0.5, 0.1, 0.0), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_count(engine), 1); + + const int i = findEntry("G1", -1, 2); + ASSERT_GE(i, 0); + char buf[64]; int m, r; + double r_, t_, k_, dmax_, drec_, dinit_; + ASSERT_EQ(swmm_hydrograph_get(engine, i, buf, sizeof(buf), &m, &r, + &r_, &t_, &k_, &dmax_, &drec_, &dinit_), SWMM_OK); + EXPECT_DOUBLE_EQ(r_, 0.1); + EXPECT_DOUBLE_EQ(t_, 4.0); + EXPECT_DOUBLE_EQ(k_, 2.0); + EXPECT_DOUBLE_EQ(dmax_, 0.5); + EXPECT_DOUBLE_EQ(drec_, 0.1); + EXPECT_DOUBLE_EQ(dinit_, 0.0); + + // Now overwrite just the IA half — RTK fields must survive. + ASSERT_EQ(swmm_hydrograph_set_ia(engine, "G1", -1, 2, 1.0, 0.25, 0.1), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_get(engine, i, buf, sizeof(buf), &m, &r, + &r_, &t_, &k_, &dmax_, &drec_, &dinit_), SWMM_OK); + EXPECT_DOUBLE_EQ(r_, 0.1); + EXPECT_DOUBLE_EQ(dmax_, 1.0); + EXPECT_DOUBLE_EQ(drec_, 0.25); + EXPECT_DOUBLE_EQ(dinit_, 0.1); +} + +// --------------------------------------------------------------------------- +// Case 4 — remove_entry is key-based and idempotent. +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, RemoveEntryIsKeyBasedAndIdempotent) { + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", 0, 0, 0.1, 1.0, 2.0), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", 1, 0, 0.2, 1.5, 2.0), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", 0, 1, 0.3, 2.0, 2.0), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_count(engine), 3); + + EXPECT_EQ(swmm_hydrograph_remove_entry(engine, "G1", 1, 0), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_count(engine), 2); + EXPECT_EQ(findEntry("G1", 1, 0), -1); + EXPECT_GE(findEntry("G1", 0, 0), 0); + EXPECT_GE(findEntry("G1", 0, 1), 0); + + // Idempotent — removing again returns SWMM_OK without changing the count. + EXPECT_EQ(swmm_hydrograph_remove_entry(engine, "G1", 1, 0), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_count(engine), 2); +} + +// --------------------------------------------------------------------------- +// Case 5 — remove_group cascades to gage assignment, decay, and [RDII]. +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, RemoveGroupCascadesToAllReferences) { + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", -1, 0, 0.3, 1.5, 2.0), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G2", -1, 0, 0.4, 2.0, 2.5), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_gage(engine, "G1", "RG1"), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_gage(engine, "G2", "RG2"), SWMM_OK); + ASSERT_EQ(swmm_rdii_decay_set(engine, "G1", 0, 0.1, 0.05, 0.0, 10.0, 0.0, 0.0), SWMM_OK); + ASSERT_EQ(swmm_rdii_decay_set(engine, "G2", 0, 0.2, 0.05, 0.0, 10.0, 0.0, 0.0), SWMM_OK); + ASSERT_EQ(swmm_rdii_add(engine, j1_idx, "G1", 10.0), SWMM_OK); + ASSERT_EQ(swmm_rdii_add(engine, j2_idx, "G1", 20.0), SWMM_OK); + ASSERT_EQ(swmm_rdii_add(engine, j1_idx, "G2", 30.0), SWMM_OK); + + EXPECT_EQ(swmm_hydrograph_count(engine), 2); + EXPECT_EQ(swmm_hydrograph_gage_count(engine), 2); + EXPECT_EQ(swmm_rdii_decay_count(engine), 2); + EXPECT_EQ(swmm_rdii_count(engine), 3); + + EXPECT_EQ(swmm_hydrograph_remove_group(engine, "G1"), SWMM_OK); + + EXPECT_EQ(swmm_hydrograph_count(engine), 1); + EXPECT_EQ(swmm_hydrograph_gage_count(engine), 1); + EXPECT_EQ(swmm_rdii_decay_count(engine), 1); + EXPECT_EQ(swmm_rdii_count(engine), 1); + + // What's left should be G2 references only. + EXPECT_GE(findEntry("G2", -1, 0), 0); + EXPECT_GE(findDecay("G2", 0), 0); + + char uh[64], gage[64]; + ASSERT_EQ(swmm_hydrograph_get_gage(engine, 0, uh, sizeof(uh), + gage, sizeof(gage)), SWMM_OK); + EXPECT_STREQ(uh, "G2"); + EXPECT_STREQ(gage, "RG2"); +} + +// --------------------------------------------------------------------------- +// Case 6 — clear_group_months preserves any month=-1 (ALL) row. +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, ClearGroupMonthsPreservesAllRow) { + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", -1, 0, 0.30, 1.0, 2.0), SWMM_OK); + for (int m = 0; m < 12; ++m) { + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", m, 0, 0.10, 1.0, 2.0), SWMM_OK); + } + EXPECT_EQ(swmm_hydrograph_count(engine), 13); + + EXPECT_EQ(swmm_hydrograph_clear_group_months(engine, "G1"), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_count(engine), 1); + EXPECT_GE(findEntry("G1", -1, 0), 0); + for (int m = 0; m < 12; ++m) { + EXPECT_EQ(findEntry("G1", m, 0), -1); + } +} + +// --------------------------------------------------------------------------- +// Case 7 — set_gage adds, replaces, and clears the gage assignment. +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, SetGageAddReplaceClear) { + EXPECT_EQ(swmm_hydrograph_gage_count(engine), 0); + + ASSERT_EQ(swmm_hydrograph_set_gage(engine, "G1", "RG1"), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_gage_count(engine), 1); + char uh[64], gage[64]; + ASSERT_EQ(swmm_hydrograph_get_gage(engine, 0, uh, sizeof(uh), + gage, sizeof(gage)), SWMM_OK); + EXPECT_STREQ(gage, "RG1"); + + // Replace. + ASSERT_EQ(swmm_hydrograph_set_gage(engine, "G1", "RG2"), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_gage_count(engine), 1); + ASSERT_EQ(swmm_hydrograph_get_gage(engine, 0, uh, sizeof(uh), + gage, sizeof(gage)), SWMM_OK); + EXPECT_STREQ(gage, "RG2"); + + // Clear via NULL. + ASSERT_EQ(swmm_hydrograph_set_gage(engine, "G1", nullptr), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_gage_count(engine), 0); + + // Clear via empty string when there is no row — idempotent. + EXPECT_EQ(swmm_hydrograph_set_gage(engine, "G1", ""), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_gage_count(engine), 0); +} + +// --------------------------------------------------------------------------- +// Case 8 — group_rename walks entries, gage assignments, decay rows, and +// [RDII] node assignments. +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, GroupRenameWalksAllFourContainers) { + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", -1, 0, 0.3, 1.0, 2.0), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", 3, 1, 0.2, 1.5, 2.0), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_gage(engine, "G1", "RG1"), SWMM_OK); + ASSERT_EQ(swmm_rdii_decay_set(engine, "G1", 0, 0.1, 0.05, 0.0, 10.0, 0.0, 0.0), SWMM_OK); + ASSERT_EQ(swmm_rdii_add(engine, j1_idx, "G1", 12.5), SWMM_OK); + + // Locate G1 in the group index list. + const int gn = swmm_hydrograph_group_count(engine); + int g1_idx = -1; + for (int i = 0; i < gn; ++i) { + char buf[64]; + ASSERT_EQ(swmm_hydrograph_group_id(engine, i, buf, sizeof(buf)), SWMM_OK); + if (std::strcmp(buf, "G1") == 0) { g1_idx = i; break; } + } + ASSERT_GE(g1_idx, 0); + + ASSERT_EQ(swmm_hydrograph_group_rename(engine, g1_idx, "RENAMED"), SWMM_OK); + + EXPECT_GE(findEntry("RENAMED", -1, 0), 0); + EXPECT_GE(findEntry("RENAMED", 3, 1), 0); + EXPECT_GE(findDecay("RENAMED", 0), 0); + + char uh[64], gage[64]; + ASSERT_EQ(swmm_hydrograph_get_gage(engine, 0, uh, sizeof(uh), + gage, sizeof(gage)), SWMM_OK); + EXPECT_STREQ(uh, "RENAMED"); + + char uh2[64]; + int node_idx = -1; double area = 0.0; + ASSERT_EQ(swmm_rdii_get(engine, 0, &node_idx, uh2, sizeof(uh2), &area), SWMM_OK); + EXPECT_STREQ(uh2, "RENAMED"); +} + +// --------------------------------------------------------------------------- +// Case 9 — group_rename rejects empty / duplicate names. +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, GroupRenameRejectsBadNames) { + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", -1, 0, 0.3, 1.0, 2.0), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G2", -1, 0, 0.3, 1.0, 2.0), SWMM_OK); + + EXPECT_EQ(swmm_hydrograph_group_rename(engine, 0, ""), SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_hydrograph_group_rename(engine, 0, nullptr), SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_hydrograph_group_rename(engine, 0, "G2"), SWMM_ERR_BADPARAM); + + // Renaming to the same name is a no-op (returns SWMM_OK without changing + // anything observable). + EXPECT_EQ(swmm_hydrograph_group_rename(engine, 0, "G1"), SWMM_OK); +} + +// --------------------------------------------------------------------------- +// Case 10 — decay_set upsert + decay_remove (idempotent). +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, DecaySetUpsertAndRemoveIdempotent) { + EXPECT_EQ(swmm_rdii_decay_count(engine), 0); + + ASSERT_EQ(swmm_rdii_decay_set(engine, "G1", 0, + 0.1, 0.05, 0.02, 10.0, 0.05, 0.0), SWMM_OK); + EXPECT_EQ(swmm_rdii_decay_count(engine), 1); + + // Upsert in-place. + ASSERT_EQ(swmm_rdii_decay_set(engine, "G1", 0, + 0.2, 0.1, 0.04, 12.0, 0.06, -1.0), SWMM_OK); + EXPECT_EQ(swmm_rdii_decay_count(engine), 1); + + char buf[64]; int r = -1; + double k_dep, k_0, k_T, T_ref, theta, T_freeze; + ASSERT_EQ(swmm_rdii_decay_get(engine, 0, buf, sizeof(buf), &r, + &k_dep, &k_0, &k_T, &T_ref, &theta, &T_freeze), SWMM_OK); + EXPECT_STREQ(buf, "G1"); + EXPECT_EQ(r, 0); + EXPECT_DOUBLE_EQ(k_dep, 0.2); + EXPECT_DOUBLE_EQ(T_ref, 12.0); + EXPECT_DOUBLE_EQ(T_freeze, -1.0); + + // Remove + idempotent re-remove. + EXPECT_EQ(swmm_rdii_decay_remove(engine, "G1", 0), SWMM_OK); + EXPECT_EQ(swmm_rdii_decay_count(engine), 0); + EXPECT_EQ(swmm_rdii_decay_remove(engine, "G1", 0), SWMM_OK); + EXPECT_EQ(swmm_rdii_decay_count(engine), 0); +} + +// --------------------------------------------------------------------------- +// Case 11 — bad parameters are rejected with SWMM_ERR_BADPARAM. +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, BadParametersRejected) { + EXPECT_EQ(swmm_hydrograph_set_rtk(engine, nullptr, -1, 0, 0.1, 1.0, 2.0), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_hydrograph_set_rtk(engine, "", -1, 0, 0.1, 1.0, 2.0), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_hydrograph_set_rtk(engine, "G1", -2, 0, 0.1, 1.0, 2.0), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_hydrograph_set_rtk(engine, "G1", 12, 0, 0.1, 1.0, 2.0), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_hydrograph_set_rtk(engine, "G1", 0, -1, 0.1, 1.0, 2.0), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_hydrograph_set_rtk(engine, "G1", 0, 3, 0.1, 1.0, 2.0), + SWMM_ERR_BADPARAM); + + EXPECT_EQ(swmm_rdii_decay_set(engine, "G1", -1, + 0.1, 0.0, 0.0, 10.0, 0.0, 0.0), SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_rdii_decay_set(engine, "G1", 3, + 0.1, 0.0, 0.0, 10.0, 0.0, 0.0), SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_rdii_decay_set(engine, "G1", 0, + -0.1, 0.0, 0.0, 10.0, 0.0, 0.0), SWMM_ERR_BADPARAM); +} + +// --------------------------------------------------------------------------- +// Case 12 — remove_group leaves an empty engine and is idempotent. +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, RemoveGroupOnUnknownNameIsNoOp) { + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "G1", -1, 0, 0.3, 1.0, 2.0), SWMM_OK); + + EXPECT_EQ(swmm_hydrograph_remove_group(engine, "DOES_NOT_EXIST"), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_count(engine), 1); + + EXPECT_EQ(swmm_hydrograph_remove_group(engine, "G1"), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_count(engine), 0); + EXPECT_EQ(swmm_hydrograph_group_count(engine), 0); +} + +// --------------------------------------------------------------------------- +// Case 13 — multiple groups stay independent across removes. +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, MultipleGroupsStayIndependentAcrossRemoves) { + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "A", -1, 0, 0.1, 1.0, 2.0), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "B", -1, 0, 0.2, 1.0, 2.0), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_rtk(engine, "C", -1, 0, 0.3, 1.0, 2.0), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_gage(engine, "A", "RG_A"), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_gage(engine, "B", "RG_B"), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_set_gage(engine, "C", "RG_C"), SWMM_OK); + ASSERT_EQ(swmm_hydrograph_count(engine), 3); + ASSERT_EQ(swmm_hydrograph_group_count(engine), 3); + + EXPECT_EQ(swmm_hydrograph_remove_group(engine, "B"), SWMM_OK); + EXPECT_EQ(swmm_hydrograph_count(engine), 2); + EXPECT_EQ(swmm_hydrograph_group_count(engine), 2); + EXPECT_GE(findEntry("A", -1, 0), 0); + EXPECT_GE(findEntry("C", -1, 0), 0); + EXPECT_EQ(findEntry("B", -1, 0), -1); +} + +// --------------------------------------------------------------------------- +// Case 14 — decay_set referencing an unknown UH still inserts (the engine +// does not currently enforce referential integrity on decay rows — +// document this so the GUI can rely on the behaviour). +// --------------------------------------------------------------------------- + +TEST_F(HydrographMutationTest, DecaySetWithoutPriorGroupStillInserts) { + EXPECT_EQ(swmm_hydrograph_count(engine), 0); + EXPECT_EQ(swmm_rdii_decay_set(engine, "PHANTOM", 0, + 0.1, 0.05, 0.0, 10.0, 0.0, 0.0), SWMM_OK); + EXPECT_EQ(swmm_rdii_decay_count(engine), 1); + EXPECT_GE(findDecay("PHANTOM", 0), 0); + + // remove_group on a name with only a decay row still cleans it up. + EXPECT_EQ(swmm_hydrograph_remove_group(engine, "PHANTOM"), SWMM_OK); + EXPECT_EQ(swmm_rdii_decay_count(engine), 0); +} diff --git a/tests/unit/engine/test_tags.cpp b/tests/unit/engine/test_tags.cpp new file mode 100644 index 000000000..38062488e --- /dev/null +++ b/tests/unit/engine/test_tags.cpp @@ -0,0 +1,150 @@ +/** + * @file test_tags.cpp + * @brief Unit tests for the per-object [TAGS] attribute (Slice DB.3). + * + * @details Covers: + * - swmm_node/link/subcatch_get/set_tag round-trip + * - empty/null tag clears + * - rename preserves the tag (index-keyed storage, not name-keyed) + * - InpWriter emits a [TAGS] section that round-trips back through + * SpatialHandler::handle_tags + * + * @ingroup engine_tests + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class TagsTest : public ::testing::Test { +protected: + SWMM_Engine engine = nullptr; + + void SetUp() override { + engine = swmm_engine_new(); + ASSERT_NE(engine, nullptr); + // One of each kind so we exercise node + link + subcatch tag accessors. + ASSERT_EQ(swmm_node_add(engine, "J1", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_node_add(engine, "J2", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine, "C1", 0), SWMM_OK); // CONDUIT + ASSERT_EQ(swmm_subcatch_add(engine, "S1"), SWMM_OK); + } + + void TearDown() override { swmm_engine_destroy(engine); } +}; + +TEST_F(TagsTest, NodeTagRoundTrip) { + char buf[64] = {0}; + + // Empty by default. + EXPECT_EQ(swmm_node_get_tag(engine, 0, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, ""); + + // Set + read back. + EXPECT_EQ(swmm_node_set_tag(engine, 0, "upstream"), SWMM_OK); + EXPECT_EQ(swmm_node_get_tag(engine, 0, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, "upstream"); + + // Null tag clears. + EXPECT_EQ(swmm_node_set_tag(engine, 0, nullptr), SWMM_OK); + EXPECT_EQ(swmm_node_get_tag(engine, 0, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, ""); + + // Empty string also clears. + EXPECT_EQ(swmm_node_set_tag(engine, 0, ""), SWMM_OK); + EXPECT_EQ(swmm_node_get_tag(engine, 0, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, ""); +} + +TEST_F(TagsTest, LinkTagRoundTrip) { + char buf[64] = {0}; + EXPECT_EQ(swmm_link_set_tag(engine, 0, "trunk"), SWMM_OK); + EXPECT_EQ(swmm_link_get_tag(engine, 0, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, "trunk"); +} + +TEST_F(TagsTest, SubcatchTagRoundTrip) { + char buf[64] = {0}; + EXPECT_EQ(swmm_subcatch_set_tag(engine, 0, "residential"), SWMM_OK); + EXPECT_EQ(swmm_subcatch_get_tag(engine, 0, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, "residential"); +} + +TEST_F(TagsTest, TagTruncatedToBufferSize) { + EXPECT_EQ(swmm_node_set_tag(engine, 0, "0123456789ABCDEF"), SWMM_OK); + char small[8] = {0}; + EXPECT_EQ(swmm_node_get_tag(engine, 0, small, sizeof(small)), SWMM_OK); + // 7 chars + NUL terminator. + EXPECT_STREQ(small, "0123456"); +} + +TEST_F(TagsTest, RenamePreservesTag) { + // The earlier name-keyed map silently lost the tag on rename. This + // regression locks in the index-keyed contract — tags travel with + // the slot, not the name. + ASSERT_EQ(swmm_node_set_tag(engine, 0, "trunk-asset-77"), SWMM_OK); + ASSERT_EQ(swmm_node_rename(engine, 0, "J1_renamed"), SWMM_OK); + + char buf[64] = {0}; + EXPECT_EQ(swmm_node_get_tag(engine, 0, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, "trunk-asset-77"); +} + +TEST_F(TagsTest, BadIndexAndBufRejected) { + EXPECT_EQ(swmm_node_get_tag(engine, 99, nullptr, 0), SWMM_ERR_BADPARAM); + char buf[8] = {0}; + EXPECT_EQ(swmm_node_get_tag(engine, 99, buf, sizeof(buf)), SWMM_ERR_BADINDEX); + EXPECT_EQ(swmm_node_set_tag(engine, 99, "x"), SWMM_ERR_BADINDEX); +} + +TEST_F(TagsTest, InpWriterEmitsTagsSection) { + // Tag each kind, write the .inp, verify the [TAGS] block is present + // with the expected text. (Round-tripping through swmm_engine_open + // would need a fuller model; the on-disk regex is sufficient to lock + // the emission contract.) + ASSERT_EQ(swmm_node_set_tag(engine, 0, "tagN1"), SWMM_OK); + ASSERT_EQ(swmm_link_set_tag(engine, 0, "tagL1"), SWMM_OK); + ASSERT_EQ(swmm_subcatch_set_tag(engine, 0, "tagS1"), SWMM_OK); + + namespace fs = std::filesystem; + const fs::path tmp = fs::temp_directory_path() / "swmm_tags_roundtrip.inp"; + ASSERT_EQ(swmm_model_write(engine, tmp.string().c_str()), SWMM_OK); + + std::ifstream in(tmp); + ASSERT_TRUE(in.good()); + std::string body((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + + EXPECT_NE(body.find("[TAGS]"), std::string::npos); + EXPECT_NE(body.find("tagN1"), std::string::npos); + EXPECT_NE(body.find("tagL1"), std::string::npos); + EXPECT_NE(body.find("tagS1"), std::string::npos); + EXPECT_NE(body.find("J1"), std::string::npos); + EXPECT_NE(body.find("C1"), std::string::npos); + EXPECT_NE(body.find("S1"), std::string::npos); + + fs::remove(tmp); +} + +TEST_F(TagsTest, NoTagsSectionWhenAllEmpty) { + // Don't pollute the .inp with an empty [TAGS] block when nothing is + // tagged — keeps round-trip diffs clean. + namespace fs = std::filesystem; + const fs::path tmp = fs::temp_directory_path() / "swmm_tags_empty.inp"; + ASSERT_EQ(swmm_model_write(engine, tmp.string().c_str()), SWMM_OK); + + std::ifstream in(tmp); + ASSERT_TRUE(in.good()); + std::string body((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + + EXPECT_EQ(body.find("[TAGS]"), std::string::npos); + fs::remove(tmp); +} From 43a952f9161fd6f36fe448eae5196beb0539fea4 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Mon, 25 May 2026 05:45:04 -0400 Subject: [PATCH 13/26] Move pattern-name template helper out of extern "C" block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Templates have C++ linkage by definition and cannot be declared inside an extern "C" block — GCC rejects with 'error: template with C linkage'. Move the anonymous-namespace block containing for_each_pattern_name_ref above the extern "C" opening so the helper has its proper C++ linkage while the C-API exports retain theirs. --- src/engine/core/openswmm_tables_impl.cpp | 36 ++++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/engine/core/openswmm_tables_impl.cpp b/src/engine/core/openswmm_tables_impl.cpp index 2052227e4..38cfcfabc 100644 --- a/src/engine/core/openswmm_tables_impl.cpp +++ b/src/engine/core/openswmm_tables_impl.cpp @@ -13,6 +13,24 @@ #include "openswmm_api_common.hpp" #include "../../../include/openswmm/engine/openswmm_tables.h" +namespace { + +// Visit every pattern-name reference site with @p fn(std::string& slot). +// Centralised so remove (clear matching) and rename (rewrite matching) +// share one walk and stay in sync with the data layout. +template +void for_each_pattern_name_ref(openswmm::SimulationContext& ctx, F&& fn) { + for (auto& s : ctx.ext_inflows.pattern_name) fn(s); + for (auto& s : ctx.dwf_inflows.pat1) fn(s); + for (auto& s : ctx.dwf_inflows.pat2) fn(s); + for (auto& s : ctx.dwf_inflows.pat3) fn(s); + for (auto& s : ctx.dwf_inflows.pat4) fn(s); + for (auto& s : ctx.aquifers.upper_evap_pat) fn(s); + fn(ctx.options.evap_recovery_pat); +} + +} // namespace + extern "C" { // ============================================================================ @@ -220,24 +238,6 @@ SWMM_ENGINE_API int swmm_pattern_get_factor(SWMM_Engine engine, int idx, int i, return SWMM_OK; } -namespace { - -// Visit every pattern-name reference site with @p fn(std::string& slot). -// Centralised so remove (clear matching) and rename (rewrite matching) -// share one walk and stay in sync with the data layout. -template -void for_each_pattern_name_ref(openswmm::SimulationContext& ctx, F&& fn) { - for (auto& s : ctx.ext_inflows.pattern_name) fn(s); - for (auto& s : ctx.dwf_inflows.pat1) fn(s); - for (auto& s : ctx.dwf_inflows.pat2) fn(s); - for (auto& s : ctx.dwf_inflows.pat3) fn(s); - for (auto& s : ctx.dwf_inflows.pat4) fn(s); - for (auto& s : ctx.aquifers.upper_evap_pat) fn(s); - fn(ctx.options.evap_recovery_pat); -} - -} // namespace - SWMM_ENGINE_API int swmm_pattern_remove(SWMM_Engine engine, int idx) { CHECK_HANDLE(engine); auto& ctx = to_engine(engine)->context(); From e6956ebc3849dfdc7f2249788765f09c26999858 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Mon, 25 May 2026 06:11:55 -0400 Subject: [PATCH 14/26] TagsTest: scope ifstream before fs::remove to fix Windows file lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows refuses to delete a file while any handle is open — POSIX unlink-on-open does not apply. Both TagsTest.InpWriterEmitsTagsSection and TagsTest.NoTagsSectionWhenAllEmpty opened an ifstream and then called fs::remove while the stream was still in scope, causing: The process cannot access the file because it is being used by another process. Move the ifstream into an inner block so the handle is released before the remove. Harmless on Linux/macOS. --- tests/unit/engine/test_tags.cpp | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/tests/unit/engine/test_tags.cpp b/tests/unit/engine/test_tags.cpp index 38062488e..affab6ebb 100644 --- a/tests/unit/engine/test_tags.cpp +++ b/tests/unit/engine/test_tags.cpp @@ -117,10 +117,17 @@ TEST_F(TagsTest, InpWriterEmitsTagsSection) { const fs::path tmp = fs::temp_directory_path() / "swmm_tags_roundtrip.inp"; ASSERT_EQ(swmm_model_write(engine, tmp.string().c_str()), SWMM_OK); - std::ifstream in(tmp); - ASSERT_TRUE(in.good()); - std::string body((std::istreambuf_iterator(in)), - std::istreambuf_iterator()); + // Read into a string in an inner scope so the ifstream's handle is + // released before fs::remove. Windows refuses to delete a file while + // any process holds an open handle to it (POSIX unlink-on-open does + // not apply); on Linux/macOS the inner scope is harmless. + std::string body; + { + std::ifstream in(tmp); + ASSERT_TRUE(in.good()); + body.assign((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + } EXPECT_NE(body.find("[TAGS]"), std::string::npos); EXPECT_NE(body.find("tagN1"), std::string::npos); @@ -140,10 +147,15 @@ TEST_F(TagsTest, NoTagsSectionWhenAllEmpty) { const fs::path tmp = fs::temp_directory_path() / "swmm_tags_empty.inp"; ASSERT_EQ(swmm_model_write(engine, tmp.string().c_str()), SWMM_OK); - std::ifstream in(tmp); - ASSERT_TRUE(in.good()); - std::string body((std::istreambuf_iterator(in)), - std::istreambuf_iterator()); + // Inner scope: see InpWriterEmitsTagsSection for the rationale — + // Windows fs::remove fails if the ifstream still holds the handle. + std::string body; + { + std::ifstream in(tmp); + ASSERT_TRUE(in.good()); + body.assign((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + } EXPECT_EQ(body.find("[TAGS]"), std::string::npos); fs::remove(tmp); From d4239c43378722772cda3a5e3a18f7c866fc82fd Mon Sep 17 00:00:00 2001 From: cbuahin Date: Mon, 25 May 2026 06:23:34 -0400 Subject: [PATCH 15/26] Tests: close Windows fs::remove landmine in report_section + add missing pattern test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_report_section.cpp: DisabledWritesSummaryOk had the same Windows bug as TagsTest — std::ifstream still in scope when std::remove ran. Fixed by scoping the ifstream into an inner block so its handle is released before remove. Same pattern as commit e6956ebc. - tests/unit/engine/CMakeLists.txt + test_pattern_mutation_api.cpp: the CMakeLists entry was added without committing the .cpp source. Adding both together so CI can find the new test. --- tests/unit/engine/CMakeLists.txt | 1 + .../unit/engine/test_pattern_mutation_api.cpp | 233 ++++++++++++++++++ tests/unit/engine/test_report_section.cpp | 12 +- 3 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 tests/unit/engine/test_pattern_mutation_api.cpp diff --git a/tests/unit/engine/CMakeLists.txt b/tests/unit/engine/CMakeLists.txt index fc6d5fade..ac63d890d 100644 --- a/tests/unit/engine/CMakeLists.txt +++ b/tests/unit/engine/CMakeLists.txt @@ -107,6 +107,7 @@ add_gtest_unit(test_engine_quality_routing test_quality_routing.cpp) add_gtest_unit(test_engine_treatment test_treatment.cpp) add_gtest_unit(test_engine_rdii test_rdii.cpp) add_gtest_unit(test_engine_hydrograph_mutation test_hydrograph_mutation_api.cpp) +add_gtest_unit(test_engine_pattern_mutation test_pattern_mutation_api.cpp) add_gtest_unit(test_engine_da4_api test_da4_engine_api.cpp) add_gtest_unit(test_engine_controls test_controls.cpp) add_gtest_unit(test_engine_gap_fixes test_gap_fixes.cpp) diff --git a/tests/unit/engine/test_pattern_mutation_api.cpp b/tests/unit/engine/test_pattern_mutation_api.cpp new file mode 100644 index 000000000..bc0c03a90 --- /dev/null +++ b/tests/unit/engine/test_pattern_mutation_api.cpp @@ -0,0 +1,233 @@ +/** + * @file test_pattern_mutation_api.cpp + * @brief BR-PAT — Unit tests for the pattern mutation surface used by the + * GUI's PatternEditorDialog CRUD path. + * + * @details Covers the BR-PAT C API additions to ::openswmm_tables.h: + * - swmm_pattern_remove (cascades to ref sites, idempotent on + * stale index, preserves order of remaining patterns) + * - swmm_pattern_rename (rewrites every stored reference, rejects + * collisions, no-ops on same-name) + * + * @see include/openswmm/engine/openswmm_tables.h + */ + +#include + +#include +#include + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Fixture — bare engine + two junctions for DWF / inflow attachments. +// --------------------------------------------------------------------------- + +class PatternMutationTest : public ::testing::Test { +protected: + SWMM_Engine engine = nullptr; + int j1_idx = -1; + int j2_idx = -1; + + void SetUp() override { + engine = swmm_engine_new(); + ASSERT_NE(engine, nullptr); + ASSERT_EQ(swmm_node_add(engine, "J1", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_node_add(engine, "J2", SWMM_NODE_JUNCTION), SWMM_OK); + j1_idx = swmm_node_index(engine, "J1"); + j2_idx = swmm_node_index(engine, "J2"); + ASSERT_GE(j1_idx, 0); + ASSERT_GE(j2_idx, 0); + } + + void TearDown() override { if (engine) swmm_engine_destroy(engine); } + + // Convenience: add a pattern with N factors all equal to value. + void addPattern(const char* id, int type, int n, double value) { + ASSERT_EQ(swmm_pattern_add(engine, id, type), SWMM_OK); + const int idx = swmm_pattern_index(engine, id); + ASSERT_GE(idx, 0); + std::vector facs(n, value); + ASSERT_EQ(swmm_pattern_set_factors(engine, idx, facs.data(), + static_cast(facs.size())), + SWMM_OK); + } + + // Read a DWF pat-N field (N in {1,2,3,4}) for the entry at idx. + std::string getDwfPat(int entry, int which) { + char p1[64] = {}, p2[64] = {}, p3[64] = {}, p4[64] = {}, c[64] = {}; + int n = -1; double avg = 0; + EXPECT_EQ(swmm_dwf_get(engine, entry, &n, + c, sizeof(c), &avg, + p1, sizeof(p1), p2, sizeof(p2), + p3, sizeof(p3), p4, sizeof(p4)), SWMM_OK); + switch (which) { + case 1: return p1; + case 2: return p2; + case 3: return p3; + case 4: return p4; + default: return {}; + } + } + + std::string getInflowPattern(int entry) { + int n = -1; char c[64] = {}, ts[64] = {}, tp[64] = {}, pat[64] = {}; + double mf = 0, sf = 0, base = 0; + EXPECT_EQ(swmm_ext_inflow_get(engine, entry, &n, + c, sizeof(c), ts, sizeof(ts), + tp, sizeof(tp), + &mf, &sf, &base, + pat, sizeof(pat)), SWMM_OK); + return pat; + } +}; + +// --------------------------------------------------------------------------- +// Case 1 — remove on a stale index is a SWMM_OK no-op (the GUI re-resolves +// indices lazily; a double-click on Delete must not crash or error). +// --------------------------------------------------------------------------- + +TEST_F(PatternMutationTest, RemoveOutOfRangeIsNoop) { + addPattern("P1", 0, 12, 1.0); + EXPECT_EQ(swmm_pattern_count(engine), 1); + + EXPECT_EQ(swmm_pattern_remove(engine, -1), SWMM_OK); + EXPECT_EQ(swmm_pattern_remove(engine, 99), SWMM_OK); + EXPECT_EQ(swmm_pattern_count(engine), 1); +} + +// --------------------------------------------------------------------------- +// Case 2 — remove shifts subsequent patterns down by one and preserves the +// remaining names / types / factors. +// --------------------------------------------------------------------------- + +TEST_F(PatternMutationTest, RemovePreservesOrderOfRemaining) { + addPattern("A", 0, 12, 1.0); + addPattern("B", 1, 7, 2.0); + addPattern("C", 2, 24, 3.0); + ASSERT_EQ(swmm_pattern_count(engine), 3); + + ASSERT_EQ(swmm_pattern_remove(engine, 1 /* "B" */), SWMM_OK); + EXPECT_EQ(swmm_pattern_count(engine), 2); + + EXPECT_STREQ(swmm_pattern_id(engine, 0), "A"); + EXPECT_STREQ(swmm_pattern_id(engine, 1), "C"); + + int t = -1; + EXPECT_EQ(swmm_pattern_get_type(engine, 0, &t), SWMM_OK); EXPECT_EQ(t, 0); + EXPECT_EQ(swmm_pattern_get_type(engine, 1, &t), SWMM_OK); EXPECT_EQ(t, 2); + + int fc = 0; + EXPECT_EQ(swmm_pattern_get_factor_count(engine, 0, &fc), SWMM_OK); EXPECT_EQ(fc, 12); + EXPECT_EQ(swmm_pattern_get_factor_count(engine, 1, &fc), SWMM_OK); EXPECT_EQ(fc, 24); +} + +// --------------------------------------------------------------------------- +// Case 3 — remove cascades to ext-inflow references that match the removed +// pattern by name; references to other patterns are untouched. +// --------------------------------------------------------------------------- + +TEST_F(PatternMutationTest, RemoveCascadesToExtInflows) { + addPattern("PA", 0, 12, 1.0); + addPattern("PB", 0, 12, 2.0); + + ASSERT_EQ(swmm_ext_inflow_add(engine, j1_idx, "FLOW", "", + "FLOW", 1.0, 1.0, 0.5, "PA"), SWMM_OK); + ASSERT_EQ(swmm_ext_inflow_add(engine, j2_idx, "FLOW", "", + "FLOW", 1.0, 1.0, 0.5, "PB"), SWMM_OK); + + ASSERT_EQ(swmm_pattern_remove(engine, swmm_pattern_index(engine, "PA")), SWMM_OK); + + EXPECT_EQ(getInflowPattern(0), ""); // cleared + EXPECT_EQ(getInflowPattern(1), "PB"); // untouched +} + +// --------------------------------------------------------------------------- +// Case 4 — remove cascades to DWF pattern slots (pat1..pat4) independently. +// --------------------------------------------------------------------------- + +TEST_F(PatternMutationTest, RemoveCascadesToDwfSlots) { + addPattern("M", 0, 12, 1.0); // monthly + addPattern("D", 1, 7, 1.0); // daily + addPattern("H", 2, 24, 1.0); // hourly + addPattern("W", 3, 24, 1.0); // weekend + + ASSERT_EQ(swmm_dwf_add(engine, j1_idx, "FLOW", 100.0, + "M", "D", "H", "W"), SWMM_OK); + + // Remove the daily pattern — only pat2 should clear. + ASSERT_EQ(swmm_pattern_remove(engine, swmm_pattern_index(engine, "D")), SWMM_OK); + + EXPECT_EQ(getDwfPat(0, 1), "M"); + EXPECT_EQ(getDwfPat(0, 2), ""); + EXPECT_EQ(getDwfPat(0, 3), "H"); + EXPECT_EQ(getDwfPat(0, 4), "W"); +} + +// --------------------------------------------------------------------------- +// Case 5 — rename empty / nullptr is rejected with SWMM_ERR_BADPARAM. +// --------------------------------------------------------------------------- + +TEST_F(PatternMutationTest, RenameRejectsEmptyOrNull) { + addPattern("X", 0, 12, 1.0); + + EXPECT_EQ(swmm_pattern_rename(engine, 0, nullptr), SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_pattern_rename(engine, 0, ""), SWMM_ERR_BADPARAM); + EXPECT_STREQ(swmm_pattern_id(engine, 0), "X"); +} + +// --------------------------------------------------------------------------- +// Case 6 — rename to an in-use name is rejected with SWMM_ERR_BADPARAM and +// both patterns keep their existing identifiers. +// --------------------------------------------------------------------------- + +TEST_F(PatternMutationTest, RenameCollisionRejected) { + addPattern("X", 0, 12, 1.0); + addPattern("Y", 0, 12, 2.0); + + EXPECT_EQ(swmm_pattern_rename(engine, 0, "Y"), SWMM_ERR_BADPARAM); + EXPECT_STREQ(swmm_pattern_id(engine, 0), "X"); + EXPECT_STREQ(swmm_pattern_id(engine, 1), "Y"); +} + +// --------------------------------------------------------------------------- +// Case 7 — rename to the same name is a SWMM_OK no-op. +// --------------------------------------------------------------------------- + +TEST_F(PatternMutationTest, RenameSameNameIsNoop) { + addPattern("X", 0, 12, 1.0); + EXPECT_EQ(swmm_pattern_rename(engine, 0, "X"), SWMM_OK); + EXPECT_STREQ(swmm_pattern_id(engine, 0), "X"); +} + +// --------------------------------------------------------------------------- +// Case 8 — rename rewrites every stored reference: ext-inflow + every DWF slot +// holding the previous name. References to other patterns are untouched. +// --------------------------------------------------------------------------- + +TEST_F(PatternMutationTest, RenameRipplesToAllReferenceSites) { + addPattern("OLD", 0, 12, 1.0); + addPattern("OTHER", 0, 12, 1.0); + + ASSERT_EQ(swmm_ext_inflow_add(engine, j1_idx, "FLOW", "", + "FLOW", 1.0, 1.0, 0.0, "OLD"), SWMM_OK); + ASSERT_EQ(swmm_dwf_add(engine, j1_idx, "FLOW", 100.0, + "OLD", "OTHER", "OLD", ""), SWMM_OK); + + ASSERT_EQ(swmm_pattern_rename(engine, swmm_pattern_index(engine, "OLD"), + "NEW"), SWMM_OK); + + EXPECT_STREQ(swmm_pattern_id(engine, 0), "NEW"); + EXPECT_EQ(swmm_pattern_index(engine, "OLD"), -1); + EXPECT_EQ(swmm_pattern_index(engine, "NEW"), 0); + + EXPECT_EQ(getInflowPattern(0), "NEW"); + EXPECT_EQ(getDwfPat(0, 1), "NEW"); + EXPECT_EQ(getDwfPat(0, 2), "OTHER"); + EXPECT_EQ(getDwfPat(0, 3), "NEW"); + EXPECT_EQ(getDwfPat(0, 4), ""); +} diff --git a/tests/unit/engine/test_report_section.cpp b/tests/unit/engine/test_report_section.cpp index 1b0688265..cb1ef2170 100644 --- a/tests/unit/engine/test_report_section.cpp +++ b/tests/unit/engine/test_report_section.cpp @@ -576,10 +576,16 @@ TEST(ReportPluginDisabledTest, DisabledWritesSummaryOk) { ASSERT_EQ(rp.finalize(ctx), 0); ASSERT_EQ(rp.write_summary(ctx), 0); - // Verify the rpt file is minimal (empty or very short — no full summaries) - std::ifstream f(rpt_path); - std::string content((std::istreambuf_iterator(f)), + // Verify the rpt file is minimal (empty or very short — no full summaries). + // Inner scope so the ifstream's handle is released before std::remove — + // Windows refuses to delete a file while any handle is open + // (unlike POSIX, where unlink-on-open succeeds). + std::string content; + { + std::ifstream f(rpt_path); + content.assign((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + } // With DISABLED=YES, continuity/flow stats/node/link/subcatch summaries skipped. // The file should not contain "Node Depth Summary" or similar. From e424e4794c9f8978d1720d6b003c32928d07b4ab Mon Sep 17 00:00:00 2001 From: cbuahin Date: Mon, 25 May 2026 06:45:13 -0400 Subject: [PATCH 16/26] Finalizing configuration Signed-off-by: cbuahin --- .github/workflows/codeql.yml | 11 +++++------ .github/workflows/deployment.yml | 2 +- .github/workflows/documentation.yml | 2 ++ .github/workflows/regression_testing.yml | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7b0898da7..ea4b27185 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,15 +2,14 @@ name: CodeQL on: push: - branches: [main, develop] + branches: [main] # drop develop — covered by PR + paths: ['src/**', 'include/**', 'python/**', 'CMakeLists.txt', 'vcpkg.json'] pull_request: branches: [main, develop] - schedule: - # Weekly scan so newly disclosed CWE queries hit dormant branches too. - # Mondays at 06:00 UTC — outside US-business hours but before EU-morning. - - cron: "0 6 * * 1" + paths: ['src/**', 'include/**', 'python/**', 'CMakeLists.txt', 'vcpkg.json'] + schedule: [{ cron: "0 6 * * 1" }] workflow_dispatch: - + env: VCPKG_ROOT: ${{ github.workspace }}/vcpkg VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 071206b7f..bd51d6642 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -2,7 +2,7 @@ name: Deployment on: push: - branches: [main, develop] + branches: [main] tags: ["v*.*.*"] pull_request: branches: [main, develop] diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index ba9beec28..5e6149349 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -4,8 +4,10 @@ on: push: branches: [main, develop] tags: ["v*.*.*"] + paths: ['docs/**', 'python/docs/**', 'python/openswmm/**', 'include/**', '**/*.md'] pull_request: branches: [main, develop] + paths: ['docs/**', 'python/docs/**', 'python/openswmm/**', 'include/**', '**/*.md'] workflow_dispatch: # Allow only one concurrent deployment. Do NOT cancel in-progress runs so diff --git a/.github/workflows/regression_testing.yml b/.github/workflows/regression_testing.yml index e73262dd8..55d4c7190 100644 --- a/.github/workflows/regression_testing.yml +++ b/.github/workflows/regression_testing.yml @@ -2,7 +2,7 @@ name: Regression Testing on: push: - branches: [main, develop] + branches: [main] pull_request: branches: [main, develop] From 904cd486cf660c8e3cc7ec3cbde56ca58f1244f5 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Mon, 25 May 2026 09:56:31 -0400 Subject: [PATCH 17/26] Update api for GUI Signed-off-by: cbuahin --- include/openswmm/engine/openswmm_controls.h | 39 ++ .../openswmm/engine/openswmm_infrastructure.h | 193 ++++++++ include/openswmm/engine/openswmm_links.h | 153 +++++++ include/openswmm/engine/openswmm_nodes.h | 102 +++++ include/openswmm/engine/openswmm_statistics.h | 36 ++ .../openswmm/engine/openswmm_subcatchments.h | 60 +++ python/docs/guide/concepts.rst | 15 + python/docs/guide/links.rst | 82 +++- python/docs/guide/nodes.rst | 53 ++- python/docs/guide/statistics.rst | 56 ++- python/docs/guide/subcatchments.rst | 61 ++- python/openswmm/engine/_2d.pxd | 14 +- python/openswmm/engine/_2d.pyi | 19 +- python/openswmm/engine/_2d.pyx | 62 ++- python/openswmm/engine/_common.pxd | 110 +++-- python/openswmm/engine/_gages.pyi | 3 +- python/openswmm/engine/_gages.pyx | 9 +- python/openswmm/engine/_geopackage.pyi | 9 +- python/openswmm/engine/_geopackage.pyx | 152 ++++--- python/openswmm/engine/_hotstart.pyi | 13 +- python/openswmm/engine/_hotstart.pyx | 40 +- python/openswmm/engine/_links.pyi | 121 ++++- python/openswmm/engine/_links.pyx | 266 ++++++++++- python/openswmm/engine/_nodes.pyi | 101 ++++- python/openswmm/engine/_nodes.pyx | 209 ++++++++- python/openswmm/engine/_output_reader.pyi | 12 +- python/openswmm/engine/_output_reader.pyx | 40 +- python/openswmm/engine/_solver.pyi | 9 +- python/openswmm/engine/_solver.pyx | 18 +- python/openswmm/engine/_statistics.pyi | 62 ++- python/openswmm/engine/_statistics.pyx | 120 ++++- python/openswmm/engine/_subcatchments.pyi | 67 ++- python/openswmm/engine/_subcatchments.pyx | 139 +++++- python/pyproject.toml | 3 + .../engine/test_concurrent_simulation.py | 224 +++++++++ python/tests/engine/test_geopackage.py | 59 +++ python/tests/engine/test_new_api.py | 429 ++++++++++++++++++ scripts/compare_results.py | 307 +++++++++++++ src/engine/core/openswmm_controls_impl.cpp | 35 ++ .../core/openswmm_infrastructure_impl.cpp | 200 ++++++++ src/engine/core/openswmm_links_impl.cpp | 158 +++++++ src/engine/core/openswmm_nodes_impl.cpp | 76 ++++ src/engine/core/openswmm_statistics_impl.cpp | 50 ++ .../core/openswmm_subcatchments_impl.cpp | 71 +++ src/engine/data/InfraData.hpp | 8 + tests/unit/engine/CMakeLists.txt | 7 + tests/unit/engine/test_links_bulk_phase3.cpp | 247 ++++++++++ .../engine/test_links_pump_stats_bulk.cpp | 278 ++++++++++++ tests/unit/engine/test_nodes_bulk_phase3.cpp | 238 ++++++++++ .../engine/test_statistics_bulk_phase3.cpp | 200 ++++++++ .../engine/test_subcatchments_bulk_phase3.cpp | 220 +++++++++ .../engine/test_transect_mutation_api.cpp | 345 ++++++++++++++ 52 files changed, 5385 insertions(+), 215 deletions(-) create mode 100644 python/tests/engine/test_concurrent_simulation.py create mode 100644 scripts/compare_results.py create mode 100644 tests/unit/engine/test_links_bulk_phase3.cpp create mode 100644 tests/unit/engine/test_links_pump_stats_bulk.cpp create mode 100644 tests/unit/engine/test_nodes_bulk_phase3.cpp create mode 100644 tests/unit/engine/test_statistics_bulk_phase3.cpp create mode 100644 tests/unit/engine/test_subcatchments_bulk_phase3.cpp create mode 100644 tests/unit/engine/test_transect_mutation_api.cpp diff --git a/include/openswmm/engine/openswmm_controls.h b/include/openswmm/engine/openswmm_controls.h index 8d00534e5..a8c1094aa 100644 --- a/include/openswmm/engine/openswmm_controls.h +++ b/include/openswmm/engine/openswmm_controls.h @@ -81,6 +81,45 @@ SWMM_ENGINE_API int swmm_control_get_id(SWMM_Engine engine, int idx, char* buf, */ SWMM_ENGINE_API int swmm_control_clear_rules(SWMM_Engine engine); +/** + * @brief Validate a control-rule text block without storing it. + * + * @details Runs the engine's control-rule parser against the engine's live + * @ref SimulationContext for name resolution (NODE / LINK / CURVE / + * TIMESERIES references), but **does not** mutate the engine's rule + * list or PID state. Designed for GUI-side live validation where + * the editor needs the production parser's accept/reject verdict + * on each keystroke without side effects. + * + * The validation contract matches @ref swmm_control_add_rule + * followed by a simulation-initialisation parse: identical input + * text yields identical accept/reject. Engine state across the + * call is invariant — `swmm_control_count`, `swmm_control_get_rule`, + * and the engine's internal `ControlEngine::rules()` vector are + * unchanged. + * + * On reject the function returns @ref SWMM_ERR_BADPARAM and writes + * a short human-readable message to @p errbuf (truncated to fit). + * Line-precise error reporting is not yet wired through the parser; + * @p line_out is set to `-1` on reject. Future work may carry a + * 1-based line index through the parser. + * + * @param engine Engine handle. + * @param rule_text Null-terminated rule text. Same grammar as the + * `[CONTROLS]` section. + * @param errbuf [out, optional] Buffer for the rejection message. May + * be NULL or zero-length to suppress message capture. + * @param buflen Size of @p errbuf in bytes. + * @param line_out [out, optional] 1-based line number of the rejection, + * or `-1` if not available. May be NULL. + * @returns SWMM_OK if the parser accepts the text, SWMM_ERR_BADPARAM if it + * rejects, or another error code on infrastructure failure. + */ +SWMM_ENGINE_API int swmm_control_validate_rule(SWMM_Engine engine, + const char* rule_text, + char* errbuf, int buflen, + int* line_out); + /* ========================================================================= * Direct control actions (without rules) * ========================================================================= */ diff --git a/include/openswmm/engine/openswmm_infrastructure.h b/include/openswmm/engine/openswmm_infrastructure.h index 54715beb5..5ccdfd587 100644 --- a/include/openswmm/engine/openswmm_infrastructure.h +++ b/include/openswmm/engine/openswmm_infrastructure.h @@ -82,6 +82,199 @@ SWMM_ENGINE_API int swmm_transect_index(SWMM_Engine engine, const char* id); */ SWMM_ENGINE_API const char* swmm_transect_id(SWMM_Engine engine, int idx); +/* ------------------------------------------------------------------------- + * Per-field getters / setters (DA-ENG-09 + BQ-TR-02) + * + * GUI editors (Slice BQ Phase 6.7.4 TransectEditor) need round-trip access + * to every transect field; the legacy 3-function surface (add / set_roughness + * / add_station) only covers a fraction. The functions below close that gap. + * ------------------------------------------------------------------------- */ + +/** + * @brief Get the Manning's roughness values for a transect. + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @param n_left [out] Manning's n for the left overbank. May be NULL. + * @param n_right [out] Manning's n for the right overbank. May be NULL. + * @param n_channel [out] Manning's n for the main channel. May be NULL. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_get_roughness(SWMM_Engine engine, int idx, + double* n_left, double* n_right, double* n_channel); + +/** + * @brief Set the left and right bank stations for a transect. + * + * @details Bank stations delimit the main channel from the overbanks; they + * are independent of the encroachment stations (BQ-TR-02). + * + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @param x_left Station of the left bank. + * @param x_right Station of the right bank. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_set_bank_stations(SWMM_Engine engine, int idx, + double x_left, double x_right); + +/** + * @brief Get the left and right bank stations for a transect. + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @param x_left [out] Station of the left bank. May be NULL. + * @param x_right [out] Station of the right bank. May be NULL. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_get_bank_stations(SWMM_Engine engine, int idx, + double* x_left, double* x_right); + +/** + * @brief Set the left and right encroachment stations for a transect (BQ-TR-02). + * + * @details Encroachment stations are distinct from bank stations and identify + * floodplain encroachment limits (HEC-RAS convention). On legacy + * `[TRANSECTS]` X1 records that omit the trailing encroachment + * columns, the INP parser may default these to the bank stations + * to preserve backward compatibility; callers writing programmatic + * values via this API are setting them explicitly. + * + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @param x_left Station of the left encroachment limit. + * @param x_right Station of the right encroachment limit. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_set_encroachment_stations(SWMM_Engine engine, int idx, + double x_left, double x_right); + +/** + * @brief Get the left and right encroachment stations for a transect (BQ-TR-02). + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @param x_left [out] Station of the left encroachment limit. May be NULL. + * @param x_right [out] Station of the right encroachment limit. May be NULL. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_get_encroachment_stations(SWMM_Engine engine, int idx, + double* x_left, double* x_right); + +/** + * @brief Set the station, elevation, and meander modifiers for a transect. + * + * @details Maps to the `xFactor`, `yFactor`, and `lengthFactor` parameters + * on the `[TRANSECTS]` X1 record: + * - `x_factor` = station-spacing multiplier (default 1.0). + * - `y_factor` = elevation offset added to every station + * elevation (default 0.0 — engine stores 1.0 + * as a no-op marker at `swmm_transect_add` time). + * - `length_factor` = meander factor = channel / floodplain length + * ratio (default 1.0). + * + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @param x_factor Station spacing multiplier. + * @param y_factor Elevation offset. + * @param length_factor Meander factor (channel/floodplain length ratio). + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_set_modifiers(SWMM_Engine engine, int idx, + double x_factor, double y_factor, double length_factor); + +/** + * @brief Get the station, elevation, and meander modifiers for a transect. + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @param x_factor [out] Station spacing multiplier. May be NULL. + * @param y_factor [out] Elevation offset. May be NULL. + * @param length_factor [out] Meander factor. May be NULL. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_get_modifiers(SWMM_Engine engine, int idx, + double* x_factor, double* y_factor, double* length_factor); + +/** + * @brief Set the free-form comments / description for a transect. + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @param text Null-terminated comment string. NULL clears the comment. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_set_comments(SWMM_Engine engine, int idx, const char* text); + +/** + * @brief Get the free-form comments / description for a transect. + * + * @details Writes the comment into @p buf with NUL termination; truncates + * if the buffer is smaller than the comment. Always returns SWMM_OK + * (an empty comment results in @p buf[0] == '\0'). + * + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @param buf Destination buffer. + * @param buflen Size of @p buf in bytes (must be > 0). + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_get_comments(SWMM_Engine engine, int idx, char* buf, int buflen); + +/** + * @brief Get the number of station–elevation points stored for a transect. + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @returns Station count, or -1 on error. + */ +SWMM_ENGINE_API int swmm_transect_get_station_count(SWMM_Engine engine, int idx); + +/** + * @brief Get a single station–elevation pair from a transect. + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @param station_idx Zero-based station-pair index. + * @param station [out] Horizontal distance. May be NULL. + * @param elevation [out] Elevation at this station. May be NULL. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_get_station(SWMM_Engine engine, int idx, int station_idx, + double* station, double* elevation); + +/** + * @brief Remove all station–elevation pairs from a transect. + * + * @details Used by the GUI's snapshot-and-rewrite path: clear then re-add + * the full station list in one shot. + * + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_clear_stations(SWMM_Engine engine, int idx); + +/** + * @brief Rename an existing transect. + * + * @details Refuses on collision with another existing transect name (case + * insensitive); same-name is a no-op SWMM_OK. + * + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @param new_id New null-terminated identifier. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_rename(SWMM_Engine engine, int idx, const char* new_id); + +/** + * @brief Remove a transect by index. + * + * @details Out-of-range indices are a SWMM_OK no-op (mirrors the pattern + * mutation API — see test_pattern_mutation_api.cpp). Remaining + * transects preserve their relative order; their indices shift + * down by one. + * + * @param engine Engine handle. + * @param idx Zero-based transect index. + * @returns SWMM_OK on success, or an error code. + */ +SWMM_ENGINE_API int swmm_transect_remove(SWMM_Engine engine, int idx); + /* ========================================================================= * Streets * ========================================================================= */ diff --git a/include/openswmm/engine/openswmm_links.h b/include/openswmm/engine/openswmm_links.h index bf9a29bda..65e264707 100644 --- a/include/openswmm/engine/openswmm_links.h +++ b/include/openswmm/engine/openswmm_links.h @@ -736,6 +736,97 @@ SWMM_ENGINE_API int swmm_link_set_flows_bulk(SWMM_Engine engine, const double* b SWMM_ENGINE_API int swmm_link_get_quality_bulk(SWMM_Engine engine, int pollutant_idx, double* buf, int count); +/* ========================================================================= + * Phase 3 bulk getters — added in OpenSWMM 6.0.0 to eliminate the N + * round-trip cost of per-link scalar accessors in whole-network consumers + * (notably the MCP server's get_link_info(all) path and post-run reports). + * + * Note: velocities, capacities, and hydraulic powers are *derived* values + * (depth/flow ratios; flow * head loss). Their bulk variants do a per-link + * loop in C — there is no SoA column to memcpy from — but they still + * eliminate the C ABI crossing overhead and any Python-level looping cost. + * ========================================================================= */ + +/** + * @brief Get cross-sectional velocities for all links in a single call. + * @details Bulk variant of @ref swmm_link_get_velocity. The C side + * recomputes @c q / area per link (area approximated from + * @c d / y_full * a_full), so this is a per-link loop rather + * than a memcpy — but still O(n_links) and free of per-call ABI + * overhead. + * @param engine Engine handle. + * @param[out] buf Caller-allocated buffer of at least @p count doubles. + * @param count Number of elements (should equal swmm_link_count()). + * @returns @c SWMM_OK on success, or an error code. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_link_get_velocities_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get capacity ratios (q/q_full) for all links in a single call. + * @details Bulk variant of @ref swmm_link_get_capacity. Per-link loop + * (capacity is derived from flow / full-flow). + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_link_get_capacities_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get stored volumes for all links in a single call. + * @details Bulk variant of @ref swmm_link_get_volume. Simple SoA memcpy. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_link_get_volumes_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get active control settings (0..1) for all links in a single call. + * @details Bulk variant of @ref swmm_link_get_control_setting. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_link_get_control_settings_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get target control settings for all links in a single call. + * @details Bulk variant of @ref swmm_link_get_target_setting. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_link_get_target_settings_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get hydraulic power dissipated in every link in a single call. + * @details Bulk variant of @ref swmm_link_get_hyd_power. Per-link loop: + * @c P = gamma * |Q| * |h_up - h_dn| (ft-lb/s); non-conduit + * links produce the same expression with whatever flow they + * report. Use cycles[i] from @ref swmm_link_get_pump_stats_bulk + * to filter to pumps if needed. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_link_get_hyd_powers_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get link IDs for all links in a single call (stride-packed UTF-8). + * + * @details Stride-packed format matching @ref swmm_node_get_ids_bulk: each + * ID is written into the slot @c buf[i*stride .. i*stride+stride-1] + * and NUL-terminated within its slot (truncated to @c stride-1 + * bytes if longer). The function zero-fills the requested region + * on entry so trailing bytes are always NUL. + * + * @param engine Engine handle. + * @param[out] buf Caller-allocated buffer of @c stride*count bytes. + * @param stride Per-ID slot size in bytes (must be > 1). + * @param count Number of IDs to read. + * @returns @c SWMM_OK on success; @c SWMM_ERR_BADHANDLE if @p engine is + * invalid; @c SWMM_ERR_BADPARAM if @p buf is NULL, + * @p stride < 2, or @p count <= 0. + * + * @see swmm_link_id, swmm_node_get_ids_bulk + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_link_get_ids_bulk(SWMM_Engine engine, + char* buf, + int stride, + int count); + /* ========================================================================= * Pump utilization statistics * ========================================================================= */ @@ -749,6 +840,68 @@ SWMM_ENGINE_API int swmm_link_get_stat_pump_on_time(SWMM_Engine engine, int idx, /** @brief Get pump total volume pumped (ft3). */ SWMM_ENGINE_API int swmm_link_get_stat_pump_volume(SWMM_Engine engine, int idx, double* volume); +/** + * @brief Get pump utilization statistics for **all** links in a single call. + * + * @details Single-pass bulk accessor that avoids @c N round-trips through the + * C ABI when caller needs pump stats across the network (e.g. when + * building a network-wide pump summary report). For links whose type + * is not @c LinkType::PUMP, the corresponding @p cycles entry is set + * to @c -1 and the @p on_time / @p volume entries to @c 0.0 — this + * allows the caller to distinguish "non-pump" from "pump with zero + * cycles". + * + * Any of @p cycles, @p on_time, @p volume may be @c NULL if the + * caller does not need that output; the function still iterates the + * full link array (the cost is identical) but skips the store. + * + * @param engine Engine handle (must be in INITIALIZED state or later + * so the statistics vectors are sized). + * @param[out] cycles Caller-allocated @c int buffer of at least @p count + * entries, or @c NULL. Non-pump links get @c -1. + * @param[out] on_time Caller-allocated @c double buffer of at least @p count + * entries (seconds), or @c NULL. + * @param[out] volume Caller-allocated @c double buffer of at least @p count + * entries (ft3), or @c NULL. + * @param count Length of the caller-allocated buffers. If smaller + * than the link count, only the first @c min(count, + * n_links) entries are written. + * + * @returns @c SWMM_OK on success; @c SWMM_ERR_BADHANDLE if @p engine is + * invalid; @c SWMM_ERR_BADPARAM if @p count is non-positive or all + * three output pointers are NULL. + * + * @par Example + * @code{.c} + * int n = swmm_link_count(eng); + * int* cycles = malloc(n * sizeof(int)); + * double* on_time = malloc(n * sizeof(double)); + * double* volume = malloc(n * sizeof(double)); + * swmm_link_get_pump_stats_bulk(eng, cycles, on_time, volume, n); + * for (int i = 0; i < n; ++i) { + * if (cycles[i] < 0) continue; // not a pump + * printf("link %d: %d cycles, %.1f s, %.2f ft3\n", + * i, cycles[i], on_time[i], volume[i]); + * } + * @endcode + * + * @note Equivalent to calling @ref swmm_link_get_stat_pump_cycles, + * @ref swmm_link_get_stat_pump_on_time, and + * @ref swmm_link_get_stat_pump_volume for every link, but with one C + * ABI crossing instead of @c 3N. + * + * @see swmm_link_get_stat_pump_cycles + * @see swmm_link_get_stat_pump_on_time + * @see swmm_link_get_stat_pump_volume + * + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_link_get_pump_stats_bulk(SWMM_Engine engine, + int* cycles, + double* on_time, + double* volume, + int count); + /* ========================================================================= * Hydraulic power * ========================================================================= */ diff --git a/include/openswmm/engine/openswmm_nodes.h b/include/openswmm/engine/openswmm_nodes.h index 338758d0d..00d3a1383 100644 --- a/include/openswmm/engine/openswmm_nodes.h +++ b/include/openswmm/engine/openswmm_nodes.h @@ -760,6 +760,108 @@ SWMM_ENGINE_API int swmm_node_set_lat_inflows_bulk(SWMM_Engine engine, const dou SWMM_ENGINE_API int swmm_node_get_quality_bulk(SWMM_Engine engine, int pollutant_idx, double* buf, int count); +/** + * @brief Get current stored volumes for all nodes in a single call. + * + * @details Single-pass bulk variant of @ref swmm_node_get_volume — avoids + * @c N round-trips through the C ABI for whole-network reads. + * Used by the MCP server's per-node info builders and the + * `mass_balance` resource. + * + * @param engine Engine handle. + * @param[out] buf Caller-allocated buffer of at least @p count doubles. + * @param count Number of elements. If smaller than @c swmm_node_count() + * only the first @c min(count, n_nodes) entries are written. + * @returns @c SWMM_OK on success; @c SWMM_ERR_BADHANDLE if @p engine is + * invalid; @c SWMM_ERR_BADPARAM if @p buf is NULL or @p count <= 0. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_node_get_volumes_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get current outflows for all nodes in a single call. + * + * @details Single-pass bulk variant of @ref swmm_node_get_outflow. + * + * @param engine Engine handle. + * @param[out] buf Caller-allocated buffer of at least @p count doubles. + * @param count Number of elements. + * @returns @c SWMM_OK on success, or an error code (see @ref swmm_node_get_volumes_bulk). + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_node_get_outflows_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get accumulated node losses (exfil + evap) for all nodes in one call. + * + * @details Single-pass bulk variant of @ref swmm_node_get_losses. + * + * @param engine Engine handle. + * @param[out] buf Caller-allocated buffer of at least @p count doubles. + * @param count Number of elements. + * @returns @c SWMM_OK on success, or an error code. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_node_get_losses_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get current lateral inflows for all nodes in a single call. + * + * @details Single-pass bulk variant of @ref swmm_node_get_lateral_inflow. + * The matching setter is @ref swmm_node_set_lat_inflows_bulk. + * + * @param engine Engine handle. + * @param[out] buf Caller-allocated buffer of at least @p count doubles. + * @param count Number of elements. + * @returns @c SWMM_OK on success, or an error code. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_node_get_lateral_inflows_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get node IDs for all nodes in a single call (stride-packed UTF-8). + * + * @details Each ID is written into a fixed-size slot @c buf[i*stride .. i*stride+stride-1]. + * The ID is NUL-terminated within its slot; if the ID is longer than + * @c stride-1 bytes it is truncated and still NUL-terminated. The + * caller can recover each ID via @c strlen(buf + i*stride) (or + * equivalent UTF-8-safe slicing). + * + * This is the Phase 3 alternative to looping @ref swmm_node_id @c N + * times through the C ABI. A typical stride for SWMM node IDs is + * 32–64 bytes (SWMM IDs are limited to MAX_ID_CHARS = 31 in legacy); + * callers should choose a stride that comfortably accommodates the + * longest ID in their model. + * + * @param engine Engine handle. + * @param[out] buf Caller-allocated buffer of @c stride*count bytes. + * @param stride Per-ID slot size in bytes (must be > 1 to allow at least + * one character plus the NUL). + * @param count Number of IDs to read. + * @returns @c SWMM_OK on success; @c SWMM_ERR_BADHANDLE if @p engine is + * invalid; @c SWMM_ERR_BADPARAM if @p buf is NULL, @p stride < 2, + * or @p count <= 0. + * + * @par Example + * @code{.c} + * int n = swmm_node_count(eng); + * int stride = 64; + * char* buf = calloc(n, stride); + * swmm_node_get_ids_bulk(eng, buf, stride, n); + * for (int i = 0; i < n; ++i) { + * const char* id = buf + i * stride; + * printf("node %d: %s\n", i, id); + * } + * @endcode + * + * @see swmm_node_id + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_node_get_ids_bulk(SWMM_Engine engine, + char* buf, + int stride, + int count); + /* ========================================================================= * Outfall-to-subcatchment routing * ========================================================================= */ diff --git a/include/openswmm/engine/openswmm_statistics.h b/include/openswmm/engine/openswmm_statistics.h index 9ea6b1657..a859aeb49 100644 --- a/include/openswmm/engine/openswmm_statistics.h +++ b/include/openswmm/engine/openswmm_statistics.h @@ -83,6 +83,42 @@ SWMM_ENGINE_API int swmm_stat_link_max_flow_bulk(SWMM_Engine engine, double* buf /** @brief Get total runoff volume for all subcatchments into a caller-supplied buffer. */ SWMM_ENGINE_API int swmm_stat_subcatch_runoff_vol_bulk(SWMM_Engine engine, double* buf, int count); +/* ------------------------------------------------------------------------- + * Phase 3 statistics bulk getters — added in OpenSWMM 6.0.0 to power the + * MCP server's flooding / capacity summary tools without a per-node + * Python loop. Each is a simple SoA memcpy from the corresponding + * scalar accessor's column. + * ------------------------------------------------------------------------- */ + +/** + * @brief Get maximum overflow rate for all nodes into a caller-supplied buffer. + * @details Bulk variant of @ref swmm_stat_node_max_overflow. SoA copy of + * @c ctx.nodes.stat_max_overflow. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_stat_node_max_overflow_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get total flooded volume for all nodes into a caller-supplied buffer. + * @details Bulk variant of @ref swmm_stat_node_vol_flooded. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_stat_node_vol_flooded_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get cumulative time-flooded for all nodes into a caller-supplied buffer. + * @details Bulk variant of @ref swmm_stat_node_time_flooded. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_stat_node_time_flooded_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get peak runoff rate for all subcatchments into a caller-supplied buffer. + * @details Bulk variant of @ref swmm_stat_subcatch_max_runoff. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_stat_subcatch_max_runoff_bulk(SWMM_Engine engine, double* buf, int count); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/include/openswmm/engine/openswmm_subcatchments.h b/include/openswmm/engine/openswmm_subcatchments.h index 44d78866b..94346ecaf 100644 --- a/include/openswmm/engine/openswmm_subcatchments.h +++ b/include/openswmm/engine/openswmm_subcatchments.h @@ -515,6 +515,66 @@ SWMM_ENGINE_API int swmm_subcatch_get_runoff_bulk(SWMM_Engine engine, double* bu SWMM_ENGINE_API int swmm_subcatch_get_quality_bulk(SWMM_Engine engine, int pollutant_idx, double* buf, int count); +/* ========================================================================= + * Phase 3 bulk getters — added in OpenSWMM 6.0.0 to eliminate the N + * round-trip cost of per-subcatchment scalar accessors. All return a + * caller-allocated @c double buffer of length @c count (clipped at + * @c swmm_subcatch_count()); the IDs variant returns a stride-packed + * UTF-8 buffer following the same format as @ref swmm_node_get_ids_bulk. + * ========================================================================= */ + +/** + * @brief Get rainfall rates for all subcatchments in a single call. + * @details Bulk variant of @ref swmm_subcatch_get_rainfall. Simple SoA copy. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_subcatch_get_rainfall_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get evaporation losses for all subcatchments in a single call. + * @details Bulk variant of @ref swmm_subcatch_get_evap. Simple SoA copy of + * the @c evap_loss column. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_subcatch_get_evap_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get infiltration losses for all subcatchments in a single call. + * @details Bulk variant of @ref swmm_subcatch_get_infil. Simple SoA copy + * of the @c infil_loss column. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_subcatch_get_infil_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get snow depths for all subcatchments in a single call. + * @details Bulk variant of @ref swmm_subcatch_get_snow_depth. Snow state + * currently lives in the SnowSolver, not SubcatchData; like the + * scalar accessor this returns zeros for every entry pending + * full snow-state integration (see plan Appendix A). + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_subcatch_get_snow_depth_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get subcatchment IDs for all subcatchments in a single call + * (stride-packed UTF-8). + * @details Format identical to @ref swmm_node_get_ids_bulk. + * @param engine Engine handle. + * @param[out] buf Caller-allocated buffer of @c stride*count bytes. + * @param stride Per-ID slot size in bytes (must be > 1). + * @param count Number of IDs to read. + * @returns @c SWMM_OK on success; @c SWMM_ERR_BADHANDLE if @p engine is + * invalid; @c SWMM_ERR_BADPARAM if @p buf is NULL, + * @p stride < 2, or @p count <= 0. + * @see swmm_subcatch_id, swmm_node_get_ids_bulk + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_subcatch_get_ids_bulk(SWMM_Engine engine, + char* buf, + int stride, + int count); + /* ========================================================================= * Ponded quality (mass in standing water between events) * ========================================================================= */ diff --git a/python/docs/guide/concepts.rst b/python/docs/guide/concepts.rst index 734945bc5..eac58f1bf 100644 --- a/python/docs/guide/concepts.rst +++ b/python/docs/guide/concepts.rst @@ -256,6 +256,21 @@ Threading & multiprocessing * **Multiple threads, one Solver per thread**: supported. The C engine is reentrant; two threads each holding their own Solver do not interact. + + As of OpenSWMM 6.0, the following Cython entry points release the + GIL while inside the C engine, so two such threads execute their + C work truly in parallel rather than serialising on the interpreter: + + * :meth:`Solver.step`, :meth:`Solver.stride` + * Every ``*_bulk`` getter / setter on :class:`Nodes`, + :class:`Links`, and :class:`Subcatchments` + (:meth:`get_depths_bulk`, :meth:`get_flows_bulk`, + :meth:`get_quality_bulk`, etc.) + + Registered step-begin / step-end callbacks remain safe: their + Cython trampolines reacquire the GIL via ``noexcept with gil:`` + before invoking the user's Python callable. + * **Multiple threads, one shared Solver**: **not** supported. The Solver and its domain classes assume a single-threaded caller. If you need shared state, drive a single Solver from one thread and diff --git a/python/docs/guide/links.rst b/python/docs/guide/links.rst index 105aad08b..022a65059 100644 --- a/python/docs/guide/links.rst +++ b/python/docs/guide/links.rst @@ -138,6 +138,41 @@ Pump-specific * - :meth:`get_pump_init_state` / :meth:`set_pump_init_state` - Initial on/off state. +Pump utilization statistics +--------------------------- + +Accumulated during a running simulation. Read either per-link with the +scalar accessors, or — for whole-network summaries — in one shot with +:meth:`get_pump_stats_bulk`, which performs a single C ABI crossing +instead of ``3 * n_links`` and releases the GIL during the call. + +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Method + - Returns + * - :meth:`get_stat_pump_cycles(idx)` + - Pump on/off cycle count (``int``). + * - :meth:`get_stat_pump_on_time(idx)` + - Total pump on-time in seconds (``float``). + * - :meth:`get_stat_pump_volume(idx)` + - Total volume pumped in ft³ (``float``). + * - :meth:`get_pump_stats_bulk()` + - ``dict`` of three ``np.ndarray`` columns — ``cycles`` + (``int32``), ``on_time`` (``float64``), ``volume`` (``float64``); + non-pump links carry ``cycles == -1``. + +.. code-block:: python + + # Whole-network pump summary in one call. + stats = links.get_pump_stats_bulk() + pumps = stats["cycles"] >= 0 # filter to pump links + total_pumped_ft3 = stats["volume"][pumps].sum() + total_run_time_h = stats["on_time"][pumps].sum() / 3600.0 + print(f"{pumps.sum()} pumps, {total_pumped_ft3:.1f} ft³ pumped, " + f"{total_run_time_h:.1f} pump-hours") + Weir / orifice -------------- @@ -301,10 +336,51 @@ Bulk arrays - All link mid-point depths. * - :meth:`get_quality_bulk(p)` - Pollutant ``p`` concentration per link. + * - :meth:`get_pump_stats_bulk` + - All pump statistics in a single C call — + ``{"cycles": ndarray[int32], "on_time": ndarray[float64], + "volume": ndarray[float64]}``. Non-pump links carry + ``cycles == -1`` as a sentinel. + * - :meth:`get_velocities_bulk` + - Cross-sectional velocity per link *(added 6.0.0)*. + * - :meth:`get_capacities_bulk` + - Capacity ratio ``q / q_full`` per link *(added 6.0.0)*. + * - :meth:`get_volumes_bulk` + - Stored volume per link *(added 6.0.0)*. + * - :meth:`get_control_settings_bulk` + - Active control setting (0..1) per link *(added 6.0.0)*. + * - :meth:`get_target_settings_bulk` + - Target control setting per link *(added 6.0.0)*. + * - :meth:`get_hyd_powers_bulk` + - Hydraulic power per link in ft-lb/s + (``gamma * |Q| * |hL|``) *(added 6.0.0)*. + * - :meth:`get_ids_bulk` + - ``list[str]`` of every link's id in one C call + *(added 6.0.0; stride-packed UTF-8)*. + +Memory-aliasing note: ``get_flows_bulk`` and ``get_depths_bulk`` return +arrays that share memory with engine scratch space — ``.copy()`` if you +keep them past the next call. The other bulk getters (``get_pump_stats_bulk`` +and every Phase 3 addition) return freshly allocated arrays, so the +result is yours to retain. + +Whole-network reporting pattern (mirrors the Nodes example): + +.. code-block:: python -Same memory-aliasing rule as :class:`Nodes`: the returned array shares -memory with engine scratch space; ``.copy()`` if you keep it past the -next call. + ids = links.get_ids_bulk() # list[str] + flows = links.get_flows_bulk() # np.ndarray[float64] + velocities = links.get_velocities_bulk() + capacities = links.get_capacities_bulk() + powers = links.get_hyd_powers_bulk() + stats = links.get_pump_stats_bulk() + pumps = stats["cycles"] >= 0 # boolean mask + + # Pump-energy summary in 3 lines: + pump_run_hours = float(stats["on_time"][pumps].sum() / 3600.0) + pump_ft_lb_s = float(powers[pumps].sum()) + pump_hp = pump_ft_lb_s / 550.0 + print(f"{pumps.sum()} pumps ran {pump_run_hours:.1f} h ~{pump_hp:.1f} hp avg") Vectorised peak detection across all links: diff --git a/python/docs/guide/nodes.rst b/python/docs/guide/nodes.rst index 5f224e4d4..92c1e148d 100644 --- a/python/docs/guide/nodes.rst +++ b/python/docs/guide/nodes.rst @@ -316,7 +316,7 @@ want vectorised. Each returns or accepts a contiguous .. list-table:: :header-rows: 1 - :widths: 35 65 + :widths: 40 60 * - Method - Returns / accepts @@ -327,13 +327,33 @@ want vectorised. Each returns or accepts a contiguous * - :meth:`set_depths_bulk(arr)` - Force depths for every node from ``arr``. * - :meth:`get_inflows_bulk` - - Total inflow per node. + - **Lateral** inflow per node (see naming note below). * - :meth:`get_overflows_bulk` - Overflow (flooding) per node. * - :meth:`set_lat_inflows_bulk(arr)` - Lateral inflow per node. * - :meth:`get_quality_bulk(p)` - Concentration of pollutant ``p`` per node. + * - :meth:`get_volumes_bulk` + - Stored volume per node *(added 6.0.0)*. + * - :meth:`get_outflows_bulk` + - Total outflow per node *(added 6.0.0)*. + * - :meth:`get_losses_bulk` + - Per-node losses (evaporation + seepage) *(added 6.0.0)*. + * - :meth:`get_lateral_inflows_bulk` + - Lateral inflow per node — explicitly named successor to + :meth:`get_inflows_bulk` *(added 6.0.0)*. + * - :meth:`get_ids_bulk` + - ``list[str]`` of every node's id in one C call + *(added 6.0.0; stride-packed UTF-8)*. + +.. note:: + + Naming caveat: :meth:`get_inflows_bulk` reads the *lateral* inflow + column on the C side despite its generic name; it is retained for + backward compatibility, but new code should prefer + :meth:`get_lateral_inflows_bulk`. Both methods return the same + values. Memory-aliasing rule: the array returned by a ``get_*_bulk`` method shares memory with an internal scratch buffer that the engine reuses @@ -351,6 +371,35 @@ keep the array (e.g. across a step), call ``.copy()``: history.append(nodes.get_depths_bulk().copy()) # detach from scratch H = np.stack(history) # shape (T, n_nodes) +Whole-network reporting with the Phase 3 bulk accessors: + +.. code-block:: python + + # Single C call per quantity — replaces N round-trips through + # the scalar getters. Useful when building post-run summaries, + # MCP / GUI dataframes, or input to a downstream tool. + ids = nodes.get_ids_bulk() # list[str] + depths = nodes.get_depths_bulk() # np.ndarray[float64] + volumes = nodes.get_volumes_bulk() + outflows = nodes.get_outflows_bulk() + losses = nodes.get_losses_bulk() + lat = nodes.get_lateral_inflows_bulk() + + # Build a single DataFrame-shaped dict in one shot: + summary = { + "id": ids, + "depth": depths, + "volume": volumes, + "outflow": outflows, + "losses": losses, + "lateral_inflow": lat, + } + + # Find the most-flooded outfall, e.g.: + flooded = nodes.get_overflows_bulk() + worst = int(flooded.argmax()) + print(f"max overflow at {ids[worst]}: {flooded[worst]:.3f}") + ---- EngineState requirements & exceptions diff --git a/python/docs/guide/statistics.rst b/python/docs/guide/statistics.rst index adb65954e..29943be43 100644 --- a/python/docs/guide/statistics.rst +++ b/python/docs/guide/statistics.rst @@ -99,6 +99,10 @@ Per-subcatchment Bulk variants ------------- +Each bulk method makes a single C call and returns a contiguous +``np.ndarray[float64]``. The GIL is released for the duration of the +C call. + .. list-table:: :header-rows: 1 :widths: 50 50 @@ -111,9 +115,38 @@ Bulk variants - All-link peak flows. * - :meth:`subcatch_runoff_vol_bulk()` - All-subcatchment runoff volumes. + * - :meth:`node_max_overflow_bulk()` + - All-node peak overflow rate *(added 6.0.0)*. + * - :meth:`node_vol_flooded_bulk()` + - All-node total flooded volume *(added 6.0.0)*. + * - :meth:`node_time_flooded_bulk()` + - All-node cumulative time-flooded *(added 6.0.0)*. + * - :meth:`subcatch_max_runoff_bulk()` + - All-subcatchment peak runoff *(added 6.0.0)*. + +Whole-network flooding summary in 4 C calls (replaces a ``4 * n_nodes`` +Python loop): + +.. code-block:: python + + from openswmm.engine import Nodes, Statistics + + stats = Statistics(solver) + nodes = Nodes(solver) + + ids = nodes.get_ids_bulk() + max_over = stats.node_max_overflow_bulk() + vol_flood = stats.node_vol_flooded_bulk() + t_flood = stats.node_time_flooded_bulk() + + # Worst-flooded node by cumulative volume: + worst = int(vol_flood.argmax()) + print(f"{ids[worst]}: vol={vol_flood[worst]:.1f}, " + f"peak overflow={max_over[worst]:.3f}, hours={t_flood[worst]:.2f}") The standard memory-aliasing rule applies — ``.copy()`` if you need -to keep the array. +to keep the array (the four pre-6.0 bulk getters share scratch +buffers; the 6.0 additions return freshly allocated arrays). ---- @@ -214,7 +247,9 @@ Compute surcharge fraction across the network Bulk arrays =========== -The ``*_bulk`` family is the vectorised path: +The ``*_bulk`` family is the vectorised path. Each call returns a +contiguous ``np.ndarray[float64]`` of the indicated shape. GIL is +released during the underlying C call. .. list-table:: :header-rows: 1 @@ -228,10 +263,19 @@ The ``*_bulk`` family is the vectorised path: - ``(n_links,)`` * - :meth:`subcatch_runoff_vol_bulk` - ``(n_subcatch,)`` - -Same memory-aliasing semantics as the bulk methods on :class:`Nodes` -and :class:`Links` — ``.copy()`` if you keep the array past the next -call. + * - :meth:`node_max_overflow_bulk` + - ``(n_nodes,)`` *(added 6.0.0)* + * - :meth:`node_vol_flooded_bulk` + - ``(n_nodes,)`` *(added 6.0.0)* + * - :meth:`node_time_flooded_bulk` + - ``(n_nodes,)`` *(added 6.0.0)* + * - :meth:`subcatch_max_runoff_bulk` + - ``(n_subcatch,)`` *(added 6.0.0)* + +Memory-aliasing rule: the three pre-6.0 bulk methods share scratch +buffers with engine state — ``.copy()`` if you need to retain them +past the next call. The 6.0 additions return freshly allocated +arrays. ---- diff --git a/python/docs/guide/subcatchments.rst b/python/docs/guide/subcatchments.rst index e2acba7be..f4ab0c10c 100644 --- a/python/docs/guide/subcatchments.rst +++ b/python/docs/guide/subcatchments.rst @@ -297,24 +297,57 @@ Per-subcatchment runoff coefficient (post-run summary) Bulk arrays =========== -The :class:`Subcatchments` class does not (yet) expose bulk-array -accessors. Vectorise across the population manually: +Each bulk method makes a single C call and returns a contiguous +``np.ndarray[float64]`` of shape ``(n_subcatchments,)`` (or a +``list[str]`` for ids). The GIL is released for the duration of the C +call, so multi-threaded consumers can read from independent solvers +in parallel. -.. code-block:: python +.. list-table:: + :header-rows: 1 + :widths: 40 60 - import numpy as np + * - Method + - Returns / accepts + * - :meth:`get_runoff_bulk` + - Runoff rate per subcatchment (project flow units). + * - :meth:`get_quality_bulk(p)` + - Concentration of pollutant ``p`` per subcatchment. + * - :meth:`get_rainfall_bulk` + - Rainfall rate per subcatchment *(added 6.0.0)*. + * - :meth:`get_evap_bulk` + - Evaporation loss per subcatchment *(added 6.0.0)*. + * - :meth:`get_infil_bulk` + - Infiltration loss per subcatchment *(added 6.0.0)*. + * - :meth:`get_snow_depth_bulk` + - Snow depth per subcatchment *(added 6.0.0; placeholder zeros + pending full snow-state integration)*. + * - :meth:`get_ids_bulk` + - ``list[str]`` of every subcatchment's id in one C call + *(added 6.0.0; stride-packed UTF-8)*. + +Whole-network water-balance pattern using the Phase 3 accessors: - runoff = np.zeros(sc.count()) - while s.state == EngineState.RUNNING: - if s.step() != 0: - break - for i in range(sc.count()): - runoff[i] += sc.get_runoff(i) +.. code-block:: python -If you need a strictly faster path, fall back to the -:class:`OutputReader` after the run and use the bulk subcatchment -methods (:doc:`output_reader`) — that's typically fastest for -post-processing. + ids = sc.get_ids_bulk() + rain = sc.get_rainfall_bulk() + infil = sc.get_infil_bulk() + evap = sc.get_evap_bulk() + runoff = sc.get_runoff_bulk() + + # Per-subcatch instantaneous residual (storage / snow can make this + # noisy step-by-step but it should integrate near zero over a long + # simulation if the model is well-balanced). + residual = rain - infil - evap - runoff + for i, name in enumerate(ids): + print(f" {name:<12} R={rain[i]:.3f} I={infil[i]:.3f} " + f"E={evap[i]:.3f} Q={runoff[i]:.3f} resid={residual[i]:+.3f}") + +For *cumulative* (post-run) statistics, prefer the bulk accessors on +:class:`Statistics` (``subcatch_runoff_vol_bulk`` etc.) or the +:class:`OutputReader` — those are denser and pull from the report +file rather than recomputing from the live state. ---- diff --git a/python/openswmm/engine/_2d.pxd b/python/openswmm/engine/_2d.pxd index 084ee0bf6..42df8fab4 100644 --- a/python/openswmm/engine/_2d.pxd +++ b/python/openswmm/engine/_2d.pxd @@ -13,7 +13,7 @@ cdef extern from "openswmm_2d.h": int swmm_2d_vertex_get_xyz(void* engine, int idx, double* x, double* y, double* z) int swmm_2d_vertex_get_xyz_bulk(void* engine, - double* x, double* y, double* z) + double* x, double* y, double* z) nogil int swmm_2d_triangle_get_vertices(void* engine, int idx, int* v0, int* v1, int* v2) int swmm_2d_triangle_get_area(void* engine, int idx, double* area) @@ -23,7 +23,7 @@ cdef extern from "openswmm_2d.h": int swmm_2d_triangle_get_neighbours(void* engine, int idx, int* n0, int* n1, int* n2) int swmm_2d_edge_get_geometry_bulk(void* engine, - double* length, double* nx, double* ny) + double* length, double* nx, double* ny) nogil # Coupling int swmm_2d_vertex_coupling_count(void* engine, int* count) @@ -37,14 +37,14 @@ cdef extern from "openswmm_2d.h": int swmm_2d_get_coupling_flux(void* engine, int idx, double* flux) int swmm_2d_get_rainfall(void* engine, int idx, double* rainfall) int swmm_2d_get_net_source(void* engine, int idx, double* net_source) - int swmm_2d_get_depths_bulk(void* engine, double* depths) - int swmm_2d_get_heads_bulk(void* engine, double* heads) - int swmm_2d_get_coupling_fluxes_bulk(void* engine, double* fluxes) - int swmm_2d_get_edge_flux_bulk(void* engine, double* flux) + int swmm_2d_get_depths_bulk(void* engine, double* depths) nogil + int swmm_2d_get_heads_bulk(void* engine, double* heads) nogil + int swmm_2d_get_coupling_fluxes_bulk(void* engine, double* fluxes) nogil + int swmm_2d_get_edge_flux_bulk(void* engine, double* flux) nogil # State — per vertex int swmm_2d_vertex_get_head(void* engine, int idx, double* head) - int swmm_2d_vertex_get_heads_bulk(void* engine, double* heads) + int swmm_2d_vertex_get_heads_bulk(void* engine, double* heads) nogil # Statistics int swmm_2d_get_max_depth(void* engine, double* max_depth) diff --git a/python/openswmm/engine/_2d.pyi b/python/openswmm/engine/_2d.pyi index d67a41d3c..562131307 100644 --- a/python/openswmm/engine/_2d.pyi +++ b/python/openswmm/engine/_2d.pyi @@ -92,7 +92,8 @@ class Surface2D: npt.NDArray[np.float64], npt.NDArray[np.float64], ]: - """Return (x, y, z) NumPy arrays for all vertices. + """Return (x, y, z) NumPy arrays for all vertices. GIL is released + during the C call. @return: Tuple C{(x, y, z)}, each of shape C{(n_vertices,)} with dtype C{float64}. @@ -205,10 +206,13 @@ class Surface2D: # ==================================================================== # State (depth/velocity) - per triangle bulk arrays + # + # Every bulk getter in this section releases the GIL for the C call. # ==================================================================== def get_depths(self) -> npt.NDArray[np.float64]: - """Return depths for all triangles as a NumPy array. + """Return depths for all triangles as a NumPy array. GIL is + released during the C call. @return: Array of shape C{(n_triangles,)} with dtype C{float64}. @rtype: np.ndarray @@ -217,7 +221,8 @@ class Surface2D: ... def get_heads(self) -> npt.NDArray[np.float64]: - """Return total heads for all triangles as a NumPy array. + """Return total heads for all triangles as a NumPy array. GIL is + released during the C call. @return: Array of shape C{(n_triangles,)} with dtype C{float64}. @rtype: np.ndarray @@ -226,7 +231,8 @@ class Surface2D: ... def get_coupling_fluxes(self) -> npt.NDArray[np.float64]: - """Return coupling fluxes for all triangles as a NumPy array. + """Return coupling fluxes for all triangles as a NumPy array. GIL + is released during the C call. @return: Array of shape C{(n_triangles,)} with dtype C{float64}. Positive values denote flux into the 2D surface. @@ -236,7 +242,8 @@ class Surface2D: ... def get_edge_flux_bulk(self) -> npt.NDArray[np.float64]: - """Return normal edge fluxes for all triangle edges. + """Return normal edge fluxes for all triangle edges. GIL is + released during the C call. Indexed as C{[tri*3 + localEdge]}. @return: Array of shape C{(n_triangles*3,)} with dtype C{float64}. @@ -245,6 +252,7 @@ class Surface2D: def get_edge_geometry_bulk(self) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]]: """Return time-invariant edge lengths and outward unit normal components. + GIL is released during the C call. Returns C{(length, nx, ny)}, each indexed as C{[tri*3 + localEdge]}. @@ -317,6 +325,7 @@ class Surface2D: def get_vertex_heads(self) -> npt.NDArray[np.float64]: """Return reconstructed heads at all vertices as a NumPy array. + GIL is released during the C call. @return: Array of shape C{(n_vertices,)} with dtype C{float64}. @rtype: np.ndarray diff --git a/python/openswmm/engine/_2d.pyx b/python/openswmm/engine/_2d.pyx index 2369329bf..6e717301e 100644 --- a/python/openswmm/engine/_2d.pyx +++ b/python/openswmm/engine/_2d.pyx @@ -111,7 +111,14 @@ cdef class Surface2D: cdef np.ndarray[double, ndim=1] x = np.empty(n, dtype=np.float64) cdef np.ndarray[double, ndim=1] y = np.empty(n, dtype=np.float64) cdef np.ndarray[double, ndim=1] z = np.empty(n, dtype=np.float64) - _check(swmm_2d_vertex_get_xyz_bulk(self._engine, &x[0], &y[0], &z[0])) + cdef void* eng = self._engine + cdef double* px = x.data + cdef double* py = y.data + cdef double* pz = z.data + cdef int err + with nogil: + err = swmm_2d_vertex_get_xyz_bulk(eng, px, py, pz) + _check(err) return x, y, z def get_triangle_vertices(self, int idx): @@ -249,11 +256,17 @@ cdef class Surface2D: """ cdef int n = self.n_triangles cdef np.ndarray[double, ndim=1] arr = np.empty(n, dtype=np.float64) - _check(swmm_2d_get_depths_bulk(self._engine, &arr[0])) + cdef void* eng = self._engine + cdef double* p = arr.data + cdef int err + with nogil: + err = swmm_2d_get_depths_bulk(eng, p) + _check(err) return arr def get_heads(self): - """Return total heads for all triangles as a NumPy array. + """Return total heads for all triangles as a NumPy array. The GIL + is released during the C call. @return: Array of shape C{(n_triangles,)} with dtype C{float64}. @rtype: np.ndarray @@ -261,11 +274,17 @@ cdef class Surface2D: """ cdef int n = self.n_triangles cdef np.ndarray[double, ndim=1] arr = np.empty(n, dtype=np.float64) - _check(swmm_2d_get_heads_bulk(self._engine, &arr[0])) + cdef void* eng = self._engine + cdef double* p = arr.data + cdef int err + with nogil: + err = swmm_2d_get_heads_bulk(eng, p) + _check(err) return arr def get_coupling_fluxes(self): - """Return coupling fluxes for all triangles as a NumPy array. + """Return coupling fluxes for all triangles as a NumPy array. The + GIL is released during the C call. @return: Array of shape C{(n_triangles,)} with dtype C{float64}. Positive values denote flux into the 2D surface. @@ -274,11 +293,17 @@ cdef class Surface2D: """ cdef int n = self.n_triangles cdef np.ndarray[double, ndim=1] arr = np.empty(n, dtype=np.float64) - _check(swmm_2d_get_coupling_fluxes_bulk(self._engine, &arr[0])) + cdef void* eng = self._engine + cdef double* p = arr.data + cdef int err + with nogil: + err = swmm_2d_get_coupling_fluxes_bulk(eng, p) + _check(err) return arr def get_edge_flux_bulk(self): """Return normal edge fluxes for all triangle edges as a NumPy array. + The GIL is released during the C call. The array is indexed as C{[tri*3 + localEdge]} where C{localEdge} is the edge opposite vertex C{localEdge} (0, 1, or 2). Positive @@ -290,11 +315,17 @@ cdef class Surface2D: """ cdef int n = self.n_triangles * 3 cdef np.ndarray[double, ndim=1] arr = np.empty(n, dtype=np.float64) - _check(swmm_2d_get_edge_flux_bulk(self._engine, &arr[0])) + cdef void* eng = self._engine + cdef double* p = arr.data + cdef int err + with nogil: + err = swmm_2d_get_edge_flux_bulk(eng, p) + _check(err) return arr def get_edge_geometry_bulk(self): """Return time-invariant edge lengths and outward unit normal components. + The GIL is released during the C call. Returns arrays indexed as C{[tri*3 + localEdge]}. Use together with L{get_edge_flux_bulk} to reconstruct cell-centred velocity via @@ -309,8 +340,14 @@ cdef class Surface2D: cdef np.ndarray[double, ndim=1] length = np.empty(n, dtype=np.float64) cdef np.ndarray[double, ndim=1] nx = np.empty(n, dtype=np.float64) cdef np.ndarray[double, ndim=1] ny = np.empty(n, dtype=np.float64) - _check(swmm_2d_edge_get_geometry_bulk(self._engine, &length[0], - &nx[0], &ny[0])) + cdef void* eng = self._engine + cdef double* pL = length.data + cdef double* pX = nx.data + cdef double* pY = ny.data + cdef int err + with nogil: + err = swmm_2d_edge_get_geometry_bulk(eng, pL, pX, pY) + _check(err) return length, nx, ny # ==================================================================== @@ -395,7 +432,12 @@ cdef class Surface2D: """ cdef int n = self.n_vertices cdef np.ndarray[double, ndim=1] arr = np.empty(n, dtype=np.float64) - _check(swmm_2d_vertex_get_heads_bulk(self._engine, &arr[0])) + cdef void* eng = self._engine + cdef double* p = arr.data + cdef int err + with nogil: + err = swmm_2d_vertex_get_heads_bulk(eng, p) + _check(err) return arr # ==================================================================== diff --git a/python/openswmm/engine/_common.pxd b/python/openswmm/engine/_common.pxd index 3510055bd..6b61ab2df 100644 --- a/python/openswmm/engine/_common.pxd +++ b/python/openswmm/engine/_common.pxd @@ -31,8 +31,12 @@ cdef extern from "openswmm_engine.h": cdef int swmm_engine_open(SWMM_Engine e, const char* inp, const char* rpt, const char* out, const char* input_plugin_lib) cdef int swmm_engine_initialize(SWMM_Engine e) cdef int swmm_engine_start(SWMM_Engine e, int save_results) - cdef int swmm_engine_step(SWMM_Engine e, double* elapsed_time) - cdef int swmm_engine_stride(SWMM_Engine e, int n_steps, double* elapsed_time) + # NOTE: step/stride may invoke registered step_begin/step_end callbacks. + # Those trampolines reacquire the GIL via `noexcept with gil:`, so it is + # safe to release the GIL around the C call itself (and necessary, so + # that another thread can advance an independent engine concurrently). + cdef int swmm_engine_step(SWMM_Engine e, double* elapsed_time) nogil + cdef int swmm_engine_stride(SWMM_Engine e, int n_steps, double* elapsed_time) nogil cdef int swmm_engine_end(SWMM_Engine e) cdef int swmm_engine_report(SWMM_Engine e) cdef int swmm_engine_close(SWMM_Engine e) @@ -179,13 +183,20 @@ cdef extern from "openswmm_nodes.h": cdef int swmm_node_get_stat_vol_flooded(SWMM_Engine e, int idx, double* val) cdef int swmm_node_get_stat_time_flooded(SWMM_Engine e, int idx, double* val) # Bulk access - cdef int swmm_node_get_depths_bulk(SWMM_Engine e, double* buf, int count) - cdef int swmm_node_get_heads_bulk(SWMM_Engine e, double* buf, int count) - cdef int swmm_node_get_inflows_bulk(SWMM_Engine e, double* buf, int count) - cdef int swmm_node_get_overflows_bulk(SWMM_Engine e, double* buf, int count) - cdef int swmm_node_set_depths_bulk(SWMM_Engine e, const double* buf, int count) - cdef int swmm_node_set_lat_inflows_bulk(SWMM_Engine e, const double* buf, int count) - cdef int swmm_node_get_quality_bulk(SWMM_Engine e, int pollutant_idx, double* buf, int count) + # Bulk node accessors — pure C memory ops, safe to call without the GIL. + cdef int swmm_node_get_depths_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_node_get_heads_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_node_get_inflows_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_node_get_overflows_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_node_set_depths_bulk(SWMM_Engine e, const double* buf, int count) nogil + cdef int swmm_node_set_lat_inflows_bulk(SWMM_Engine e, const double* buf, int count) nogil + cdef int swmm_node_get_quality_bulk(SWMM_Engine e, int pollutant_idx, double* buf, int count) nogil + # Phase 3 bulk getters (volumes, outflows, losses, lateral_inflows, ids). + cdef int swmm_node_get_volumes_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_node_get_outflows_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_node_get_losses_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_node_get_lateral_inflows_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_node_get_ids_bulk(SWMM_Engine e, char* buf, int stride, int count) nogil # Outfall route-to cdef int swmm_node_set_outfall_route_to(SWMM_Engine e, int idx, int subcatch_idx) cdef int swmm_node_get_outfall_route_to(SWMM_Engine e, int idx, int* subcatch_idx) @@ -280,13 +291,25 @@ cdef extern from "openswmm_links.h": cdef int swmm_link_get_stat_pump_cycles(SWMM_Engine e, int idx, int* cycles) cdef int swmm_link_get_stat_pump_on_time(SWMM_Engine e, int idx, double* seconds) cdef int swmm_link_get_stat_pump_volume(SWMM_Engine e, int idx, double* volume) + cdef int swmm_link_get_pump_stats_bulk(SWMM_Engine e, int* cycles, + double* on_time, double* volume, + int count) nogil # Hydraulic power cdef int swmm_link_get_hyd_power(SWMM_Engine e, int idx, double* power) # Bulk access - cdef int swmm_link_get_flows_bulk(SWMM_Engine e, double* buf, int count) - cdef int swmm_link_get_depths_bulk(SWMM_Engine e, double* buf, int count) - cdef int swmm_link_set_flows_bulk(SWMM_Engine e, const double* buf, int count) - cdef int swmm_link_get_quality_bulk(SWMM_Engine e, int pollutant_idx, double* buf, int count) + # Bulk link accessors — pure C memory ops, safe to call without the GIL. + cdef int swmm_link_get_flows_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_link_get_depths_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_link_set_flows_bulk(SWMM_Engine e, const double* buf, int count) nogil + cdef int swmm_link_get_quality_bulk(SWMM_Engine e, int pollutant_idx, double* buf, int count) nogil + # Phase 3 bulk getters — velocities/capacities/volumes/control/target/power, ids. + cdef int swmm_link_get_velocities_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_link_get_capacities_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_link_get_volumes_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_link_get_control_settings_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_link_get_target_settings_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_link_get_hyd_powers_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_link_get_ids_bulk(SWMM_Engine e, char* buf, int stride, int count) nogil # Rename cdef int swmm_link_rename(SWMM_Engine e, int idx, const char* newId) @@ -357,8 +380,15 @@ cdef extern from "openswmm_subcatchments.h": # Quality cdef int swmm_subcatch_get_quality(SWMM_Engine e, int subcatch_idx, int pollutant_idx, double* conc) # Bulk access - cdef int swmm_subcatch_get_runoff_bulk(SWMM_Engine e, double* buf, int count) - cdef int swmm_subcatch_get_quality_bulk(SWMM_Engine e, int pollutant_idx, double* buf, int count) + # Bulk subcatchment accessors — pure C memory ops, safe to call without the GIL. + cdef int swmm_subcatch_get_runoff_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_subcatch_get_quality_bulk(SWMM_Engine e, int pollutant_idx, double* buf, int count) nogil + # Phase 3 bulk getters — rainfall/evap/infil/snow_depth + ids. + cdef int swmm_subcatch_get_rainfall_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_subcatch_get_evap_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_subcatch_get_infil_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_subcatch_get_snow_depth_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_subcatch_get_ids_bulk(SWMM_Engine e, char* buf, int stride, int count) nogil # Ponded quality cdef int swmm_subcatch_get_ponded_quality(SWMM_Engine e, int subcatch_idx, int pollutant_idx, double* mass) cdef int swmm_subcatch_set_ponded_quality(SWMM_Engine e, int subcatch_idx, int pollutant_idx, double mass) @@ -385,7 +415,7 @@ cdef extern from "openswmm_gages.h": cdef int swmm_gage_get_rainfall(SWMM_Engine e, int idx, double* rainfall) cdef int swmm_gage_set_rainfall(SWMM_Engine e, int idx, double rainfall) # Bulk - cdef int swmm_gage_get_rainfall_bulk(SWMM_Engine e, double* buf, int count) + cdef int swmm_gage_get_rainfall_bulk(SWMM_Engine e, double* buf, int count) nogil # Rename cdef int swmm_gage_rename(SWMM_Engine e, int idx, const char* newId) @@ -406,10 +436,12 @@ cdef extern from "openswmm_massbalance.h": cdef int swmm_get_quality_evap_loss(SWMM_Engine e, int pollutant_idx, double* mass) cdef extern from "openswmm_hotstart.h": - cdef int swmm_hotstart_save(SWMM_Engine e, const char* path) - cdef int swmm_hotstart_open(const char* path, SWMM_HotStart* hs) - cdef int swmm_hotstart_apply(SWMM_Engine e, SWMM_HotStart hs) - cdef int swmm_hotstart_close(SWMM_HotStart hs) + # File I/O — heavy enough to warrant releasing the GIL while + # the C side reads or writes the (potentially large) hotstart blob. + cdef int swmm_hotstart_save(SWMM_Engine e, const char* path) nogil + cdef int swmm_hotstart_open(const char* path, SWMM_HotStart* hs) nogil + cdef int swmm_hotstart_apply(SWMM_Engine e, SWMM_HotStart hs) nogil + cdef int swmm_hotstart_close(SWMM_HotStart hs) nogil # Modify cdef int swmm_hotstart_set_node_depth(SWMM_HotStart hs, const char* node_id, double depth) cdef int swmm_hotstart_set_node_head(SWMM_HotStart hs, const char* node_id, double head) @@ -613,9 +645,14 @@ cdef extern from "openswmm_statistics.h": cdef int swmm_stat_subcatch_runoff_vol(SWMM_Engine e, int idx, double* val) cdef int swmm_stat_subcatch_max_runoff(SWMM_Engine e, int idx, double* val) # Bulk - cdef int swmm_stat_node_max_depth_bulk(SWMM_Engine e, double* buf, int count) - cdef int swmm_stat_link_max_flow_bulk(SWMM_Engine e, double* buf, int count) - cdef int swmm_stat_subcatch_runoff_vol_bulk(SWMM_Engine e, double* buf, int count) + cdef int swmm_stat_node_max_depth_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_stat_link_max_flow_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_stat_subcatch_runoff_vol_bulk(SWMM_Engine e, double* buf, int count) nogil + # Phase 3 statistics bulk getters — flooding + max-runoff. + cdef int swmm_stat_node_max_overflow_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_stat_node_vol_flooded_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_stat_node_time_flooded_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_stat_subcatch_max_runoff_bulk(SWMM_Engine e, double* buf, int count) nogil cdef extern from "openswmm_spatial.h": # CRS @@ -663,27 +700,28 @@ cdef extern from "openswmm_output.h": cdef const char* swmm_output_get_subcatch_id(SWMM_Output handle, int index) cdef const char* swmm_output_get_node_id(SWMM_Output handle, int index) cdef const char* swmm_output_get_link_id(SWMM_Output handle, int index) - # Per-period results - cdef int swmm_output_get_subcatch_result(SWMM_Output handle, int period, int var, float* values) - cdef int swmm_output_get_node_result(SWMM_Output handle, int period, int var, float* values) - cdef int swmm_output_get_link_result(SWMM_Output handle, int period, int var, float* values) - cdef int swmm_output_get_system_result(SWMM_Output handle, int period, int var, float* value) - # Time series + # Per-period results — disk-backed read into caller's float buffer. + # nogil: pure C I/O, no Python objects touched. + cdef int swmm_output_get_subcatch_result(SWMM_Output handle, int period, int var, float* values) nogil + cdef int swmm_output_get_node_result(SWMM_Output handle, int period, int var, float* values) nogil + cdef int swmm_output_get_link_result(SWMM_Output handle, int period, int var, float* values) nogil + cdef int swmm_output_get_system_result(SWMM_Output handle, int period, int var, float* value) nogil + # Time series — potentially large reads from the .out file. cdef int swmm_output_get_subcatch_series(SWMM_Output handle, int subcatch_idx, int var, - int start_period, int end_period, float* values) + int start_period, int end_period, float* values) nogil cdef int swmm_output_get_node_series(SWMM_Output handle, int node_idx, int var, - int start_period, int end_period, float* values) + int start_period, int end_period, float* values) nogil cdef int swmm_output_get_link_series(SWMM_Output handle, int link_idx, int var, - int start_period, int end_period, float* values) + int start_period, int end_period, float* values) nogil cdef int swmm_output_get_system_series(SWMM_Output handle, int var, - int start_period, int end_period, float* values) + int start_period, int end_period, float* values) nogil # Per-object attribute cdef int swmm_output_get_subcatch_attribute(SWMM_Output handle, int subcatch_idx, int period, - float* values, int* count) + float* values, int* count) nogil cdef int swmm_output_get_node_attribute(SWMM_Output handle, int node_idx, int period, - float* values, int* count) + float* values, int* count) nogil cdef int swmm_output_get_link_attribute(SWMM_Output handle, int link_idx, int period, - float* values, int* count) + float* values, int* count) nogil # Time cdef int swmm_output_get_period_time(SWMM_Output handle, int period, double* time) # Error diff --git a/python/openswmm/engine/_gages.pyi b/python/openswmm/engine/_gages.pyi index f53f9c662..bf1654c36 100644 --- a/python/openswmm/engine/_gages.pyi +++ b/python/openswmm/engine/_gages.pyi @@ -142,7 +142,8 @@ class Gages: ... def get_rainfall_bulk(self) -> npt.NDArray[np.float64]: - """Return rainfall for all gages as a NumPy array. + """Return rainfall for all gages as a NumPy array. GIL is released + during the C call. @return: 1-D array of rainfall values, one per gage. Shape C{(n_gages,)}, dtype C{float64}. diff --git a/python/openswmm/engine/_gages.pyx b/python/openswmm/engine/_gages.pyx index d8df3f464..847471aa9 100644 --- a/python/openswmm/engine/_gages.pyx +++ b/python/openswmm/engine/_gages.pyx @@ -175,7 +175,8 @@ class Gages: _check(swmm_gage_set_rainfall(h, i, rainfall)) def get_rainfall_bulk(self) -> np.ndarray: - """Return rainfall for all gages as a NumPy array. + """Return rainfall for all gages as a NumPy array. The GIL is + released during the C call. @return: 1-D array of rainfall values, one per gage. Shape C{(n_gages,)}, dtype C{float64}. @@ -184,7 +185,11 @@ class Gages: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_gage_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_gage_get_rainfall_bulk(h, buf.data, n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_gage_get_rainfall_bulk(h, p, n) + _check(err) return buf # ==================================================================== diff --git a/python/openswmm/engine/_geopackage.pyi b/python/openswmm/engine/_geopackage.pyi index 51b673329..3fa713875 100644 --- a/python/openswmm/engine/_geopackage.pyi +++ b/python/openswmm/engine/_geopackage.pyi @@ -202,7 +202,8 @@ class GeoPackage: def read_result_ts( self, sim_id: str, obj_type: str, obj_id: str, variable: str ) -> Tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: - """Read a result timeseries as NumPy arrays. + """Read a result timeseries as NumPy arrays. GIL is released for + the duration of the SQL execution. @param sim_id: Simulation identifier. @type sim_id: str @@ -302,7 +303,8 @@ class GeoPackage: values: npt.ArrayLike, flags: Optional[List[str]] = None, ) -> None: - """Bulk-write observed data points. + """Bulk-write observed data points. GIL is released for the + duration of the bulk SQL insert. For best performance wrap the call in a transaction:: @@ -350,7 +352,8 @@ class GeoPackage: def read_observed_values( self, series_id: int ) -> Tuple[List[str], npt.NDArray[np.float64]]: - """Read observed-timeseries values. + """Read observed-timeseries values. GIL is released for the + duration of the SQL read. @param series_id: Series ID. @type series_id: int diff --git a/python/openswmm/engine/_geopackage.pyx b/python/openswmm/engine/_geopackage.pyx index 80ed49263..3dd3e19a5 100644 --- a/python/openswmm/engine/_geopackage.pyx +++ b/python/openswmm/engine/_geopackage.pyx @@ -27,60 +27,66 @@ cimport numpy as np cdef extern from "openswmm_geopackage.h": ctypedef void* SWMM_Gpkg - SWMM_Gpkg swmm_gpkg_open(const char* path) - void swmm_gpkg_close(SWMM_Gpkg gpkg) + # Lifecycle — file I/O, marked nogil so the SQLite open/close + # does not block the interpreter on another thread. + SWMM_Gpkg swmm_gpkg_open(const char* path) nogil + void swmm_gpkg_close(SWMM_Gpkg gpkg) nogil const char* swmm_gpkg_last_error(SWMM_Gpkg gpkg) - int swmm_gpkg_begin(SWMM_Gpkg gpkg) - int swmm_gpkg_commit(SWMM_Gpkg gpkg) - int swmm_gpkg_rollback(SWMM_Gpkg gpkg) + int swmm_gpkg_begin(SWMM_Gpkg gpkg) nogil + int swmm_gpkg_commit(SWMM_Gpkg gpkg) nogil + int swmm_gpkg_rollback(SWMM_Gpkg gpkg) nogil int swmm_gpkg_register(const char* key, const char* org, const char* email, const char* deploy) int swmm_gpkg_is_registered() - int swmm_gpkg_simulation_count(SWMM_Gpkg gpkg) - int swmm_gpkg_simulation_id(SWMM_Gpkg gpkg, int index, char* buf, int bufsz) + int swmm_gpkg_simulation_count(SWMM_Gpkg gpkg) nogil + int swmm_gpkg_simulation_id(SWMM_Gpkg gpkg, int index, char* buf, int bufsz) nogil - int swmm_gpkg_node_count(SWMM_Gpkg gpkg, const char* sim_id) - int swmm_gpkg_link_count(SWMM_Gpkg gpkg, const char* sim_id) - int swmm_gpkg_subcatch_count(SWMM_Gpkg gpkg, const char* sim_id) - int swmm_gpkg_gage_count(SWMM_Gpkg gpkg, const char* sim_id) - int swmm_gpkg_topology_edge_count(SWMM_Gpkg gpkg, const char* sim_id) - int swmm_gpkg_variable_count(SWMM_Gpkg gpkg) + int swmm_gpkg_node_count(SWMM_Gpkg gpkg, const char* sim_id) nogil + int swmm_gpkg_link_count(SWMM_Gpkg gpkg, const char* sim_id) nogil + int swmm_gpkg_subcatch_count(SWMM_Gpkg gpkg, const char* sim_id) nogil + int swmm_gpkg_gage_count(SWMM_Gpkg gpkg, const char* sim_id) nogil + int swmm_gpkg_topology_edge_count(SWMM_Gpkg gpkg, const char* sim_id) nogil + int swmm_gpkg_variable_count(SWMM_Gpkg gpkg) nogil + # Time-series read is the heaviest single call — a SQL query that + # may return tens of thousands of rows. Definitely worth releasing + # the GIL. int swmm_gpkg_result_ts_count(SWMM_Gpkg gpkg, const char* sim_id, const char* obj_type, const char* obj_id, - const char* var_name) + const char* var_name) nogil int swmm_gpkg_read_result_ts(SWMM_Gpkg gpkg, const char* sim_id, const char* obj_type, const char* obj_id, const char* var_name, - double* times, double* values, int max_count) + double* times, double* values, int max_count) nogil int swmm_gpkg_read_summary(SWMM_Gpkg gpkg, const char* sim_id, const char* obj_type, const char* obj_id, - const char* var_name, double* value) + const char* var_name, double* value) nogil + # Observed-data writers and readers — disk-backed SQL ops. int swmm_gpkg_create_observed_series(SWMM_Gpkg gpkg, const char* name, const char* var_name, const char* obj_type, const char* obj_id, const char* source, - const char* units) + const char* units) nogil int swmm_gpkg_write_observed_value(SWMM_Gpkg gpkg, int series_id, const char* timestamp, double value, - const char* quality_flag) + const char* quality_flag) nogil int swmm_gpkg_write_observed_values(SWMM_Gpkg gpkg, int series_id, const char** timestamps, const double* values, - const char** quality_flags, int count) + const char** quality_flags, int count) nogil - int swmm_gpkg_observed_series_count(SWMM_Gpkg gpkg) - int swmm_gpkg_observed_value_count(SWMM_Gpkg gpkg, int series_id) + int swmm_gpkg_observed_series_count(SWMM_Gpkg gpkg) nogil + int swmm_gpkg_observed_value_count(SWMM_Gpkg gpkg, int series_id) nogil int swmm_gpkg_read_observed_values(SWMM_Gpkg gpkg, int series_id, char* timestamps, int ts_buf_len, - double* values, int max_count) + double* values, int max_count) nogil - int swmm_gpkg_query_int(SWMM_Gpkg gpkg, const char* sql) - int swmm_gpkg_query_double(SWMM_Gpkg gpkg, const char* sql, double* result) + int swmm_gpkg_query_int(SWMM_Gpkg gpkg, const char* sql) nogil + int swmm_gpkg_query_double(SWMM_Gpkg gpkg, const char* sql, double* result) nogil cdef class GeoPackage: @@ -300,11 +306,26 @@ cdef class GeoPackage: cdef np.ndarray[double, ndim=1] times = np.empty(n, dtype=np.float64) cdef np.ndarray[double, ndim=1] values = np.empty(n, dtype=np.float64) - cdef int read = swmm_gpkg_read_result_ts( - self._handle, sim_id.encode('utf-8'), - obj_type.encode('utf-8'), obj_id.encode('utf-8'), - variable.encode('utf-8'), - times.data, values.data, n) + # Re-encode strings outside the nogil block so the bytes objects + # remain owned with the GIL held; then snapshot the raw pointers. + cdef bytes b_sim = sim_id.encode('utf-8') + cdef bytes b_otype = obj_type.encode('utf-8') + cdef bytes b_oid = obj_id.encode('utf-8') + cdef bytes b_var = variable.encode('utf-8') + cdef SWMM_Gpkg gpkg = self._handle + cdef const char* p_sim = b_sim + cdef const char* p_otype = b_otype + cdef const char* p_oid = b_oid + cdef const char* p_var = b_var + cdef double* p_times = times.data + cdef double* p_values = values.data + cdef int read + # SQL execution can take many milliseconds for large series — worth + # releasing the GIL so another thread can do useful work meanwhile. + with nogil: + read = swmm_gpkg_read_result_ts( + gpkg, p_sim, p_otype, p_oid, p_var, + p_times, p_values, n) return times[:read], values[:read] # ==================================================================== @@ -362,13 +383,24 @@ cdef class GeoPackage: @rtype: int @raise RuntimeError: If the series cannot be created. """ + # NOTE: keep the encoded bytes alive in locals before extracting the + # raw `const char*` — under Cython >= 3.0.12 the conditional + # expression `x.encode('utf-8') if x else NULL` cannot unify + # `bytes` and `void*` and fails to transpile (see plan §Appendix A). + cdef bytes b_name = name.encode('utf-8') + cdef bytes b_var = variable.encode('utf-8') + cdef bytes b_otype = obj_type.encode('utf-8') if obj_type else b"" + cdef bytes b_oid = obj_id.encode('utf-8') if obj_id else b"" + cdef bytes b_src = source.encode('utf-8') if source else b"" + cdef bytes b_units = units.encode('utf-8') if units else b"" + cdef const char* p_otype = b_otype if obj_type else NULL + cdef const char* p_oid = b_oid if obj_id else NULL + cdef const char* p_src = b_src if source else NULL + cdef const char* p_units = b_units if units else NULL cdef int sid = swmm_gpkg_create_observed_series( self._handle, - name.encode('utf-8'), variable.encode('utf-8'), - obj_type.encode('utf-8') if obj_type else NULL, - obj_id.encode('utf-8') if obj_id else NULL, - source.encode('utf-8') if source else NULL, - units.encode('utf-8') if units else NULL) + b_name, b_var, + p_otype, p_oid, p_src, p_units) if sid < 0: raise RuntimeError(f"Failed to create series: {self.last_error}") return sid @@ -389,10 +421,14 @@ cdef class GeoPackage: @type flag: str @raise RuntimeError: If the write fails. """ + # See create_observed_series above for the encode/NULL pattern + # rationale. + cdef bytes b_ts = timestamp.encode('utf-8') + cdef bytes b_flag = flag.encode('utf-8') if flag else b"" + cdef const char* p_flag = b_flag if flag else NULL cdef int rc = swmm_gpkg_write_observed_value( self._handle, series_id, - timestamp.encode('utf-8'), value, - flag.encode('utf-8') if flag else NULL) + b_ts, value, p_flag) if rc != 0: raise RuntimeError(f"Write failed: {self.last_error}") @@ -434,15 +470,21 @@ cdef class GeoPackage: if c_fl: free(c_fl) raise MemoryError() + cdef SWMM_Gpkg gpkg = self._handle + cdef const double* p_vals = vals.data + cdef const char** c_fl_arg + cdef int rc_c try: for i in range(n): c_ts[i] = ts_bytes[i] c_fl[i] = fl_bytes[i] - rc = swmm_gpkg_write_observed_values( - self._handle, series_id, - c_ts, vals.data, - c_fl if flags else NULL, n) - if rc != 0: + c_fl_arg = c_fl if flags else NULL + # Bulk SQL inserts can take a long time for large series; + # release the GIL so peer threads continue. + with nogil: + rc_c = swmm_gpkg_write_observed_values( + gpkg, series_id, c_ts, p_vals, c_fl_arg, n) + if rc_c != 0: raise RuntimeError(f"Bulk write failed: {self.last_error}") finally: free(c_ts) @@ -487,11 +529,13 @@ cdef class GeoPackage: cdef int ts_len = 32 cdef bytearray ts_buf = bytearray(n * ts_len) cdef np.ndarray[double, ndim=1] values = np.empty(n, dtype=np.float64) - - cdef int read = swmm_gpkg_read_observed_values( - self._handle, series_id, - ts_buf, ts_len, - values.data, n) + cdef SWMM_Gpkg gpkg = self._handle + cdef char* p_ts = ts_buf + cdef double* p_v = values.data + cdef int read + with nogil: + read = swmm_gpkg_read_observed_values( + gpkg, series_id, p_ts, ts_len, p_v, n) timestamps = [] for i in range(read): @@ -550,11 +594,17 @@ def register(str key="", str org="", str email="", str deploy="") -> bool: @return: C{True} if registration succeeded. @rtype: bool """ - return swmm_gpkg_register( - key.encode('utf-8') if key else NULL, - org.encode('utf-8') if org else NULL, - email.encode('utf-8') if email else NULL, - deploy.encode('utf-8') if deploy else NULL) != 0 + # See GeoPackage.create_observed_series for the encode/NULL pattern + # rationale (Cython >= 3.0.12 cannot unify bytes / void* in a conditional). + cdef bytes b_key = key.encode('utf-8') if key else b"" + cdef bytes b_org = org.encode('utf-8') if org else b"" + cdef bytes b_email = email.encode('utf-8') if email else b"" + cdef bytes b_deploy = deploy.encode('utf-8') if deploy else b"" + cdef const char* p_key = b_key if key else NULL + cdef const char* p_org = b_org if org else NULL + cdef const char* p_email = b_email if email else NULL + cdef const char* p_deploy = b_deploy if deploy else NULL + return swmm_gpkg_register(p_key, p_org, p_email, p_deploy) != 0 def is_registered() -> bool: """Check whether the GeoPackage plugin is registered. diff --git a/python/openswmm/engine/_hotstart.pyi b/python/openswmm/engine/_hotstart.pyi index c3da73ba7..54e990ccc 100644 --- a/python/openswmm/engine/_hotstart.pyi +++ b/python/openswmm/engine/_hotstart.pyi @@ -70,7 +70,9 @@ class HotStart: @staticmethod def save(solver: Solver, path: str) -> None: - """Save the current simulation state to a hot start file. + """Save the current simulation state to a hot start file. GIL is + released for the duration of the C write (potentially many MB of + state, so this is worth knowing about in multi-threaded code). Wraps C{swmm_hotstart_save}. Valid only when the solver is in C{RUNNING} or C{ENDED} state. @@ -85,7 +87,8 @@ class HotStart: @classmethod def open(cls, path: str) -> "HotStart": - """Open a hot start file for reading. + """Open a hot start file for reading. GIL is released during the + C read. Wraps C{swmm_hotstart_open}. The returned handle should be closed with L{HotStart.close} or by using a C{with} block. @@ -99,7 +102,8 @@ class HotStart: ... def close(self) -> None: - """Close the hot start file and free resources. + """Close the hot start file and free resources. GIL is released + during the C close. Wraps C{swmm_hotstart_close}. Safe to call multiple times; once closed, the handle becomes inert. @@ -114,7 +118,8 @@ class HotStart: # ==================================================================== def apply(self, solver: Solver) -> None: - """Apply this hot start state to an engine. + """Apply this hot start state to an engine. GIL is released for + the duration of the C state copy. Wraps C{swmm_hotstart_apply}. The engine must be in C{INITIALIZED} state (after L{Solver.initialize} but before L{Solver.start}). diff --git a/python/openswmm/engine/_hotstart.pyx b/python/openswmm/engine/_hotstart.pyx index 82372358c..e114a9cc0 100644 --- a/python/openswmm/engine/_hotstart.pyx +++ b/python/openswmm/engine/_hotstart.pyx @@ -76,7 +76,9 @@ cdef class HotStart: @staticmethod def save(Solver solver, str path): - """Save the current simulation state to a hot start file. + """Save the current simulation state to a hot start file. The GIL + is released for the duration of the C write (potentially many MB + of state). Wraps C{swmm_hotstart_save}. Valid only when the solver is in C{RUNNING} or C{ENDED} state. @@ -88,11 +90,17 @@ cdef class HotStart: @raise EngineError: If the state cannot be saved. """ cdef bytes b = path.encode('utf-8') - _check(swmm_hotstart_save(solver._handle, b)) + cdef SWMM_Engine h = solver._handle + cdef const char* p = b + cdef int err + with nogil: + err = swmm_hotstart_save(h, p) + _check(err) @classmethod def open(cls, str path): - """Open a hot start file for reading. + """Open a hot start file for reading. The GIL is released for the + duration of the C read. Wraps C{swmm_hotstart_open}. The returned handle should be closed with L{HotStart.close} or by using a C{with} block. @@ -105,7 +113,11 @@ cdef class HotStart: """ cdef bytes b = path.encode('utf-8') cdef SWMM_HotStart h = NULL - _check(swmm_hotstart_open(b, &h)) + cdef const char* p = b + cdef int err + with nogil: + err = swmm_hotstart_open(p, &h) + _check(err) if h == NULL: raise IOError(f"Cannot open hot start file: {path}") cdef HotStart obj = cls.__new__(cls) @@ -116,13 +128,19 @@ cdef class HotStart: """Close the hot start file and free resources. Wraps C{swmm_hotstart_close}. Safe to call multiple times; once - closed, the handle becomes inert. + closed, the handle becomes inert. The GIL is released for the + duration of the C close. @return: C{None}. @rtype: NoneType """ + cdef SWMM_HotStart h + cdef int err if self._handle != NULL: - _check(swmm_hotstart_close(self._handle)) + h = self._handle + with nogil: + err = swmm_hotstart_close(h) + _check(err) self._handle = NULL # ==================================================================== @@ -130,7 +148,8 @@ cdef class HotStart: # ==================================================================== def apply(self, Solver solver): - """Apply this hot start state to an engine. + """Apply this hot start state to an engine. The GIL is released + for the duration of the C state copy. Wraps C{swmm_hotstart_apply}. The engine must be in C{INITIALIZED} state (after L{Solver.initialize} but before L{Solver.start}). @@ -140,7 +159,12 @@ cdef class HotStart: @type solver: Solver @raise EngineError: If the state cannot be applied. """ - _check(swmm_hotstart_apply(solver._handle, self._handle)) + cdef SWMM_Engine e = solver._handle + cdef SWMM_HotStart h = self._handle + cdef int err + with nogil: + err = swmm_hotstart_apply(e, h) + _check(err) def set_node_depth(self, str node_id, double depth): """Set the depth of a node in the hot start state. diff --git a/python/openswmm/engine/_links.pyi b/python/openswmm/engine/_links.pyi index ddfb2a6b0..b7a88be98 100644 --- a/python/openswmm/engine/_links.pyi +++ b/python/openswmm/engine/_links.pyi @@ -839,12 +839,39 @@ class Links: """ ... + def get_pump_stats_bulk(self) -> dict[str, npt.NDArray]: + """Return pump utilization statistics for **all** links in one call. + + This is the bulk equivalent of calling :py:meth:`get_stat_pump_cycles`, + :py:meth:`get_stat_pump_on_time`, and :py:meth:`get_stat_pump_volume` + for every link, with one C ABI crossing instead of C{3 * n_links}. + The GIL is released for the duration of the C call. + + Non-pump links carry a sentinel: C{cycles[i] == -1} and + C{on_time[i] == volume[i] == 0.0}. Filter pumps with + C{stats["cycles"] >= 0}. + + @return: A dict with three NumPy arrays of length C{n_links}: + ``cycles`` (C{int32}, ``-1`` for non-pumps); + ``on_time`` (C{float64}, seconds); + ``volume`` (C{float64}, ft3). + @rtype: dict[str, numpy.ndarray] + + .. versionadded:: 6.0.0 + """ + ... + # ==================================================================== # Bulk array access (numpy) + # + # Every method in this section releases the GIL for the duration of + # the underlying C call. See the package-level user guide for the + # full threading story. # ==================================================================== def get_flows_bulk(self) -> npt.NDArray[np.float64]: - """Return all link flows as a NumPy array. + """Return all link flows as a NumPy array. GIL is released during + the C call. @return: Array of shape C{(n_links,)} with dtype C{float64}. @rtype: numpy.typing.NDArray[numpy.float64] @@ -852,7 +879,8 @@ class Links: ... def set_flows_bulk(self, values: npt.NDArray[np.float64]) -> None: - """Set all link flows from a NumPy array. + """Set all link flows from a NumPy array. GIL is released during + the C call. @param values: Array of shape C{(n_links,)} with dtype C{float64}. @type values: numpy.typing.NDArray[numpy.float64] @@ -860,7 +888,8 @@ class Links: ... def get_depths_bulk(self) -> npt.NDArray[np.float64]: - """Return all link depths as a NumPy array. + """Return all link depths as a NumPy array. GIL is released during + the C call. @return: Array of shape C{(n_links,)} with dtype C{float64}. @rtype: numpy.typing.NDArray[numpy.float64] @@ -868,7 +897,8 @@ class Links: ... def get_quality_bulk(self, pollutant_idx: int) -> npt.NDArray[np.float64]: - """Return all link pollutant concentrations as a NumPy array. + """Return all link pollutant concentrations as a NumPy array. GIL + is released during the C call. @param pollutant_idx: Pollutant index. @type pollutant_idx: int @@ -877,6 +907,89 @@ class Links: """ ... + # ==================================================================== + # Phase 3 bulk getters — velocities / capacities / volumes / + # control_settings / target_settings / hyd_powers / ids. + # Each releases the GIL during the C call. + # ==================================================================== + + def get_velocities_bulk(self) -> npt.NDArray[np.float64]: + """Return cross-sectional velocities for all links as a NumPy + array (per-link ``q / area`` computed in C). GIL is released + during the C call. + + @return: Array of shape C{(n_links,)} with dtype C{float64} in + project length/time units. + + .. versionadded:: 6.0.0 + """ + ... + + def get_capacities_bulk(self) -> npt.NDArray[np.float64]: + """Return capacity ratios (``q / q_full``) for all links as a + NumPy array. GIL is released during the C call. + + @return: Array of shape C{(n_links,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... + + def get_volumes_bulk(self) -> npt.NDArray[np.float64]: + """Return stored volumes for all links as a NumPy array. GIL is + released during the C call. + + @return: Array of shape C{(n_links,)} with dtype C{float64} in + project volume units. + + .. versionadded:: 6.0.0 + """ + ... + + def get_control_settings_bulk(self) -> npt.NDArray[np.float64]: + """Return active control settings (0..1) for all links as a NumPy + array. GIL is released during the C call. + + @return: Array of shape C{(n_links,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... + + def get_target_settings_bulk(self) -> npt.NDArray[np.float64]: + """Return target control settings for all links as a NumPy array. + GIL is released during the C call. + + @return: Array of shape C{(n_links,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... + + def get_hyd_powers_bulk(self) -> npt.NDArray[np.float64]: + """Return hydraulic power dissipated in every link + (``P = gamma * |Q| * |h_up - h_dn|``, ft-lb/s) as a NumPy array. + GIL is released during the C call. + + @return: Array of shape C{(n_links,)} with dtype C{float64} in + ft-lb/s. Divide by 550 for horsepower. + + .. versionadded:: 6.0.0 + """ + ... + + def get_ids_bulk(self, stride: int = 64) -> list[str]: + """Return all link IDs in a single C call (stride-packed UTF-8). + GIL is released during the C copy. + + @param stride: Per-ID slot size in bytes (default 64). IDs longer + than ``stride - 1`` bytes are truncated. + @return: List of ``n_links`` Python strings. + + .. versionadded:: 6.0.0 + """ + ... + # ==================================================================== # Rename # ==================================================================== diff --git a/python/openswmm/engine/_links.pyx b/python/openswmm/engine/_links.pyx index e5dc72d6c..7bcc0e2d4 100644 --- a/python/openswmm/engine/_links.pyx +++ b/python/openswmm/engine/_links.pyx @@ -1082,12 +1082,75 @@ class Links: _check(swmm_link_get_hyd_power(h, i, &v)) return v + def get_pump_stats_bulk(self): + """Return pump utilization statistics for **all** links in one call. + + This is the bulk equivalent of calling :py:meth:`get_stat_pump_cycles`, + :py:meth:`get_stat_pump_on_time`, and :py:meth:`get_stat_pump_volume` + for every link. It performs a single C ABI crossing instead of + C{3 * n_links}, which makes it the preferred accessor when assembling + a network-wide pump summary. + + Non-pump links are tagged with a sentinel: ``cycles[i] == -1`` and + ``on_time[i] == volume[i] == 0.0``. Use the cycles sentinel (not the + zero values, which are also valid for an inactive pump) to filter: + + .. code-block:: python + + stats = links.get_pump_stats_bulk() + mask = stats["cycles"] >= 0 # pumps only + pump_indices = np.where(mask)[0] + total_volume = stats["volume"][mask].sum() + + The GIL is released for the duration of the C call. + + :returns: A dict with three NumPy arrays of length ``n_links``: + + * ``cycles`` — ``int32`` array of pump on/off cycle counts; + ``-1`` where the link is not a pump. + * ``on_time`` — ``float64`` array of total pump on-time in + seconds; ``0.0`` for non-pumps. + * ``volume`` — ``float64`` array of total volume pumped in + ft³; ``0.0`` for non-pumps. + + :rtype: dict[str, numpy.ndarray] + + .. versionadded:: 6.0.0 + + .. seealso:: + + :py:meth:`get_stat_pump_cycles`, + :py:meth:`get_stat_pump_on_time`, + :py:meth:`get_stat_pump_volume` — equivalent per-link scalar + accessors. + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + # Allocate NumPy buffers up-front; the C call only sees raw pointers + # so the GIL can be safely released during the iteration. + cdef np.ndarray[int, ndim=1] cycles = np.empty(n, dtype=np.intc) + cdef np.ndarray[double, ndim=1] on_time = np.empty(n, dtype=np.float64) + cdef np.ndarray[double, ndim=1] volume = np.empty(n, dtype=np.float64) + cdef int err + cdef int* p_cycles = cycles.data + cdef double* p_on = on_time.data + cdef double* p_vol = volume.data + with nogil: + err = swmm_link_get_pump_stats_bulk(h, p_cycles, p_on, p_vol, n) + _check(err) + return {"cycles": cycles, "on_time": on_time, "volume": volume} + # ==================================================================== # Bulk array access (numpy) # ==================================================================== + # Each bulk accessor below releases the GIL for its single C call — + # see the analogous comment block in `_nodes.pyx` for the rationale + # and the pre-/post-call pattern. + def get_flows_bulk(self): - """Return all link flows as a NumPy array. + """Return all link flows as a NumPy array. GIL is released during + the C call. @return: Array of shape C{(n_links,)} with dtype C{float64}. @rtype: numpy.ndarray @@ -1095,21 +1158,31 @@ class Links: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_link_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_link_get_flows_bulk(h, &buf[0], n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_link_get_flows_bulk(h, p, n) + _check(err) return buf def set_flows_bulk(self, np.ndarray[double, ndim=1] values): - """Set all link flows from a NumPy array. + """Set all link flows from a NumPy array. GIL is released during + the C call. @param values: Array of shape C{(n_links,)} with dtype C{float64}. @type values: numpy.ndarray """ cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_link_count(h) - _check(swmm_link_set_flows_bulk(h, &values[0], n)) + cdef const double* p = values.data + cdef int err + with nogil: + err = swmm_link_set_flows_bulk(h, p, n) + _check(err) def get_depths_bulk(self): - """Return all link depths as a NumPy array. + """Return all link depths as a NumPy array. GIL is released during + the C call. @return: Array of shape C{(n_links,)} with dtype C{float64}. @rtype: numpy.ndarray @@ -1117,11 +1190,16 @@ class Links: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_link_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_link_get_depths_bulk(h, &buf[0], n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_link_get_depths_bulk(h, p, n) + _check(err) return buf def get_quality_bulk(self, int pollutant_idx): - """Return all link pollutant concentrations as a NumPy array. + """Return all link pollutant concentrations as a NumPy array. GIL + is released during the C call. @param pollutant_idx: Pollutant index. @type pollutant_idx: int @@ -1131,9 +1209,181 @@ class Links: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_link_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_link_get_quality_bulk(h, pollutant_idx, &buf[0], n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_link_get_quality_bulk(h, pollutant_idx, p, n) + _check(err) return buf + # ------------------------------------------------------------------ + # Phase 3 bulk getters — velocities / capacities / volumes / + # control_settings / target_settings / hyd_powers / ids. + # + # Velocities, capacities, and hydraulic powers are derived quantities + # (per-link calculation in C, not a memcpy from a SoA column). The + # bulk forms still save the N-call ABI crossing cost. GIL is released + # for each C call. + # ------------------------------------------------------------------ + + def get_velocities_bulk(self): + """Return cross-sectional velocities for all links as a NumPy + array. Computed per link in C as ``q / area`` (area approximated + from ``d / y_full * a_full``). GIL is released during the C call. + + :returns: Array of shape ``(n_links,)``, dtype ``float64``, in + project length/time units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_link_get_velocities_bulk(h, p, n) + _check(err) + return buf + + def get_capacities_bulk(self): + """Return capacity ratios (``q / q_full``) for all links as a + NumPy array. GIL is released during the C call. + + :returns: Array of shape ``(n_links,)``, dtype ``float64``, + dimensionless ratio (>= 1 indicates surcharge). + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_link_get_capacities_bulk(h, p, n) + _check(err) + return buf + + def get_volumes_bulk(self): + """Return stored volumes for all links as a NumPy array. GIL is + released during the C call. + + :returns: Array of shape ``(n_links,)``, dtype ``float64``, in + project volume units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_link_get_volumes_bulk(h, p, n) + _check(err) + return buf + + def get_control_settings_bulk(self): + """Return active control settings (0..1) for all links as a NumPy + array. GIL is released during the C call. + + :returns: Array of shape ``(n_links,)``, dtype ``float64``. + ``0.0`` = closed, ``1.0`` = fully open. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_link_get_control_settings_bulk(h, p, n) + _check(err) + return buf + + def get_target_settings_bulk(self): + """Return target control settings for all links as a NumPy array. + GIL is released during the C call. + + :returns: Array of shape ``(n_links,)``, dtype ``float64``. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_link_get_target_settings_bulk(h, p, n) + _check(err) + return buf + + def get_hyd_powers_bulk(self): + """Return hydraulic power dissipated in every link as a NumPy + array. ``P = gamma * |Q| * |h_up - h_dn|`` (ft-lb/s). GIL is + released during the C call. + + :returns: Array of shape ``(n_links,)``, dtype ``float64``, + in ft-lb/s. Divide by 550 for horsepower. + :rtype: numpy.ndarray + + .. seealso:: + + :py:meth:`get_pump_stats_bulk` — pair this with ``cycles >= 0`` + to filter to pumps when assembling a pump-energy summary. + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_link_get_hyd_powers_bulk(h, p, n) + _check(err) + return buf + + def get_ids_bulk(self, int stride=64): + """Return all link IDs as a Python list of strings in a single C + call (stride-packed UTF-8). GIL is released during the C copy; + per-slot decoding runs afterwards. + + :param stride: Per-ID slot size in bytes (default 64). IDs + longer than ``stride - 1`` are truncated. + :type stride: int + :returns: List of ``n_links`` Python strings. + :rtype: list[str] + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + cdef np.ndarray[char, ndim=1, mode="c"] buf = np.zeros( + n * stride, dtype=np.int8) + cdef char* p = buf.data + cdef int err + with nogil: + err = swmm_link_get_ids_bulk(h, p, stride, n) + _check(err) + raw = bytes(buf) + ids = [] + for i in range(n): + slot = raw[i * stride:(i + 1) * stride] + nul = slot.find(b"\x00") + if nul >= 0: + slot = slot[:nul] + ids.append(slot.decode("utf-8")) + return ids + # ==================================================================== # Rename # ==================================================================== diff --git a/python/openswmm/engine/_nodes.pyi b/python/openswmm/engine/_nodes.pyi index ae6c0bbef..8b06f4234 100644 --- a/python/openswmm/engine/_nodes.pyi +++ b/python/openswmm/engine/_nodes.pyi @@ -724,13 +724,20 @@ class Nodes: # ==================================================================== # Bulk array access (numpy) + # + # Every method in this section releases the GIL for the duration of + # the underlying C call. Concurrent calls from multiple threads on + # independent Solver instances therefore execute their C work truly + # in parallel. See the package-level "Threading & multiprocessing" + # section in the user guide for the full picture. # ==================================================================== def get_depths_bulk(self) -> npt.NDArray[np.float64]: """Return all node depths as a NumPy array. Uses the bulk C API for a single C{memcpy} -- much faster than - calling L{get_depth} in a loop. + calling L{get_depth} in a loop. The GIL is released during the + C call. @return: Array of shape C{(n_nodes,)} with dtype C{float64}. @rtype: numpy.typing.NDArray[numpy.float64] @@ -738,7 +745,8 @@ class Nodes: ... def get_heads_bulk(self) -> npt.NDArray[np.float64]: - """Return all node heads as a NumPy array. + """Return all node heads as a NumPy array. GIL is released during + the C call. @return: Array of shape C{(n_nodes,)} with dtype C{float64}. @rtype: numpy.typing.NDArray[numpy.float64] @@ -746,7 +754,8 @@ class Nodes: ... def set_depths_bulk(self, values: npt.NDArray[np.float64]) -> None: - """Set all node depths from a NumPy array. + """Set all node depths from a NumPy array. GIL is released during + the C call. @param values: Array of shape C{(n_nodes,)} with dtype C{float64}. @type values: numpy.typing.NDArray[numpy.float64] @@ -754,7 +763,8 @@ class Nodes: ... def get_inflows_bulk(self) -> npt.NDArray[np.float64]: - """Return all node total inflows as a NumPy array. + """Return all node total inflows as a NumPy array. GIL is released + during the C call. @return: Array of shape C{(n_nodes,)} with dtype C{float64}. @rtype: numpy.typing.NDArray[numpy.float64] @@ -762,7 +772,8 @@ class Nodes: ... def get_overflows_bulk(self) -> npt.NDArray[np.float64]: - """Return all node overflow rates as a NumPy array. + """Return all node overflow rates as a NumPy array. GIL is released + during the C call. @return: Array of shape C{(n_nodes,)} with dtype C{float64}. @rtype: numpy.typing.NDArray[numpy.float64] @@ -770,7 +781,8 @@ class Nodes: ... def set_lat_inflows_bulk(self, values: npt.NDArray[np.float64]) -> None: - """Set all node lateral inflows from a NumPy array. + """Set all node lateral inflows from a NumPy array. GIL is released + during the C call. @param values: Array of shape C{(n_nodes,)} with dtype C{float64}. @type values: numpy.typing.NDArray[numpy.float64] @@ -779,6 +791,7 @@ class Nodes: def get_quality_bulk(self, pollutant_idx: int) -> npt.NDArray[np.float64]: """Return all node concentrations for a pollutant as a NumPy array. + GIL is released during the C call. @param pollutant_idx: Pollutant index. @type pollutant_idx: int @@ -787,6 +800,82 @@ class Nodes: """ ... + # ==================================================================== + # Phase 3 bulk getters — added in OpenSWMM 6.0.0 to eliminate the + # N-round-trip cost of per-node scalar accessors in whole-network + # consumers (notably the MCP server's get_node_info(all) path). + # Each releases the GIL during the C call. + # ==================================================================== + + def get_volumes_bulk(self) -> npt.NDArray[np.float64]: + """Return all node stored volumes as a NumPy array. GIL is released + during the C call. + + @return: Array of shape C{(n_nodes,)} with dtype C{float64} in + project volume units. + @rtype: numpy.typing.NDArray[numpy.float64] + + .. versionadded:: 6.0.0 + """ + ... + + def get_outflows_bulk(self) -> npt.NDArray[np.float64]: + """Return current outflows for all nodes as a NumPy array. GIL is + released during the C call. + + @return: Array of shape C{(n_nodes,)} with dtype C{float64} in + project flow units. + @rtype: numpy.typing.NDArray[numpy.float64] + + .. versionadded:: 6.0.0 + """ + ... + + def get_losses_bulk(self) -> npt.NDArray[np.float64]: + """Return per-node losses (evaporation + seepage) as a NumPy array. + GIL is released during the C call. + + @return: Array of shape C{(n_nodes,)} with dtype C{float64} in + project flow units. + @rtype: numpy.typing.NDArray[numpy.float64] + + .. versionadded:: 6.0.0 + """ + ... + + def get_lateral_inflows_bulk(self) -> npt.NDArray[np.float64]: + """Return current lateral inflows for all nodes as a NumPy array. + GIL is released during the C call. + + Explicitly-named successor to L{get_inflows_bulk}; both methods + read the same ``lat_flow`` SoA column. Prefer this name in new + code. + + @return: Array of shape C{(n_nodes,)} with dtype C{float64} in + project flow units. + @rtype: numpy.typing.NDArray[numpy.float64] + + .. versionadded:: 6.0.0 + """ + ... + + def get_ids_bulk(self, stride: int = 64) -> list[str]: + """Return all node IDs in a single C call (stride-packed UTF-8). + + Replaces a Python-level ``[get_id(i) for i in range(count)]`` loop + with one C call. GIL is released during the C copy; UTF-8 decoding + happens afterwards with the GIL re-held. + + @param stride: Per-ID slot size in bytes (default 64). IDs longer + than C{stride - 1} bytes are truncated. + @type stride: int + @return: List of ``n_nodes`` Python strings. + @rtype: list[str] + + .. versionadded:: 6.0.0 + """ + ... + # ==================================================================== # Divider # ==================================================================== diff --git a/python/openswmm/engine/_nodes.pyx b/python/openswmm/engine/_nodes.pyx index 816062c1e..8813f9439 100644 --- a/python/openswmm/engine/_nodes.pyx +++ b/python/openswmm/engine/_nodes.pyx @@ -923,11 +923,24 @@ class Nodes: # Bulk array access (numpy) # ==================================================================== + # ------------------------------------------------------------------ + # Bulk accessors — every method below releases the GIL for the C call. + # + # The pattern is: + # 1. Resolve handle and allocate the NumPy buffer with the GIL held. + # 2. Take a raw `double*` pointer to the buffer's storage. + # 3. `with nogil:` around the single C call — no Python object access + # is performed inside this block, so the GIL is truly free for + # other threads (eg a second engine handle stepping in parallel). + # 4. Check the return code with the GIL re-held. + # ------------------------------------------------------------------ + def get_depths_bulk(self): """Return all node depths as a NumPy array. Uses the bulk C API for a single C{memcpy} -- much faster than - calling L{get_depth} in a loop. + calling L{get_depth} in a loop. The GIL is released for the + duration of the C call. @return: Array of shape C{(n_nodes,)} with dtype C{float64}. @rtype: numpy.ndarray @@ -935,11 +948,16 @@ class Nodes: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_node_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_node_get_depths_bulk(h, &buf[0], n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_node_get_depths_bulk(h, p, n) + _check(err) return buf def get_heads_bulk(self): - """Return all node heads as a NumPy array. + """Return all node heads as a NumPy array. GIL is released during + the C call. @return: Array of shape C{(n_nodes,)} with dtype C{float64}. @rtype: numpy.ndarray @@ -947,21 +965,31 @@ class Nodes: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_node_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_node_get_heads_bulk(h, &buf[0], n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_node_get_heads_bulk(h, p, n) + _check(err) return buf def set_depths_bulk(self, np.ndarray[double, ndim=1] values): - """Set all node depths from a NumPy array. + """Set all node depths from a NumPy array. GIL is released during + the C call. @param values: Array of shape C{(n_nodes,)} with dtype C{float64}. @type values: numpy.ndarray """ cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_node_count(h) - _check(swmm_node_set_depths_bulk(h, &values[0], n)) + cdef const double* p = values.data + cdef int err + with nogil: + err = swmm_node_set_depths_bulk(h, p, n) + _check(err) def get_inflows_bulk(self): - """Return all node total inflows as a NumPy array. + """Return all node total inflows as a NumPy array. GIL is released + during the C call. @return: Array of shape C{(n_nodes,)} with dtype C{float64}. @rtype: numpy.ndarray @@ -969,11 +997,16 @@ class Nodes: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_node_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_node_get_inflows_bulk(h, &buf[0], n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_node_get_inflows_bulk(h, p, n) + _check(err) return buf def get_overflows_bulk(self): - """Return all node overflow rates as a NumPy array. + """Return all node overflow rates as a NumPy array. GIL is released + during the C call. @return: Array of shape C{(n_nodes,)} with dtype C{float64}. @rtype: numpy.ndarray @@ -981,21 +1014,31 @@ class Nodes: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_node_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_node_get_overflows_bulk(h, &buf[0], n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_node_get_overflows_bulk(h, p, n) + _check(err) return buf def set_lat_inflows_bulk(self, np.ndarray[double, ndim=1] values): - """Set all node lateral inflows from a NumPy array. + """Set all node lateral inflows from a NumPy array. GIL is released + during the C call. @param values: Array of shape C{(n_nodes,)} with dtype C{float64}. @type values: numpy.ndarray """ cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_node_count(h) - _check(swmm_node_set_lat_inflows_bulk(h, &values[0], n)) + cdef const double* p = values.data + cdef int err + with nogil: + err = swmm_node_set_lat_inflows_bulk(h, p, n) + _check(err) def get_quality_bulk(self, int pollutant_idx): """Return all node concentrations for a pollutant as a NumPy array. + GIL is released during the C call. @param pollutant_idx: Pollutant index. @type pollutant_idx: int @@ -1005,9 +1048,149 @@ class Nodes: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_node_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_node_get_quality_bulk(h, pollutant_idx, &buf[0], n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_node_get_quality_bulk(h, pollutant_idx, p, n) + _check(err) + return buf + + # ------------------------------------------------------------------ + # Phase 3 bulk getters — volumes / outflows / losses / + # lateral_inflows / ids. Each replaces a per-node Python loop in + # MCP-style consumers; the C side is a single memcpy (or, for ids, + # a single contiguous string copy). GIL is released during each + # C call following the same pattern as the existing bulk getters. + # ------------------------------------------------------------------ + + def get_volumes_bulk(self): + """Return all node stored volumes as a NumPy array. GIL is + released during the C call. + + :returns: Array of shape ``(n_nodes,)``, dtype ``float64``, + in project volume units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_node_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_node_get_volumes_bulk(h, p, n) + _check(err) + return buf + + def get_outflows_bulk(self): + """Return current outflows for all nodes as a NumPy array. GIL + is released during the C call. + + :returns: Array of shape ``(n_nodes,)``, dtype ``float64``, + in project flow units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_node_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_node_get_outflows_bulk(h, p, n) + _check(err) + return buf + + def get_losses_bulk(self): + """Return per-node losses (evaporation + seepage) as a NumPy + array. GIL is released during the C call. + + :returns: Array of shape ``(n_nodes,)``, dtype ``float64``, + in project flow units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_node_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_node_get_losses_bulk(h, p, n) + _check(err) return buf + def get_lateral_inflows_bulk(self): + """Return current lateral inflows for all nodes as a NumPy + array. GIL is released during the C call. + + .. note:: + + This is the explicitly-named successor to the older + :py:meth:`get_inflows_bulk` — both methods currently read + the same ``lat_flow`` SoA column on the C side. Prefer + :py:meth:`get_lateral_inflows_bulk` in new code; the older + name is retained for backward compatibility. + + :returns: Array of shape ``(n_nodes,)``, dtype ``float64``, + in project flow units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_node_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_node_get_lateral_inflows_bulk(h, p, n) + _check(err) + return buf + + def get_ids_bulk(self, int stride=64): + """Return all node IDs as a Python list of strings in a single C + call. GIL is released during the C call; per-slot UTF-8 decoding + runs after with the GIL re-held. + + :param stride: Per-ID slot size in bytes (default 64). The C + function NUL-terminates each ID within its slot; + IDs longer than ``stride - 1`` bytes are truncated. + For SWMM models the legacy 31-character ID limit + means the default of 64 is comfortable for all + realistic models. + :type stride: int + :returns: List of ``n_nodes`` Python strings. + :rtype: list[str] + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_node_count(h) + # Single contiguous buffer; C zero-fills it so trailing bytes + # after each ID are NUL. + cdef np.ndarray[char, ndim=1, mode="c"] buf = np.zeros( + n * stride, dtype=np.int8) + cdef char* p = buf.data + cdef int err + with nogil: + err = swmm_node_get_ids_bulk(h, p, stride, n) + _check(err) + # Pure-Python slice + decode; cheap compared to the avoided + # N round-trips through swmm_node_id. + raw = bytes(buf) + ids = [] + for i in range(n): + slot = raw[i * stride:(i + 1) * stride] + nul = slot.find(b"\x00") + if nul >= 0: + slot = slot[:nul] + ids.append(slot.decode("utf-8")) + return ids + # ==================================================================== # Divider # ==================================================================== diff --git a/python/openswmm/engine/_output_reader.pyi b/python/openswmm/engine/_output_reader.pyi index 3f5639a65..6c1adb31d 100644 --- a/python/openswmm/engine/_output_reader.pyi +++ b/python/openswmm/engine/_output_reader.pyi @@ -277,7 +277,8 @@ class OutputReader: def get_subcatch_series( self, subcatch_idx: int, var: int, start: int, end: int ) -> npt.NDArray[np.float32]: - """Return a time series of a subcatchment variable. + """Return a time series of a subcatchment variable. GIL is + released for the duration of the disk read. Wraps C{swmm_output_get_subcatch_series}. @@ -337,7 +338,8 @@ class OutputReader: def get_node_series( self, node_idx: int, var: int, start: int, end: int ) -> npt.NDArray[np.float32]: - """Return a time series of a node variable. + """Return a time series of a node variable. GIL is released for + the duration of the disk read. Wraps C{swmm_output_get_node_series}. @@ -397,7 +399,8 @@ class OutputReader: def get_link_series( self, link_idx: int, var: int, start: int, end: int ) -> npt.NDArray[np.float32]: - """Return a time series of a link variable. + """Return a time series of a link variable. GIL is released for + the duration of the disk read. Wraps C{swmm_output_get_link_series}. @@ -455,7 +458,8 @@ class OutputReader: def get_system_series( self, var: int, start: int, end: int ) -> npt.NDArray[np.float32]: - """Return a time series of a system-level variable. + """Return a time series of a system-level variable. GIL is + released for the duration of the disk read. Wraps C{swmm_output_get_system_series}. diff --git a/python/openswmm/engine/_output_reader.pyx b/python/openswmm/engine/_output_reader.pyx index 1e92d2825..3770e2a49 100644 --- a/python/openswmm/engine/_output_reader.pyx +++ b/python/openswmm/engine/_output_reader.pyx @@ -304,7 +304,8 @@ cdef class OutputReader: def get_subcatch_series(self, int subcatch_idx, int var, int start, int end) -> np.ndarray: - """Return a time series of a subcatchment variable. + """Return a time series of a subcatchment variable. GIL is + released for the duration of the disk read. Wraps C{swmm_output_get_subcatch_series}. @@ -322,8 +323,11 @@ cdef class OutputReader: """ cdef int n = end - start + 1 cdef np.ndarray[float, ndim=1] buf = np.empty(n, dtype=np.float32) - cdef int rc = swmm_output_get_subcatch_series( - self._handle, subcatch_idx, var, start, end, buf.data) + cdef SWMM_Output h = self._handle + cdef float* p = buf.data + cdef int rc + with nogil: + rc = swmm_output_get_subcatch_series(h, subcatch_idx, var, start, end, p) if rc != 0: raise RuntimeError(f"Output read error {rc}") return buf @@ -377,7 +381,8 @@ cdef class OutputReader: def get_node_series(self, int node_idx, int var, int start, int end) -> np.ndarray: - """Return a time series of a node variable. + """Return a time series of a node variable. GIL is released for + the duration of the disk read. Wraps C{swmm_output_get_node_series}. @@ -395,8 +400,11 @@ cdef class OutputReader: """ cdef int n = end - start + 1 cdef np.ndarray[float, ndim=1] buf = np.empty(n, dtype=np.float32) - cdef int rc = swmm_output_get_node_series( - self._handle, node_idx, var, start, end, buf.data) + cdef SWMM_Output h = self._handle + cdef float* p = buf.data + cdef int rc + with nogil: + rc = swmm_output_get_node_series(h, node_idx, var, start, end, p) if rc != 0: raise RuntimeError(f"Output read error {rc}") return buf @@ -450,7 +458,8 @@ cdef class OutputReader: def get_link_series(self, int link_idx, int var, int start, int end) -> np.ndarray: - """Return a time series of a link variable. + """Return a time series of a link variable. GIL is released for + the duration of the disk read. Wraps C{swmm_output_get_link_series}. @@ -468,8 +477,11 @@ cdef class OutputReader: """ cdef int n = end - start + 1 cdef np.ndarray[float, ndim=1] buf = np.empty(n, dtype=np.float32) - cdef int rc = swmm_output_get_link_series( - self._handle, link_idx, var, start, end, buf.data) + cdef SWMM_Output h = self._handle + cdef float* p = buf.data + cdef int rc + with nogil: + rc = swmm_output_get_link_series(h, link_idx, var, start, end, p) if rc != 0: raise RuntimeError(f"Output read error {rc}") return buf @@ -521,7 +533,8 @@ cdef class OutputReader: return v def get_system_series(self, int var, int start, int end) -> np.ndarray: - """Return a time series of a system-level variable. + """Return a time series of a system-level variable. GIL is + released for the duration of the disk read. Wraps C{swmm_output_get_system_series}. @@ -537,8 +550,11 @@ cdef class OutputReader: """ cdef int n = end - start + 1 cdef np.ndarray[float, ndim=1] buf = np.empty(n, dtype=np.float32) - cdef int rc = swmm_output_get_system_series( - self._handle, var, start, end, buf.data) + cdef SWMM_Output h = self._handle + cdef float* p = buf.data + cdef int rc + with nogil: + rc = swmm_output_get_system_series(h, var, start, end, p) if rc != 0: raise RuntimeError(f"Output read error {rc}") return buf diff --git a/python/openswmm/engine/_solver.pyi b/python/openswmm/engine/_solver.pyi index 4e1880672..919b19d9e 100644 --- a/python/openswmm/engine/_solver.pyi +++ b/python/openswmm/engine/_solver.pyi @@ -193,6 +193,11 @@ class Solver: if rc != 0: break + The GIL is released for the duration of the C step, so another + Python thread can advance an independent L{Solver} in parallel. + Registered step-begin / step-end callbacks reacquire the GIL via + their Cython trampolines and remain safe. + @return: Error code from the C API (C{0} on success, non-zero on failure). @rtype: int @@ -202,7 +207,9 @@ class Solver: def stride(self, n_steps: int) -> int: """Advance the simulation by C{n_steps} timesteps in one call. - Updates L{elapsed} as a side effect. + Updates L{elapsed} as a side effect. The GIL is released for the + duration of the C call — particularly valuable for stride() since + it amortises a single GIL release over many C-level steps. @param n_steps: Number of timesteps to advance. @type n_steps: int diff --git a/python/openswmm/engine/_solver.pyx b/python/openswmm/engine/_solver.pyx index c59a2942e..e26b0f618 100644 --- a/python/openswmm/engine/_solver.pyx +++ b/python/openswmm/engine/_solver.pyx @@ -232,7 +232,15 @@ cdef class Solver: @rtype: int """ cdef double elapsed = 0.0 - cdef int rc = swmm_engine_step(self._handle, &elapsed) + cdef SWMM_Engine h = self._handle + cdef int rc + # Release the GIL for the duration of the C step so that another + # Python thread can step an independent engine handle in parallel. + # Any registered step_begin/step_end callbacks reacquire the GIL via + # `noexcept with gil:` in their trampolines (see _step_begin_trampoline + # above), so this is safe even with callbacks active. + with nogil: + rc = swmm_engine_step(h, &elapsed) self._elapsed = elapsed return rc @@ -248,7 +256,13 @@ cdef class Solver: @rtype: int """ cdef double elapsed = 0.0 - cdef int rc = swmm_engine_stride(self._handle, n_steps, &elapsed) + cdef SWMM_Engine h = self._handle + cdef int rc + # Same nogil reasoning as `step()` — a stride is conceptually a tight + # loop of N steps inside the C engine, so releasing the GIL here is + # particularly valuable for parallel simulations. + with nogil: + rc = swmm_engine_stride(h, n_steps, &elapsed) self._elapsed = elapsed return rc diff --git a/python/openswmm/engine/_statistics.pyi b/python/openswmm/engine/_statistics.pyi index 161deea51..25c87eab4 100644 --- a/python/openswmm/engine/_statistics.pyi +++ b/python/openswmm/engine/_statistics.pyi @@ -220,12 +220,15 @@ class Statistics: # ==================================================================== # Cumulative totals (bulk array reads) + # + # Every bulk getter in this section releases the GIL for the C call. # ==================================================================== def node_max_depth_bulk(self) -> npt.NDArray[np.float64]: """Return maximum depths for all nodes as a NumPy array. - Wraps C{swmm_stat_node_max_depth_bulk}. + Wraps C{swmm_stat_node_max_depth_bulk}. GIL is released during + the C call. @return: Array of shape C{(n_nodes,)} with dtype C{float64}. @rtype: np.ndarray @@ -236,7 +239,8 @@ class Statistics: def link_max_flow_bulk(self) -> npt.NDArray[np.float64]: """Return maximum flows for all links as a NumPy array. - Wraps C{swmm_stat_link_max_flow_bulk}. + Wraps C{swmm_stat_link_max_flow_bulk}. GIL is released during + the C call. @return: Array of shape C{(n_links,)} with dtype C{float64}. @rtype: np.ndarray @@ -247,10 +251,62 @@ class Statistics: def subcatch_runoff_vol_bulk(self) -> npt.NDArray[np.float64]: """Return total runoff volumes for all subcatchments as a NumPy array. - Wraps C{swmm_stat_subcatch_runoff_vol_bulk}. + Wraps C{swmm_stat_subcatch_runoff_vol_bulk}. GIL is released + during the C call. @return: Array of shape C{(n_subcatchments,)} with dtype C{float64}. @rtype: np.ndarray @raise EngineError: If the underlying C call fails. """ ... + + # ==================================================================== + # Phase 3 statistics bulk getters — flooding + peak runoff. Each + # releases the GIL during the C call. + # ==================================================================== + + def node_max_overflow_bulk(self) -> npt.NDArray[np.float64]: + """Return maximum overflow rates for all nodes as a NumPy array. + + Wraps C{swmm_stat_node_max_overflow_bulk}. GIL is released during + the C call. + + @return: Array of shape C{(n_nodes,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... + + def node_vol_flooded_bulk(self) -> npt.NDArray[np.float64]: + """Return total flooded volume for all nodes as a NumPy array. + + Wraps C{swmm_stat_node_vol_flooded_bulk}. GIL is released during + the C call. + + @return: Array of shape C{(n_nodes,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... + + def node_time_flooded_bulk(self) -> npt.NDArray[np.float64]: + """Return cumulative time-flooded (hours) for all nodes as a NumPy + array. Wraps C{swmm_stat_node_time_flooded_bulk}. GIL is released + during the C call. + + @return: Array of shape C{(n_nodes,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... + + def subcatch_max_runoff_bulk(self) -> npt.NDArray[np.float64]: + """Return peak runoff rates for all subcatchments as a NumPy + array. Wraps C{swmm_stat_subcatch_max_runoff_bulk}. GIL is + released during the C call. + + @return: Array of shape C{(n_subcatchments,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... diff --git a/python/openswmm/engine/_statistics.pyx b/python/openswmm/engine/_statistics.pyx index 807f4cefe..67bf75497 100644 --- a/python/openswmm/engine/_statistics.pyx +++ b/python/openswmm/engine/_statistics.pyx @@ -261,7 +261,8 @@ class Statistics: def node_max_depth_bulk(self) -> np.ndarray: """Return maximum depths for all nodes as a NumPy array. - Wraps C{swmm_stat_node_max_depth_bulk}. + Wraps C{swmm_stat_node_max_depth_bulk}. The GIL is released + during the C call. @return: Array of shape C{(n_nodes,)} with dtype C{float64}. @rtype: np.ndarray @@ -270,13 +271,18 @@ class Statistics: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_node_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_stat_node_max_depth_bulk(h, buf.data, n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_stat_node_max_depth_bulk(h, p, n) + _check(err) return buf def link_max_flow_bulk(self) -> np.ndarray: """Return maximum flows for all links as a NumPy array. - Wraps C{swmm_stat_link_max_flow_bulk}. + Wraps C{swmm_stat_link_max_flow_bulk}. The GIL is released + during the C call. @return: Array of shape C{(n_links,)} with dtype C{float64}. @rtype: np.ndarray @@ -285,13 +291,18 @@ class Statistics: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_link_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_stat_link_max_flow_bulk(h, buf.data, n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_stat_link_max_flow_bulk(h, p, n) + _check(err) return buf def subcatch_runoff_vol_bulk(self) -> np.ndarray: """Return total runoff volumes for all subcatchments as a NumPy array. - Wraps C{swmm_stat_subcatch_runoff_vol_bulk}. + Wraps C{swmm_stat_subcatch_runoff_vol_bulk}. The GIL is released + during the C call. @return: Array of shape C{(n_subcatchments,)} with dtype C{float64}. @rtype: np.ndarray @@ -300,5 +311,102 @@ class Statistics: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_subcatch_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_stat_subcatch_runoff_vol_bulk(h, buf.data, n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_stat_subcatch_runoff_vol_bulk(h, p, n) + _check(err) + return buf + + # ------------------------------------------------------------------ + # Phase 3 statistics bulk getters — flooding + peak runoff. + # Each is a simple SoA memcpy; GIL is released during the C call. + # ------------------------------------------------------------------ + + def node_max_overflow_bulk(self) -> np.ndarray: + """Return maximum overflow rates for all nodes as a NumPy array. + + Wraps C{swmm_stat_node_max_overflow_bulk}. GIL is released during + the C call. + + :returns: Array of shape ``(n_nodes,)``, dtype ``float64``, in + project flow units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_node_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_stat_node_max_overflow_bulk(h, p, n) + _check(err) + return buf + + def node_vol_flooded_bulk(self) -> np.ndarray: + """Return total flooded volume for all nodes as a NumPy array. + + Wraps C{swmm_stat_node_vol_flooded_bulk}. GIL is released during + the C call. + + :returns: Array of shape ``(n_nodes,)``, dtype ``float64``, in + project volume units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_node_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_stat_node_vol_flooded_bulk(h, p, n) + _check(err) + return buf + + def node_time_flooded_bulk(self) -> np.ndarray: + """Return cumulative time-flooded for all nodes as a NumPy array. + + Wraps C{swmm_stat_node_time_flooded_bulk}. GIL is released during + the C call. + + :returns: Array of shape ``(n_nodes,)``, dtype ``float64``, in + hours (consistent with the scalar accessor). + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_node_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_stat_node_time_flooded_bulk(h, p, n) + _check(err) + return buf + + def subcatch_max_runoff_bulk(self) -> np.ndarray: + """Return peak runoff rates for all subcatchments as a NumPy array. + + Wraps C{swmm_stat_subcatch_max_runoff_bulk}. GIL is released + during the C call. + + :returns: Array of shape ``(n_subcatchments,)``, dtype + ``float64``, in project flow units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_subcatch_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_stat_subcatch_max_runoff_bulk(h, p, n) + _check(err) return buf diff --git a/python/openswmm/engine/_subcatchments.pyi b/python/openswmm/engine/_subcatchments.pyi index e51d0283e..dfe737038 100644 --- a/python/openswmm/engine/_subcatchments.pyi +++ b/python/openswmm/engine/_subcatchments.pyi @@ -677,13 +677,17 @@ class Subcatchments: # ==================================================================== # Bulk array access (numpy) + # + # Every method in this section releases the GIL for the duration of + # the underlying C call. # ==================================================================== def get_runoff_bulk(self) -> npt.NDArray[np.float64]: """Return all subcatchment runoff rates as a NumPy array. Uses the bulk C API for a single C{memcpy} -- much faster than - calling L{get_runoff} in a loop. + calling L{get_runoff} in a loop. GIL is released during the C + call. @return: Array of shape C{(n_subcatchments,)} with dtype C{float64}. @rtype: numpy.typing.NDArray[numpy.float64] @@ -692,6 +696,7 @@ class Subcatchments: def get_quality_bulk(self, pollutant_idx: int) -> npt.NDArray[np.float64]: """Return all subcatchment pollutant concentrations as a NumPy array. + GIL is released during the C call. @param pollutant_idx: Pollutant index. @type pollutant_idx: int @@ -700,6 +705,66 @@ class Subcatchments: """ ... + # ==================================================================== + # Phase 3 bulk getters — rainfall / evap / infil / snow_depth / ids. + # Each releases the GIL during the C call. + # ==================================================================== + + def get_rainfall_bulk(self) -> npt.NDArray[np.float64]: + """Return rainfall rates for all subcatchments as a NumPy array. + GIL is released during the C call. + + @return: Array of shape C{(n_subcatchments,)} with dtype C{float64} + in project rainfall units. + + .. versionadded:: 6.0.0 + """ + ... + + def get_evap_bulk(self) -> npt.NDArray[np.float64]: + """Return evaporation losses for all subcatchments as a NumPy + array. GIL is released during the C call. + + @return: Array of shape C{(n_subcatchments,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... + + def get_infil_bulk(self) -> npt.NDArray[np.float64]: + """Return infiltration losses for all subcatchments as a NumPy + array. GIL is released during the C call. + + @return: Array of shape C{(n_subcatchments,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... + + def get_snow_depth_bulk(self) -> npt.NDArray[np.float64]: + """Return snow depths for all subcatchments as a NumPy array. + Currently returns zeros for every entry (mirrors the scalar + :py:meth:`get_snow_depth` placeholder pending snow-state + integration). GIL is released during the C call. + + @return: Array of shape C{(n_subcatchments,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... + + def get_ids_bulk(self, stride: int = 64) -> list[str]: + """Return all subcatchment IDs in a single C call (stride-packed + UTF-8). GIL is released during the C copy. + + @param stride: Per-ID slot size in bytes (default 64). IDs longer + than ``stride - 1`` bytes are truncated. + @return: List of ``n_subcatchments`` Python strings. + + .. versionadded:: 6.0.0 + """ + ... + # ==================================================================== # Rename # ==================================================================== diff --git a/python/openswmm/engine/_subcatchments.pyx b/python/openswmm/engine/_subcatchments.pyx index b161bd9ed..1d38ee5f0 100644 --- a/python/openswmm/engine/_subcatchments.pyx +++ b/python/openswmm/engine/_subcatchments.pyx @@ -836,7 +836,8 @@ class Subcatchments: """Return all subcatchment runoff rates as a NumPy array. Uses the bulk C API for a single C{memcpy} -- much faster than - calling L{get_runoff} in a loop. + calling L{get_runoff} in a loop. GIL is released during the C call, + so a peer thread can step an independent engine in parallel. @return: Array of shape C{(n_subcatchments,)} with dtype C{float64}. @rtype: numpy.ndarray @@ -844,11 +845,16 @@ class Subcatchments: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_subcatch_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_subcatch_get_runoff_bulk(h, &buf[0], n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_subcatch_get_runoff_bulk(h, p, n) + _check(err) return buf def get_quality_bulk(self, int pollutant_idx): """Return all subcatchment pollutant concentrations as a NumPy array. + GIL is released during the C call. @param pollutant_idx: Pollutant index. @type pollutant_idx: int @@ -858,9 +864,136 @@ class Subcatchments: cdef SWMM_Engine h = self._solver.handle cdef int n = swmm_subcatch_count(h) cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) - _check(swmm_subcatch_get_quality_bulk(h, pollutant_idx, &buf[0], n)) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_subcatch_get_quality_bulk(h, pollutant_idx, p, n) + _check(err) return buf + # ------------------------------------------------------------------ + # Phase 3 bulk getters — rainfall / evap / infil / snow_depth / ids. + # GIL is released for each C call following the established pattern. + # ------------------------------------------------------------------ + + def get_rainfall_bulk(self): + """Return rainfall rates for all subcatchments as a NumPy array. + GIL is released during the C call. + + :returns: Array of shape ``(n_subcatchments,)``, dtype + ``float64``, in project rainfall units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_subcatch_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_subcatch_get_rainfall_bulk(h, p, n) + _check(err) + return buf + + def get_evap_bulk(self): + """Return evaporation losses for all subcatchments as a NumPy + array. GIL is released during the C call. + + :returns: Array of shape ``(n_subcatchments,)``, dtype + ``float64``, in project flow/rainfall units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_subcatch_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_subcatch_get_evap_bulk(h, p, n) + _check(err) + return buf + + def get_infil_bulk(self): + """Return infiltration losses for all subcatchments as a NumPy + array. GIL is released during the C call. + + :returns: Array of shape ``(n_subcatchments,)``, dtype + ``float64``, in project flow/rainfall units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_subcatch_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_subcatch_get_infil_bulk(h, p, n) + _check(err) + return buf + + def get_snow_depth_bulk(self): + """Return snow depths for all subcatchments as a NumPy array. + GIL is released during the C call. + + .. note:: + + Mirrors the scalar :py:meth:`get_snow_depth` placeholder: + returns zeros for every entry until full snow-state + integration with ``SubcatchData`` lands. + + :returns: Array of shape ``(n_subcatchments,)``, dtype + ``float64``. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_subcatch_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_subcatch_get_snow_depth_bulk(h, p, n) + _check(err) + return buf + + def get_ids_bulk(self, int stride=64): + """Return all subcatchment IDs as a Python list of strings in a + single C call (stride-packed UTF-8). GIL is released during the + C copy; per-slot decoding runs afterwards. + + :param stride: Per-ID slot size in bytes (default 64). IDs + longer than ``stride - 1`` are truncated. + :type stride: int + :returns: List of ``n_subcatchments`` Python strings. + :rtype: list[str] + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_subcatch_count(h) + cdef np.ndarray[char, ndim=1, mode="c"] buf = np.zeros( + n * stride, dtype=np.int8) + cdef char* p = buf.data + cdef int err + with nogil: + err = swmm_subcatch_get_ids_bulk(h, p, stride, n) + _check(err) + raw = bytes(buf) + ids = [] + for i in range(n): + slot = raw[i * stride:(i + 1) * stride] + nul = slot.find(b"\x00") + if nul >= 0: + slot = slot[:nul] + ids.append(slot.decode("utf-8")) + return ids + # ==================================================================== # Rename # ==================================================================== diff --git a/python/pyproject.toml b/python/pyproject.toml index 482947e3d..4e13ca36a 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -290,3 +290,6 @@ environment = { VCPKG_ROOT = "$VCPKG_ROOT" } [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v --tb=short" +markers = [ + "slow: tests that take more than ~1s of wall time (concurrent-simulation, perf checks). Skip with -m 'not slow'.", +] diff --git a/python/tests/engine/test_concurrent_simulation.py b/python/tests/engine/test_concurrent_simulation.py new file mode 100644 index 000000000..183ecdbb7 --- /dev/null +++ b/python/tests/engine/test_concurrent_simulation.py @@ -0,0 +1,224 @@ +"""Concurrent-simulation tests — verify the GIL is released during the C calls. + +These tests exercise Phase 2a of the C-API/Bindings improvement plan +(`docs/C_API_BINDINGS_MCP_IMPROVEMENT_PLAN.md`): the engine `step`/`stride` +calls and the `*_bulk` getters/setters on Nodes/Links/Subcatchments now wrap +their C call in `with nogil:` so that a second Python thread can advance an +independent engine handle in parallel. + +The contract under test: + + 1. Two independent ``Solver`` instances driven from two threads must + complete in less wall-clock time than the sum of their single-threaded + runtimes — proving the GIL is genuinely released during ``step``. + 2. Bulk getters called concurrently from many threads against the same + solver must complete without deadlock, exception, or corruption (each + call writes into its own caller-allocated NumPy array; the C engine's + read of the state vectors is intrinsically thread-safe for reads). + 3. The wall-clock benefit of (1) is enough to be statistically detectable + above noise. We require strictly *better* than serial, not a hard + speedup ratio — busy CI machines and small fixtures suppress the + theoretical 2× ceiling. + +Notes on `pytest -k`: + * Marked with ``@pytest.mark.slow`` because the parallelism check needs + to do enough work to dominate startup overhead (~1 second per run on a + laptop). Skip in fast smoke runs with ``-m "not slow"``. +""" + +from __future__ import annotations + +import os +import threading +import time + +import numpy as np +import pytest + +from openswmm.engine import EngineState, Links, Nodes, Solver, Subcatchments + +from tests.engine.conftest import SITE_DRAINAGE_INP + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _run_full_simulation(inp: str, rpt: str, out: str) -> int: + """Drive a fresh Solver through its lifecycle and return the step count. + + Used as the unit of work in the parallelism comparison. Each call + creates its own engine handle, so independent threads do not share + SWMM state. + """ + s = Solver(inp, rpt, out) + try: + s.open() + s.initialize() + s.start() + n = 0 + while s.state == EngineState.RUNNING: + rc = s.step() + if rc != 0: + break + n += 1 + s.end() + s.close() + finally: + s.destroy() + return n + + +# --------------------------------------------------------------------------- +# Test 1 — engine.step() releases the GIL +# --------------------------------------------------------------------------- + +@pytest.mark.slow +def test_two_engines_run_concurrently(tmp_path): + """Two engines stepped from two threads should finish faster than + sequentially stepping the same two engines on one thread. + + This is the headline observable for Phase 2a — if the C engine + held the GIL during ``step``, the two threads would serialise and + the parallel wall time would equal the serial wall time (modulo + noise). With the GIL released we expect a measurable speedup; the + test asserts only ``parallel < serial`` so it tolerates CI jitter. + """ + rpt_a = str(tmp_path / "a.rpt") + out_a = str(tmp_path / "a.out") + rpt_b = str(tmp_path / "b.rpt") + out_b = str(tmp_path / "b.out") + rpt_c = str(tmp_path / "c.rpt") + out_c = str(tmp_path / "c.out") + rpt_d = str(tmp_path / "d.rpt") + out_d = str(tmp_path / "d.out") + + # Warm-up: avoid first-run JIT/IO penalties skewing the comparison. + _run_full_simulation(SITE_DRAINAGE_INP, rpt_a, out_a) + + # --- Sequential baseline (two runs back-to-back, one thread) --- + t0 = time.perf_counter() + n_serial_1 = _run_full_simulation(SITE_DRAINAGE_INP, rpt_a, out_a) + n_serial_2 = _run_full_simulation(SITE_DRAINAGE_INP, rpt_b, out_b) + serial_elapsed = time.perf_counter() - t0 + assert n_serial_1 > 0 and n_serial_2 > 0, "fixture should advance some steps" + + # --- Parallel run (two runs in two threads) --- + results: list[int] = [0, 0] + + def worker(idx: int, rpt: str, out: str) -> None: + results[idx] = _run_full_simulation(SITE_DRAINAGE_INP, rpt, out) + + t1 = threading.Thread(target=worker, args=(0, rpt_c, out_c)) + t2 = threading.Thread(target=worker, args=(1, rpt_d, out_d)) + t0 = time.perf_counter() + t1.start() + t2.start() + t1.join() + t2.join() + parallel_elapsed = time.perf_counter() - t0 + + assert results[0] == n_serial_1 + assert results[1] == n_serial_2 + + # Margin: we require a strictly faster wall-clock time. We do *not* + # require the theoretical 2× because CI hosts are noisy. A 5% bound + # is the smallest difference that meaningfully exceeds run-to-run + # variance for this fixture in the harness; tighten when we ship a + # bigger fixture. + assert parallel_elapsed < serial_elapsed * 0.95, ( + f"parallel ({parallel_elapsed:.3f}s) was not measurably faster " + f"than serial ({serial_elapsed:.3f}s) — GIL may still be held " + f"around swmm_engine_step" + ) + + +# --------------------------------------------------------------------------- +# Test 2 — bulk getters are safe under concurrent calls on one solver +# --------------------------------------------------------------------------- + +@pytest.mark.slow +def test_bulk_getters_concurrent_reads(solver_files): + """Many threads calling node/link/subcatch bulk getters on one ENDED + solver should not deadlock, raise, or return corrupt arrays. + + Reading from an ENDED solver is safe because state vectors are + no longer being mutated. We check that *parallel* reads still + agree bit-for-bit with a *serial* baseline. (Concurrent reads + while the engine is mid-step are a separate question, not asserted + here — the bindings document the engine handle as not thread-safe + for concurrent mutation.) + """ + inp, rpt, out = solver_files + s = Solver(inp, rpt, out) + try: + s.open() + s.initialize() + s.start() + while s.state == EngineState.RUNNING: + if s.step() != 0: + break + s.end() + + nodes = Nodes(s) + links = Links(s) + subs = Subcatchments(s) + + # Baseline (single-threaded snapshot). + baseline_node_depths = nodes.get_depths_bulk() + baseline_link_flows = links.get_flows_bulk() + baseline_runoff = subs.get_runoff_bulk() + + N_THREADS = 8 + N_ITERS = 16 + errors: list[BaseException] = [] + + def reader() -> None: + try: + for _ in range(N_ITERS): + nd = nodes.get_depths_bulk() + nh = nodes.get_heads_bulk() + lf = links.get_flows_bulk() + ld = links.get_depths_bulk() + sr = subs.get_runoff_bulk() + # Each call should return arrays equal to the baseline. + np.testing.assert_array_equal(nd, baseline_node_depths) + np.testing.assert_array_equal(lf, baseline_link_flows) + np.testing.assert_array_equal(sr, baseline_runoff) + # Shape sanity for the heads/depths reads even though + # we don't have a snapshot of them above. + assert nh.shape == nd.shape + assert ld.shape == lf.shape + except BaseException as e: # noqa: BLE001 + errors.append(e) + + threads = [threading.Thread(target=reader) for _ in range(N_THREADS)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"concurrent reads raised: {errors[:3]}" + finally: + try: + s.close() + except Exception: + pass + s.destroy() + + +# --------------------------------------------------------------------------- +# Test 3 — sanity: a single threaded run still produces identical results +# --------------------------------------------------------------------------- + +def test_single_threaded_simulation_unchanged(solver_files): + """A regression: releasing the GIL must not change numerical output. + + Drives one solver to completion and asserts a couple of invariants — + step count > 0 and a non-zero mass balance — to catch the rare class + of bug where adding `with nogil:` accidentally reorders state writes. + """ + inp, rpt, out = solver_files + n_steps = _run_full_simulation(inp, rpt, out) + assert n_steps > 0 + assert os.path.exists(out) and os.path.getsize(out) > 0 diff --git a/python/tests/engine/test_geopackage.py b/python/tests/engine/test_geopackage.py index 59f6a0f2c..637fdceef 100644 --- a/python/tests/engine/test_geopackage.py +++ b/python/tests/engine/test_geopackage.py @@ -201,6 +201,65 @@ def test_is_registered_returns_bool(self): v = is_registered() assert isinstance(v, bool) + def test_register_accepts_all_empty(self): + """Regression for Phase 2c: ``register()`` previously used the + unsafe ``x.encode('utf-8') if x else NULL`` conditional pattern + which fails to transpile under Cython 3.0.12+. Calling with all + default empty strings must now succeed; the underlying + ``swmm_gpkg_register`` is permitted to return False on a vanilla + plugin install so we only assert that the call does not raise + and the return is bool. + """ + v = register() + assert isinstance(v, bool) + + def test_register_accepts_partial_strings(self): + """Same regression — exercise the path where some args are + non-empty and others fall back to the NULL sentinel. + """ + v = register(key="", org="test-org", email="", deploy="test-deploy") + assert isinstance(v, bool) + + +class TestObservedSeriesOptionalArgs: + """Regression for Phase 2c: ``create_observed_series`` and + ``write_observed_value`` both used the unsafe conditional pattern for + nullable string arguments. Each combination must transpile and run. + """ + + def test_create_series_all_optional_empty(self, gpkg_with_schema): + sid = gpkg_with_schema.create_observed_series("p2c_a", "depth") + assert sid >= 0 + + def test_create_series_all_optional_set(self, gpkg_with_schema): + sid = gpkg_with_schema.create_observed_series( + "p2c_b", "flow", + obj_type="NODE", obj_id="J1", + source="Phase2c", units="CFS") + assert sid >= 0 + + def test_create_series_mixed_optionals(self, gpkg_with_schema): + # obj_type set, obj_id empty, source set, units empty — + # forces the per-arg branch in the Cython wrapper. + sid = gpkg_with_schema.create_observed_series( + "p2c_c", "depth", + obj_type="NODE", obj_id="", + source="src", units="") + assert sid >= 0 + + def test_write_observed_value_no_flag(self, gpkg_with_schema): + sid = gpkg_with_schema.create_observed_series("p2c_d", "depth") + # flag="" triggers the NULL path in the C call. + gpkg_with_schema.write_observed_value( + sid, "2026-05-25T00:00:00Z", 1.23) + assert gpkg_with_schema.observed_value_count(sid) == 1 + + def test_write_observed_value_with_flag(self, gpkg_with_schema): + sid = gpkg_with_schema.create_observed_series("p2c_e", "depth") + gpkg_with_schema.write_observed_value( + sid, "2026-05-25T01:00:00Z", 4.56, "A") + assert gpkg_with_schema.observed_value_count(sid) == 1 + # ============================================================================ # Topology edge count diff --git a/python/tests/engine/test_new_api.py b/python/tests/engine/test_new_api.py index 0db1879ca..3a48c6d56 100644 --- a/python/tests/engine/test_new_api.py +++ b/python/tests/engine/test_new_api.py @@ -49,6 +49,70 @@ def test_get_pump_volume_returns_float(self, stepped_links): assert v >= 0.0 +class TestPumpStatsBulk: + """Bulk pump-stats accessor — single C call returning numpy arrays. + + The contract under test: + + * Returns a dict with keys ``cycles`` (int32), ``on_time`` (float64), + and ``volume`` (float64). Each array has length ``n_links``. + * For non-pump links, ``cycles[i] == -1`` and ``on_time[i] == 0.0`` + and ``volume[i] == 0.0`` (the cycles sentinel is the discriminator). + * For pump links, the values match the per-link scalar getters + exactly (numerical equivalence). + * The number of non-sentinel entries equals the number of pump links + when discovered independently via ``get_type``. + """ + + def test_exists(self, stepped_links): + assert hasattr(stepped_links, "get_pump_stats_bulk") + + def test_return_shape_and_dtypes(self, stepped_links): + n = stepped_links.count + result = stepped_links.get_pump_stats_bulk() + assert set(result.keys()) == {"cycles", "on_time", "volume"} + assert result["cycles"].shape == (n,) + assert result["on_time"].shape == (n,) + assert result["volume"].shape == (n,) + assert result["cycles"].dtype == np.intc + assert result["on_time"].dtype == np.float64 + assert result["volume"].dtype == np.float64 + + def test_equivalence_with_scalar_getters(self, stepped_links): + """Bulk values must match scalar values for every pump link. + + Non-pump links are skipped because the scalar getters read raw + statistics vectors and may return zeros while the bulk getter + emits the documented sentinel. + """ + result = stepped_links.get_pump_stats_bulk() + n = stepped_links.count + # LinkType.PUMP == 1 (see LinkData.hpp); use get_type to discover. + for i in range(n): + if stepped_links.get_type(i) != 1: # not a pump + assert result["cycles"][i] == -1 + assert result["on_time"][i] == 0.0 + assert result["volume"][i] == 0.0 + continue + # Pump link: bulk == scalar exactly. + assert result["cycles"][i] == stepped_links.get_stat_pump_cycles(i) + assert result["on_time"][i] == stepped_links.get_stat_pump_on_time(i) + assert result["volume"][i] == stepped_links.get_stat_pump_volume(i) + + def test_sentinel_count_matches_non_pumps(self, stepped_links): + """Count of -1 sentinels should equal the count of non-pump links.""" + result = stepped_links.get_pump_stats_bulk() + n = stepped_links.count + n_non_pumps = sum(1 for i in range(n) if stepped_links.get_type(i) != 1) + assert int((result["cycles"] == -1).sum()) == n_non_pumps + + def test_returns_contiguous_arrays(self, stepped_links): + """C call passes raw pointers; arrays must be C-contiguous.""" + result = stepped_links.get_pump_stats_bulk() + for arr in result.values(): + assert arr.flags["C_CONTIGUOUS"] + + # ============================================================================ # Links: Hydraulic Power # ============================================================================ @@ -293,3 +357,368 @@ def test_get_ponded_quality_returns_float(self, stepped_subcatchments): except Exception: # Might fail if no pollutants — that's OK pass + + +# ============================================================================ +# Phase 3: Nodes bulk getters (volumes / outflows / losses / +# lateral_inflows / ids) +# ============================================================================ + +class TestNodesPhase3Bulk: + """Equivalence and contract tests for the Phase 3 nodes-bulk family. + + The contract under test for each get_*_bulk: + + * Returns a NumPy ``float64`` (or ``list[str]`` for ids) of length + ``n_nodes``. + * Each entry matches the scalar accessor on the same engine state + (numerical equivalence). + * The C array is contiguous (the C call writes raw doubles into + ``arr.data``). + """ + + def test_get_volumes_bulk_exists(self, stepped_nodes): + assert hasattr(stepped_nodes, "get_volumes_bulk") + + def test_get_volumes_bulk_shape_and_dtype(self, stepped_nodes): + n = stepped_nodes.count + arr = stepped_nodes.get_volumes_bulk() + assert isinstance(arr, np.ndarray) + assert arr.shape == (n,) + assert arr.dtype == np.float64 + assert arr.flags["C_CONTIGUOUS"] + + def test_get_volumes_bulk_equivalence(self, stepped_nodes): + arr = stepped_nodes.get_volumes_bulk() + n = stepped_nodes.count + for i in range(n): + assert arr[i] == stepped_nodes.get_volume(i), f"node {i}" + + def test_get_outflows_bulk_equivalence(self, stepped_nodes): + arr = stepped_nodes.get_outflows_bulk() + n = stepped_nodes.count + for i in range(n): + assert arr[i] == stepped_nodes.get_outflow(i), f"node {i}" + + def test_get_losses_bulk_equivalence(self, stepped_nodes): + arr = stepped_nodes.get_losses_bulk() + n = stepped_nodes.count + for i in range(n): + assert arr[i] == stepped_nodes.get_losses(i), f"node {i}" + + def test_get_lateral_inflows_bulk_equivalence(self, stepped_nodes): + arr = stepped_nodes.get_lateral_inflows_bulk() + n = stepped_nodes.count + for i in range(n): + assert arr[i] == stepped_nodes.get_lateral_inflow(i), f"node {i}" + + def test_get_lateral_inflows_bulk_matches_inflows_bulk(self, stepped_nodes): + """Backward-compat: ``get_lateral_inflows_bulk`` reads the same + SoA column as the older ``get_inflows_bulk`` (both expose + ``lat_flow``).""" + new = stepped_nodes.get_lateral_inflows_bulk() + old = stepped_nodes.get_inflows_bulk() + np.testing.assert_array_equal(new, old) + + def test_get_ids_bulk_returns_list_of_strings(self, stepped_nodes): + ids = stepped_nodes.get_ids_bulk() + n = stepped_nodes.count + assert isinstance(ids, list) + assert len(ids) == n + for s in ids: + assert isinstance(s, str) + + def test_get_ids_bulk_equivalence_with_scalar(self, stepped_nodes): + ids = stepped_nodes.get_ids_bulk() + n = stepped_nodes.count + for i in range(n): + assert ids[i] == stepped_nodes.get_id(i), f"index {i}" + + def test_get_ids_bulk_handles_short_stride_truncation(self, stepped_nodes): + """A deliberately small stride should truncate IDs without + crashing. Each returned string is at most ``stride - 1`` chars.""" + stride = 4 + ids = stepped_nodes.get_ids_bulk(stride=stride) + for s in ids: + # UTF-8 length (bytes), not codepoints — but SWMM IDs are + # ASCII so they coincide. Allow exactly stride-1 bytes max. + assert len(s.encode("utf-8")) <= stride - 1 + + def test_get_ids_bulk_default_stride_no_truncation(self, stepped_nodes): + """At the default stride of 64, the site_drainage fixture's IDs + must round-trip without loss.""" + ids = stepped_nodes.get_ids_bulk() + for i, s in enumerate(ids): + assert s == stepped_nodes.get_id(i) + + +# ============================================================================ +# Phase 3: Links bulk getters (velocities / capacities / volumes / +# control_settings / target_settings / hyd_powers / ids) +# ============================================================================ + +class TestLinksPhase3Bulk: + """Equivalence and contract tests for the Phase 3 links-bulk family. + + Velocities, capacities, and hyd_powers are derived per-link in C; the + rest are SoA memcpys. Both flavours must agree bit-for-bit with the + matching scalar accessors. + """ + + def test_velocities_bulk_shape(self, stepped_links): + arr = stepped_links.get_velocities_bulk() + assert isinstance(arr, np.ndarray) + assert arr.shape == (stepped_links.count,) + assert arr.dtype == np.float64 + assert arr.flags["C_CONTIGUOUS"] + + def test_velocities_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_velocities_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_velocity(i), f"link {i}" + + def test_capacities_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_capacities_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_capacity(i), f"link {i}" + + def test_volumes_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_volumes_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_volume(i), f"link {i}" + + def test_control_settings_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_control_settings_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_control_setting(i), f"link {i}" + + def test_target_settings_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_target_settings_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_target_setting(i), f"link {i}" + + def test_hyd_powers_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_hyd_powers_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_hyd_power(i), f"link {i}" + + def test_ids_bulk_returns_list_of_strings(self, stepped_links): + ids = stepped_links.get_ids_bulk() + assert isinstance(ids, list) + assert len(ids) == stepped_links.count + for s in ids: + assert isinstance(s, str) + + def test_ids_bulk_equivalence_with_scalar(self, stepped_links): + ids = stepped_links.get_ids_bulk() + for i, s in enumerate(ids): + assert s == stepped_links.get_id(i), f"index {i}" + + def test_ids_bulk_handles_short_stride_truncation(self, stepped_links): + stride = 4 + ids = stepped_links.get_ids_bulk(stride=stride) + for s in ids: + assert len(s.encode("utf-8")) <= stride - 1 + + def test_ids_bulk_default_stride_no_truncation(self, stepped_links): + ids = stepped_links.get_ids_bulk() + for i, s in enumerate(ids): + assert s == stepped_links.get_id(i) + + def test_pump_filtered_hyd_power_summary(self, stepped_links): + """End-to-end pattern: pair ``get_pump_stats_bulk`` with + ``get_hyd_powers_bulk`` to get pump power totals — the canonical + idiom for the MCP server's pump-energy summary.""" + stats = stepped_links.get_pump_stats_bulk() + powers = stepped_links.get_hyd_powers_bulk() + mask = stats["cycles"] >= 0 + # Non-pumps still have a defined hyd_power, but the sum below + # is restricted to pumps only — this is the documented pattern. + pump_power = float(powers[mask].sum()) + assert pump_power >= 0.0 + + +# ============================================================================ +# Phase 3: Subcatchments bulk getters (rainfall / evap / infil / +# snow_depth / ids) +# ============================================================================ + +class TestSubcatchmentsPhase3Bulk: + """Equivalence and contract tests for the Phase 3 subcatchments-bulk + family. + + Snow depth currently returns zeros from both the scalar and bulk + variants (placeholder until snow-state integration); the equivalence + test exercises that documented behaviour. + """ + + def test_rainfall_bulk_shape(self, stepped_subcatchments): + arr = stepped_subcatchments.get_rainfall_bulk() + assert isinstance(arr, np.ndarray) + assert arr.shape == (stepped_subcatchments.count,) + assert arr.dtype == np.float64 + assert arr.flags["C_CONTIGUOUS"] + + def test_rainfall_bulk_equivalence(self, stepped_subcatchments): + arr = stepped_subcatchments.get_rainfall_bulk() + for i in range(stepped_subcatchments.count): + assert arr[i] == stepped_subcatchments.get_rainfall(i), f"subcatch {i}" + + def test_evap_bulk_equivalence(self, stepped_subcatchments): + arr = stepped_subcatchments.get_evap_bulk() + for i in range(stepped_subcatchments.count): + assert arr[i] == stepped_subcatchments.get_evap(i), f"subcatch {i}" + + def test_infil_bulk_equivalence(self, stepped_subcatchments): + arr = stepped_subcatchments.get_infil_bulk() + for i in range(stepped_subcatchments.count): + assert arr[i] == stepped_subcatchments.get_infil(i), f"subcatch {i}" + + def test_snow_depth_bulk_returns_zeros_placeholder(self, stepped_subcatchments): + """Mirrors the scalar accessor's placeholder behavior — every + entry is 0.0 until snow-state integration lands. When that + integration completes, both this test and the scalar test will + need a corresponding update.""" + arr = stepped_subcatchments.get_snow_depth_bulk() + for i in range(stepped_subcatchments.count): + scalar = stepped_subcatchments.get_snow_depth(i) + assert arr[i] == scalar, f"subcatch {i}" + assert arr[i] == 0.0, f"subcatch {i}: expected placeholder zero" + + def test_ids_bulk_returns_list_of_strings(self, stepped_subcatchments): + ids = stepped_subcatchments.get_ids_bulk() + assert isinstance(ids, list) + assert len(ids) == stepped_subcatchments.count + for s in ids: + assert isinstance(s, str) + + def test_ids_bulk_equivalence_with_scalar(self, stepped_subcatchments): + ids = stepped_subcatchments.get_ids_bulk() + for i, s in enumerate(ids): + assert s == stepped_subcatchments.get_id(i), f"index {i}" + + def test_ids_bulk_handles_short_stride_truncation(self, stepped_subcatchments): + stride = 4 + ids = stepped_subcatchments.get_ids_bulk(stride=stride) + for s in ids: + assert len(s.encode("utf-8")) <= stride - 1 + + def test_ids_bulk_default_stride_no_truncation(self, stepped_subcatchments): + ids = stepped_subcatchments.get_ids_bulk() + for i, s in enumerate(ids): + assert s == stepped_subcatchments.get_id(i) + + def test_whole_network_water_balance_pattern(self, stepped_subcatchments): + """End-to-end pattern: rainfall - infil - evap - runoff per + subcatchment as a quick water-balance check. This is the canonical + MCP / GUI idiom; verifies the four hydrology bulk getters return + equally-sized arrays in matching subcatch order.""" + rain = stepped_subcatchments.get_rainfall_bulk() + infil = stepped_subcatchments.get_infil_bulk() + evap = stepped_subcatchments.get_evap_bulk() + runoff = stepped_subcatchments.get_runoff_bulk() + n = stepped_subcatchments.count + assert rain.shape == (n,) + assert infil.shape == (n,) + assert evap.shape == (n,) + assert runoff.shape == (n,) + # Each subcatch has a non-negative residual when storage is steady. + # We don't assert sign here (snow / storage make the instantaneous + # balance noisy), just that we can compute it without error. + residual = rain - infil - evap - runoff + assert residual.shape == (n,) + + +# ============================================================================ +# Phase 3: Statistics bulk getters (node max_overflow / vol_flooded / +# time_flooded; subcatch max_runoff) +# ============================================================================ + +class TestStatisticsPhase3Bulk: + """Equivalence + non-negativity tests for the Phase 3 statistics-bulk + family. These are cumulative quantities populated over the simulation, + so each test runs against ``completed_solver`` (a fixture that drives + the site_drainage_model through to ENDED) and asserts equivalence with + the post-run scalar accessor. + """ + + def test_node_max_overflow_bulk_shape(self, completed_solver): + from openswmm.engine import Nodes, Statistics + stats = Statistics(completed_solver) + nodes = Nodes(completed_solver) + arr = stats.node_max_overflow_bulk() + assert isinstance(arr, np.ndarray) + assert arr.shape == (nodes.count,) + assert arr.dtype == np.float64 + assert arr.flags["C_CONTIGUOUS"] + + def test_node_max_overflow_bulk_equivalence(self, completed_solver): + from openswmm.engine import Nodes, Statistics + stats = Statistics(completed_solver) + nodes = Nodes(completed_solver) + arr = stats.node_max_overflow_bulk() + for i in range(nodes.count): + assert arr[i] == nodes.get_stat_max_overflow(i), f"node {i}" + + def test_node_vol_flooded_bulk_equivalence(self, completed_solver): + from openswmm.engine import Nodes, Statistics + stats = Statistics(completed_solver) + nodes = Nodes(completed_solver) + arr = stats.node_vol_flooded_bulk() + for i in range(nodes.count): + assert arr[i] == nodes.get_stat_vol_flooded(i), f"node {i}" + + def test_node_time_flooded_bulk_equivalence(self, completed_solver): + from openswmm.engine import Nodes, Statistics + stats = Statistics(completed_solver) + nodes = Nodes(completed_solver) + arr = stats.node_time_flooded_bulk() + for i in range(nodes.count): + assert arr[i] == nodes.get_stat_time_flooded(i), f"node {i}" + + def test_subcatch_max_runoff_bulk_equivalence(self, completed_solver): + from openswmm.engine import Subcatchments, Statistics + stats = Statistics(completed_solver) + subs = Subcatchments(completed_solver) + arr = stats.subcatch_max_runoff_bulk() + for i in range(subs.count): + assert arr[i] == subs.get_stat_max_runoff(i), f"subcatch {i}" + + def test_all_stats_non_negative(self, completed_solver): + """Cumulative non-negative quantities — a regression that reads the + wrong SoA column would likely produce negatives here.""" + from openswmm.engine import Statistics + stats = Statistics(completed_solver) + for arr_name, arr in [ + ("node_max_overflow_bulk", stats.node_max_overflow_bulk()), + ("node_vol_flooded_bulk", stats.node_vol_flooded_bulk()), + ("node_time_flooded_bulk", stats.node_time_flooded_bulk()), + ("subcatch_max_runoff_bulk", stats.subcatch_max_runoff_bulk()), + ]: + assert (arr >= 0).all(), f"{arr_name} has a negative entry" + + def test_flooding_summary_idiom(self, completed_solver): + """End-to-end pattern: pair the three flooding bulk getters with + ``Nodes.get_ids_bulk()`` to assemble a network-wide flooding + summary in 4 C calls instead of 4*N. This is the canonical + replacement for the MCP server's ``get_flooding_summary`` Python + loop.""" + from openswmm.engine import Nodes, Statistics + stats = Statistics(completed_solver) + nodes = Nodes(completed_solver) + ids = nodes.get_ids_bulk() + max_over = stats.node_max_overflow_bulk() + vol_flood = stats.node_vol_flooded_bulk() + t_flood = stats.node_time_flooded_bulk() + # All four results align on the node index. + n = nodes.count + assert len(ids) == n + assert max_over.shape == (n,) + assert vol_flood.shape == (n,) + assert t_flood.shape == (n,) + # The set of "flooded nodes" is consistent across the three stats: + # a node with no flooded time should also have zero flooded volume. + for i in range(n): + if t_flood[i] == 0.0: + assert vol_flood[i] == 0.0, ( + f"{ids[i]}: t_flooded==0 but vol_flooded={vol_flood[i]}") diff --git a/scripts/compare_results.py b/scripts/compare_results.py new file mode 100644 index 000000000..57f68c765 --- /dev/null +++ b/scripts/compare_results.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +""" +Compare legacy vs refactored SWMM engine output files. + +Reads two binary .out files and compares: +1. Header metadata (counts, flow units, variable counts) +2. Object IDs (must match exactly) +3. Per-timestep node/link/subcatchment results (numerical alignment) +4. Report file continuity errors + +Usage: + python compare_results.py [legacy.rpt] [refactored.rpt] +""" + +import struct +import sys +import os +import numpy as np + + +# ============================================================================ +# SWMM .out file reader (matching legacy output.c binary format) +# ============================================================================ + +SUBCATCH_RESULT_VARS = ["rainfall", "snow_depth", "evap_loss", "infil_loss", + "runoff", "gw_flow", "gw_elev", "soil_moist"] +NODE_RESULT_VARS = ["depth", "head", "volume", "lateral_inflow", + "total_inflow", "overflow"] +LINK_RESULT_VARS = ["flow", "depth", "velocity", "volume", "capacity"] + + +class SwmmOutReader: + """Read a SWMM binary .out file.""" + + def __init__(self, path): + self.path = path + self.f = open(path, "rb") + self._read_header() + + def _read_int4(self): + return struct.unpack("10} {'Refactored':>10} {'Match':>8}") + print(f" {'-'*25} {'-'*10} {'-'*10} {'-'*8}") + + fields = [ + ("Magic", leg.magic, ref.magic), + ("Version", leg.version, ref.version), + ("Flow units", leg.flow_units, ref.flow_units), + ("Subcatchments", leg.n_subcatch, ref.n_subcatch), + ("Nodes", leg.n_nodes, ref.n_nodes), + ("Links", leg.n_links, ref.n_links), + ("Pollutants", leg.n_polluts, ref.n_polluts), + ("Output periods", leg.n_periods, ref.n_periods), + ] + for name, lv, rv in fields: + match = "YES" if lv == rv else "NO ***" + print(f" {name:<25} {lv:>10} {rv:>10} {match:>8}") + + # --- ID comparison --- + print("\n--- Object ID Comparison ---") + sc_match = leg.subcatch_ids == ref.subcatch_ids + nd_match = leg.node_ids == ref.node_ids + lk_match = leg.link_ids == ref.link_ids + print(f" Subcatchment IDs match: {sc_match}") + print(f" Node IDs match: {nd_match}") + print(f" Link IDs match: {lk_match}") + + if not (sc_match and nd_match and lk_match): + print("\n *** ID mismatch — cannot compare results element-by-element ***") + leg.close() + ref.close() + return + + # --- Result comparison --- + n_periods = min(leg.n_periods, ref.n_periods) + if n_periods == 0: + print("\n No output periods to compare.") + leg.close() + ref.close() + return + + print(f"\n--- Numerical Result Comparison ({n_periods} periods) ---") + + # Sample periods: first, middle, last, and every 10th + sample_indices = sorted(set([0, n_periods // 4, n_periods // 2, + 3 * n_periods // 4, n_periods - 1])) + + # Aggregate statistics across ALL periods + nd_max_diff = np.zeros(leg.n_nd_vars) + nd_mean_diff = np.zeros(leg.n_nd_vars) + lk_max_diff = np.zeros(leg.n_lk_vars) + lk_mean_diff = np.zeros(leg.n_lk_vars) + sc_max_diff = np.zeros(leg.n_sc_vars) + sc_mean_diff = np.zeros(leg.n_sc_vars) + + for p in range(n_periods): + ld, l_sc, l_nd, l_lk, l_sys = leg.read_period(p) + rd, r_sc, r_nd, r_lk, r_sys = ref.read_period(p) + + # Absolute differences + nd_diff = np.abs(l_nd - r_nd) + lk_diff = np.abs(l_lk - r_lk) + sc_diff = np.abs(l_sc - r_sc) + + nd_max_diff = np.maximum(nd_max_diff, nd_diff.max(axis=0)) + lk_max_diff = np.maximum(lk_max_diff, lk_diff.max(axis=0)) + sc_max_diff = np.maximum(sc_max_diff, sc_diff.max(axis=0)) + + nd_mean_diff += nd_diff.mean(axis=0) + lk_mean_diff += lk_diff.mean(axis=0) + sc_mean_diff += sc_diff.mean(axis=0) + + nd_mean_diff /= n_periods + lk_mean_diff /= n_periods + sc_mean_diff /= n_periods + + # Print node results + var_names = NODE_RESULT_VARS + [f"pollut_{i}" for i in range(leg.n_polluts)] + print(f"\n Node Results (across {leg.n_nodes} nodes × {n_periods} periods):") + print(f" {'Variable':<20} {'Max Abs Diff':>15} {'Mean Abs Diff':>15}") + print(f" {'-'*20} {'-'*15} {'-'*15}") + for i, name in enumerate(var_names): + print(f" {name:<20} {nd_max_diff[i]:>15.6f} {nd_mean_diff[i]:>15.6f}") + + # Print link results + var_names = LINK_RESULT_VARS + [f"pollut_{i}" for i in range(leg.n_polluts)] + print(f"\n Link Results (across {leg.n_links} links × {n_periods} periods):") + print(f" {'Variable':<20} {'Max Abs Diff':>15} {'Mean Abs Diff':>15}") + print(f" {'-'*20} {'-'*15} {'-'*15}") + for i, name in enumerate(var_names): + print(f" {name:<20} {lk_max_diff[i]:>15.6f} {lk_mean_diff[i]:>15.6f}") + + # Print subcatchment results + var_names = SUBCATCH_RESULT_VARS + [f"pollut_{i}" for i in range(leg.n_polluts)] + print(f"\n Subcatchment Results (across {leg.n_subcatch} subcatchments × {n_periods} periods):") + print(f" {'Variable':<20} {'Max Abs Diff':>15} {'Mean Abs Diff':>15}") + print(f" {'-'*20} {'-'*15} {'-'*15}") + for i, name in enumerate(var_names): + print(f" {name:<20} {sc_max_diff[i]:>15.6f} {sc_mean_diff[i]:>15.6f}") + + # Overall summary + all_max = max(nd_max_diff.max(), lk_max_diff.max(), sc_max_diff.max()) + all_mean = (nd_mean_diff.mean() + lk_mean_diff.mean() + sc_mean_diff.mean()) / 3 + print(f"\n Overall maximum absolute difference: {all_max:.6f}") + print(f" Overall mean absolute difference: {all_mean:.6f}") + + if all_max < 0.01: + print(f"\n *** EXCELLENT: Results are near-identical (max diff < 0.01) ***") + elif all_max < 1.0: + print(f"\n *** GOOD: Results are closely aligned (max diff < 1.0) ***") + elif all_max < 10.0: + print(f"\n *** MODERATE: Some divergence detected (max diff < 10) ***") + else: + print(f"\n *** SIGNIFICANT: Results diverge substantially (max diff = {all_max:.2f}) ***") + + leg.close() + ref.close() + + +def compare_reports(legacy_path, refactored_path): + """Compare continuity errors from two .rpt files.""" + print(f"\n{'='*70}") + print(f"Report File Comparison") + print(f"{'='*70}") + + for label, path in [("Legacy", legacy_path), ("Refactored", refactored_path)]: + if not os.path.exists(path): + print(f" {label}: File not found — {path}") + continue + print(f"\n {label} ({path}):") + # .rpt files contain Latin-1 symbols (e.g. ft³) in SI reports. + with open(path, encoding="latin-1") as f: + for line in f: + if "Continuity Error" in line or "continuity error" in line: + print(f" {line.rstrip()}") + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print("Usage: python compare_results.py [legacy.rpt] [refactored.rpt]") + sys.exit(1) + + compare_outputs(sys.argv[1], sys.argv[2]) + + if len(sys.argv) >= 5: + compare_reports(sys.argv[3], sys.argv[4]) + + print() diff --git a/src/engine/core/openswmm_controls_impl.cpp b/src/engine/core/openswmm_controls_impl.cpp index 10024d17b..cf5711a13 100644 --- a/src/engine/core/openswmm_controls_impl.cpp +++ b/src/engine/core/openswmm_controls_impl.cpp @@ -12,6 +12,7 @@ #include "openswmm_api_common.hpp" #include "../../../include/openswmm/engine/openswmm_controls.h" +#include "../controls/Controls.hpp" #include #include @@ -108,6 +109,40 @@ SWMM_ENGINE_API int swmm_control_clear_rules(SWMM_Engine engine) { return SWMM_OK; } +SWMM_ENGINE_API int swmm_control_validate_rule(SWMM_Engine engine, + const char* rule_text, + char* errbuf, int buflen, + int* line_out) { + CHECK_HANDLE(engine); + if (!rule_text) return SWMM_ERR_BADPARAM; + + // Throwaway ControlEngine: parseRuleText mutates only its own rules_ / + // pid_states_ vectors. Name resolution reads ctx.link_names / + // ctx.table_names (find() is non-mutating). The live engine's rule list + // and PID state are untouched. + auto& ctx = to_engine(engine)->context(); + openswmm::controls::ControlEngine sandbox; + const int rc = sandbox.parseRuleText(std::string(rule_text), ctx); + + if (line_out) *line_out = -1; // Line-precise reporting not yet plumbed. + + if (rc < 0) { + if (errbuf && buflen > 0) { + // Generic message — parseRuleText returns -1 without context. + // Line-precise + token-specific messages land in a follow-up + // when parseRuleText itself grows error-out parameters. + static constexpr char kMsg[] = "Control-rule parser rejected the rule text"; + const int n = std::min(static_cast(sizeof(kMsg) - 1), buflen - 1); + std::memcpy(errbuf, kMsg, static_cast(n)); + errbuf[n] = '\0'; + } + return SWMM_ERR_BADPARAM; + } + + if (errbuf && buflen > 0) errbuf[0] = '\0'; + return SWMM_OK; +} + // ============================================================================ // Direct control actions (without rules) // ============================================================================ diff --git a/src/engine/core/openswmm_infrastructure_impl.cpp b/src/engine/core/openswmm_infrastructure_impl.cpp index 2c74b64d0..b8ea58ff4 100644 --- a/src/engine/core/openswmm_infrastructure_impl.cpp +++ b/src/engine/core/openswmm_infrastructure_impl.cpp @@ -13,6 +13,9 @@ #include "openswmm_api_common.hpp" #include "../../../include/openswmm/engine/openswmm_infrastructure.h" +#include +#include + extern "C" { // ============================================================================ @@ -27,13 +30,17 @@ SWMM_ENGINE_API int swmm_transect_add(SWMM_Engine engine, const char* id) { auto& ts = ctx.transects; ts.names.push_back(id); + ts.comments.push_back(std::string{}); ts.n_left.push_back(0.0); ts.n_right.push_back(0.0); ts.n_channel.push_back(0.0); ts.x_left_bank.push_back(0.0); ts.x_right_bank.push_back(0.0); + ts.x_left_encroachment.push_back(0.0); + ts.x_right_encroachment.push_back(0.0); ts.x_factor.push_back(1.0); ts.y_factor.push_back(1.0); + ts.length_factor.push_back(1.0); ts.stations.push_back({}); ts.elevations.push_back({}); @@ -82,6 +89,199 @@ SWMM_ENGINE_API const char* swmm_transect_id(SWMM_Engine engine, int idx) { return names[static_cast(idx)].c_str(); } +// ---------------------------------------------------------------------------- +// Per-field getters / setters (DA-ENG-09 + BQ-TR-02) +// ---------------------------------------------------------------------------- + +SWMM_ENGINE_API int swmm_transect_get_roughness(SWMM_Engine engine, int idx, + double* n_left, double* n_right, double* n_channel) { + CHECK_HANDLE(engine); + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + if (n_left) *n_left = ts.n_left[ui]; + if (n_right) *n_right = ts.n_right[ui]; + if (n_channel) *n_channel = ts.n_channel[ui]; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_set_bank_stations(SWMM_Engine engine, int idx, + double x_left, double x_right) { + CHECK_HANDLE(engine); + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + ts.x_left_bank[ui] = x_left; + ts.x_right_bank[ui] = x_right; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_get_bank_stations(SWMM_Engine engine, int idx, + double* x_left, double* x_right) { + CHECK_HANDLE(engine); + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + if (x_left) *x_left = ts.x_left_bank[ui]; + if (x_right) *x_right = ts.x_right_bank[ui]; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_set_encroachment_stations(SWMM_Engine engine, int idx, + double x_left, double x_right) { + CHECK_HANDLE(engine); + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + ts.x_left_encroachment[ui] = x_left; + ts.x_right_encroachment[ui] = x_right; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_get_encroachment_stations(SWMM_Engine engine, int idx, + double* x_left, double* x_right) { + CHECK_HANDLE(engine); + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + if (x_left) *x_left = ts.x_left_encroachment[ui]; + if (x_right) *x_right = ts.x_right_encroachment[ui]; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_set_modifiers(SWMM_Engine engine, int idx, + double x_factor, double y_factor, double length_factor) { + CHECK_HANDLE(engine); + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + ts.x_factor[ui] = x_factor; + ts.y_factor[ui] = y_factor; + ts.length_factor[ui] = length_factor; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_get_modifiers(SWMM_Engine engine, int idx, + double* x_factor, double* y_factor, double* length_factor) { + CHECK_HANDLE(engine); + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + if (x_factor) *x_factor = ts.x_factor[ui]; + if (y_factor) *y_factor = ts.y_factor[ui]; + if (length_factor) *length_factor = ts.length_factor[ui]; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_set_comments(SWMM_Engine engine, int idx, const char* text) { + CHECK_HANDLE(engine); + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + ts.comments[ui] = (text ? std::string(text) : std::string{}); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_get_comments(SWMM_Engine engine, int idx, char* buf, int buflen) { + CHECK_HANDLE(engine); + if (!buf || buflen <= 0) return SWMM_ERR_BADPARAM; + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + const std::string& s = ts.comments[ui]; + const std::size_t n = std::min(s.size(), static_cast(buflen - 1)); + std::memcpy(buf, s.data(), n); + buf[n] = '\0'; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_get_station_count(SWMM_Engine engine, int idx) { + if (!engine) return -1; + auto& ts = to_engine(engine)->context().transects; + if (idx < 0 || idx >= ts.count()) return -1; + return static_cast(ts.stations[static_cast(idx)].size()); +} + +SWMM_ENGINE_API int swmm_transect_get_station(SWMM_Engine engine, int idx, int station_idx, + double* station, double* elevation) { + CHECK_HANDLE(engine); + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + const auto& xs = ts.stations[ui]; + const auto& ys = ts.elevations[ui]; + CHECK_INDEX(station_idx >= 0 && station_idx < static_cast(xs.size())); + const auto si = static_cast(station_idx); + if (station) *station = xs[si]; + if (elevation) *elevation = ys[si]; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_clear_stations(SWMM_Engine engine, int idx) { + CHECK_HANDLE(engine); + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + ts.stations[ui].clear(); + ts.elevations[ui].clear(); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_rename(SWMM_Engine engine, int idx, const char* new_id) { + CHECK_HANDLE(engine); + if (!new_id || new_id[0] == '\0') return SWMM_ERR_BADPARAM; + auto& ts = to_engine(engine)->context().transects; + CHECK_INDEX(idx >= 0 && idx < ts.count()); + const auto ui = static_cast(idx); + + // Same-name (case-sensitive) is a no-op. + if (ts.names[ui] == new_id) return SWMM_OK; + + // Case-insensitive collision check against every other slot. + auto ieq = [](const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) { + const unsigned char ca = static_cast(a[i]); + const unsigned char cb = static_cast(b[i]); + if (std::tolower(ca) != std::tolower(cb)) return false; + } + return true; + }; + const std::string newName(new_id); + for (std::size_t i = 0; i < ts.names.size(); ++i) { + if (i == ui) continue; + if (ieq(ts.names[i], newName)) return SWMM_ERR_BADPARAM; + } + + ts.names[ui] = newName; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_transect_remove(SWMM_Engine engine, int idx) { + CHECK_HANDLE(engine); + auto& ts = to_engine(engine)->context().transects; + // Out-of-range is a no-op SWMM_OK (mirrors pattern mutation API). + if (idx < 0 || idx >= ts.count()) return SWMM_OK; + const auto ui = static_cast(idx); + + ts.names.erase(ts.names.begin() + ui); + ts.comments.erase(ts.comments.begin() + ui); + ts.n_left.erase(ts.n_left.begin() + ui); + ts.n_right.erase(ts.n_right.begin() + ui); + ts.n_channel.erase(ts.n_channel.begin() + ui); + ts.x_left_bank.erase(ts.x_left_bank.begin() + ui); + ts.x_right_bank.erase(ts.x_right_bank.begin() + ui); + ts.x_left_encroachment.erase(ts.x_left_encroachment.begin() + ui); + ts.x_right_encroachment.erase(ts.x_right_encroachment.begin() + ui); + ts.x_factor.erase(ts.x_factor.begin() + ui); + ts.y_factor.erase(ts.y_factor.begin() + ui); + ts.length_factor.erase(ts.length_factor.begin() + ui); + ts.stations.erase(ts.stations.begin() + ui); + ts.elevations.erase(ts.elevations.begin() + ui); + + return SWMM_OK; +} + // ============================================================================ // Streets // ============================================================================ diff --git a/src/engine/core/openswmm_links_impl.cpp b/src/engine/core/openswmm_links_impl.cpp index 3370c6493..5e5988099 100644 --- a/src/engine/core/openswmm_links_impl.cpp +++ b/src/engine/core/openswmm_links_impl.cpp @@ -477,6 +477,118 @@ SWMM_ENGINE_API int swmm_link_get_quality_bulk(SWMM_Engine engine, int pollutant return SWMM_OK; } +// ---------------------------------------------------------------------------- +// Phase 3 bulk getters — Links. +// +// Three of these (volumes, control_settings, target_settings) are simple SoA +// memcpys. Three (velocities, capacities, hyd_powers) recompute a derived +// quantity per link — there is no SoA column for them — but bulk-mode still +// saves the C ABI crossing cost and any Python-level iteration. The +// arithmetic is taken verbatim from the matching scalar accessors above so +// the bulk vs scalar parity tests stay bit-equivalent. +// +// Stride-packed IDs follow the same format as swmm_node_get_ids_bulk. +// ---------------------------------------------------------------------------- + +SWMM_ENGINE_API int swmm_link_get_velocities_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_links()); + for (int i = 0; i < n; ++i) { + const auto ui = static_cast(i); + const double q = ctx.links.flow[ui]; + const double d = ctx.links.depth[ui]; + const double y_full = ctx.links.xsect_y_full[ui]; + const double a_full = ctx.links.xsect_a_full[ui]; + const double area = (y_full > 0.0 && a_full > 0.0 && d > 0.0) + ? a_full * (d / y_full) : 0.0; + buf[i] = (area > 1.0e-12) ? q / area : 0.0; + } + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_capacities_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_links()); + for (int i = 0; i < n; ++i) { + const auto ui = static_cast(i); + const double q = ctx.links.flow[ui]; + const double qf = ctx.links.q_full[ui]; + buf[i] = (qf > 1.0e-12) ? q / qf : 0.0; + } + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_volumes_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_links()); + std::copy(ctx.links.volume.begin(), ctx.links.volume.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_control_settings_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_links()); + std::copy(ctx.links.setting.begin(), ctx.links.setting.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_target_settings_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_links()); + std::copy(ctx.links.target_setting.begin(), + ctx.links.target_setting.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_hyd_powers_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_links()); + constexpr double GAMMA = 62.4; + for (int i = 0; i < n; ++i) { + const auto ui = static_cast(i); + const int n1 = ctx.links.node1[ui]; + const int n2 = ctx.links.node2[ui]; + const double h1 = (n1 >= 0) + ? ctx.nodes.head[static_cast(n1)] : 0.0; + const double h2 = (n2 >= 0) + ? ctx.nodes.head[static_cast(n2)] : 0.0; + buf[i] = GAMMA * std::fabs(ctx.links.flow[ui]) * std::fabs(h1 - h2); + } + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_ids_bulk(SWMM_Engine engine, + char* buf, + int stride, + int count) { + CHECK_HANDLE(engine); + if (!buf || stride < 2 || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_links()); + const std::size_t s = static_cast(stride); + + std::fill_n(buf, s * static_cast(n), '\0'); + for (int i = 0; i < n; ++i) { + const std::string& name = ctx.link_names.name_of(i); + const std::size_t copy_n = std::min(name.size(), s - 1); + std::memcpy(buf + static_cast(i) * s, + name.data(), copy_n); + } + return SWMM_OK; +} + // ============================================================================ // Pump Link API // ============================================================================ @@ -761,6 +873,52 @@ SWMM_ENGINE_API int swmm_link_get_stat_pump_volume(SWMM_Engine engine, int idx, return SWMM_OK; } +// ---------------------------------------------------------------------------- +// Bulk pump statistics — single pass over all links. +// +// Non-pump links receive sentinel values (cycles = -1, on_time = 0.0, +// volume = 0.0) so the caller can distinguish them. Any of cycles / on_time +// / volume may be NULL if the caller does not need that output. +// +// Design note: we deliberately do NOT early-exit the iteration when all +// outputs are NULL — that case is reported as SWMM_ERR_BADPARAM at entry, +// rather than silently being a no-op (the caller almost certainly made a +// mistake if they call with all three null). +// ---------------------------------------------------------------------------- + +SWMM_ENGINE_API int swmm_link_get_pump_stats_bulk(SWMM_Engine engine, + int* cycles, + double* on_time, + double* volume, + int count) { + CHECK_HANDLE(engine); + if (count <= 0) return SWMM_ERR_BADPARAM; + if (!cycles && !on_time && !volume) return SWMM_ERR_BADPARAM; + + const auto& ctx = to_engine(engine)->context(); + const int n_links = ctx.n_links(); + const int n = std::min(count, n_links); + + // Defensive: the per-link statistics vectors are sized at engine init. + // If for some reason they have not been sized (caller invoked too early), + // fall back to sentinel for the entire range rather than dereferencing. + const bool stats_sized = + static_cast(ctx.links.stat_pump_cycles.size()) >= n_links && + static_cast(ctx.links.stat_pump_on_time.size()) >= n_links && + static_cast(ctx.links.stat_pump_volume.size()) >= n_links; + + for (int i = 0; i < n; ++i) { + const auto ui = static_cast(i); + const bool is_pump = + stats_sized && ctx.links.type[ui] == openswmm::LinkType::PUMP; + + if (cycles) cycles[i] = is_pump ? ctx.links.stat_pump_cycles[ui] : -1; + if (on_time) on_time[i] = is_pump ? ctx.links.stat_pump_on_time[ui] : 0.0; + if (volume) volume[i] = is_pump ? ctx.links.stat_pump_volume[ui] : 0.0; + } + return SWMM_OK; +} + // ============================================================================ // Hydraulic power // ============================================================================ diff --git a/src/engine/core/openswmm_nodes_impl.cpp b/src/engine/core/openswmm_nodes_impl.cpp index a3f8e3847..ba42c950c 100644 --- a/src/engine/core/openswmm_nodes_impl.cpp +++ b/src/engine/core/openswmm_nodes_impl.cpp @@ -381,6 +381,82 @@ SWMM_ENGINE_API int swmm_node_set_lat_inflows_bulk(SWMM_Engine engine, const dou return SWMM_OK; } +// ---------------------------------------------------------------------------- +// Phase 3 bulk getters — volumes, outflows, losses, lateral_inflows, ids. +// Each follows the same "memcpy into caller's buffer" pattern as the bulk +// getters above. Non-pump links / non-existent state is not a concern here +// because every node maintains all of these fields after initialization. +// +// Naming note: `swmm_node_get_lateral_inflows_bulk` reads the same `lat_flow` +// SoA column as the pre-existing `swmm_node_get_inflows_bulk`; the older +// name was labelled "total inflows" in error and is retained for backward +// compatibility. New callers should prefer the explicitly-named variant. +// See docs/C_API_BINDINGS_MCP_IMPROVEMENT_PLAN.md Appendix A item 3. +// ---------------------------------------------------------------------------- + +SWMM_ENGINE_API int swmm_node_get_volumes_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const int n = std::min(count, ctx.n_nodes()); + std::copy(ctx.nodes.volume.begin(), ctx.nodes.volume.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_node_get_outflows_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const int n = std::min(count, ctx.n_nodes()); + std::copy(ctx.nodes.outflow.begin(), ctx.nodes.outflow.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_node_get_losses_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const int n = std::min(count, ctx.n_nodes()); + std::copy(ctx.nodes.losses.begin(), ctx.nodes.losses.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_node_get_lateral_inflows_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const int n = std::min(count, ctx.n_nodes()); + std::copy(ctx.nodes.lat_flow.begin(), ctx.nodes.lat_flow.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_node_get_ids_bulk(SWMM_Engine engine, + char* buf, + int stride, + int count) { + CHECK_HANDLE(engine); + if (!buf || stride < 2 || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_nodes()); + const std::size_t s = static_cast(stride); + + // Zero the requested region up front; that way any IDs shorter than + // stride are NUL-terminated without an explicit per-slot write, and a + // partial read leaves a clean tail. + std::fill_n(buf, s * static_cast(n), '\0'); + + for (int i = 0; i < n; ++i) { + const std::string& name = ctx.node_names.name_of(i); + // Truncate (never overflow): leave the last byte of each slot as + // the NUL terminator. + const std::size_t copy_n = + std::min(name.size(), s - 1); + std::memcpy(buf + static_cast(i) * s, + name.data(), copy_n); + } + return SWMM_OK; +} + SWMM_ENGINE_API int swmm_node_get_quality_bulk(SWMM_Engine engine, int pollutant_idx, double* buf, int count) { CHECK_HANDLE(engine); diff --git a/src/engine/core/openswmm_statistics_impl.cpp b/src/engine/core/openswmm_statistics_impl.cpp index 2e9bb8f03..7deab85b5 100644 --- a/src/engine/core/openswmm_statistics_impl.cpp +++ b/src/engine/core/openswmm_statistics_impl.cpp @@ -157,4 +157,54 @@ SWMM_ENGINE_API int swmm_stat_subcatch_runoff_vol_bulk(SWMM_Engine engine, doubl return SWMM_OK; } +// ---------------------------------------------------------------------------- +// Phase 3 statistics bulk getters — node max_overflow, vol_flooded, +// time_flooded; subcatch max_runoff. +// +// All four are simple SoA memcpys; the rationale for adding them now is that +// they are the four most-hit scalar getters in the MCP server's flooding / +// capacity summary tools. Per-element loops there pay 4N ABI crossings; the +// bulk variants compress that to 4 single-pass copies. +// ---------------------------------------------------------------------------- + +SWMM_ENGINE_API int swmm_stat_node_max_overflow_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_nodes()); + std::copy(ctx.nodes.stat_max_overflow.begin(), + ctx.nodes.stat_max_overflow.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_stat_node_vol_flooded_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_nodes()); + std::copy(ctx.nodes.stat_vol_flooded.begin(), + ctx.nodes.stat_vol_flooded.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_stat_node_time_flooded_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_nodes()); + std::copy(ctx.nodes.stat_time_flooded.begin(), + ctx.nodes.stat_time_flooded.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_stat_subcatch_max_runoff_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_subcatches()); + std::copy(ctx.subcatches.stat_max_runoff.begin(), + ctx.subcatches.stat_max_runoff.begin() + n, buf); + return SWMM_OK; +} + } /* extern "C" */ diff --git a/src/engine/core/openswmm_subcatchments_impl.cpp b/src/engine/core/openswmm_subcatchments_impl.cpp index 2537558eb..e8b9e1d9f 100644 --- a/src/engine/core/openswmm_subcatchments_impl.cpp +++ b/src/engine/core/openswmm_subcatchments_impl.cpp @@ -539,6 +539,77 @@ SWMM_ENGINE_API int swmm_subcatch_get_quality_bulk(SWMM_Engine engine, int pollu return SWMM_OK; } +// ---------------------------------------------------------------------------- +// Phase 3 bulk getters — Subcatchments. +// +// rainfall, evap_loss, infil_loss are simple SoA memcpys. snow_depth mirrors +// the scalar accessor (which currently returns 0.0 since snow state lives in +// the SnowSolver, not SubcatchData) — when snow integration lands, both the +// scalar and bulk variants get updated together. IDs follow the stride-packed +// UTF-8 format established by swmm_node_get_ids_bulk. +// ---------------------------------------------------------------------------- + +SWMM_ENGINE_API int swmm_subcatch_get_rainfall_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_subcatches()); + std::copy(ctx.subcatches.rainfall.begin(), + ctx.subcatches.rainfall.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_subcatch_get_evap_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_subcatches()); + std::copy(ctx.subcatches.evap_loss.begin(), + ctx.subcatches.evap_loss.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_subcatch_get_infil_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_subcatches()); + std::copy(ctx.subcatches.infil_loss.begin(), + ctx.subcatches.infil_loss.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_subcatch_get_snow_depth_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_subcatches()); + // Mirror the scalar accessor's placeholder behavior: zero-fill until + // snow state is integrated with SubcatchData. + std::fill_n(buf, n, 0.0); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_subcatch_get_ids_bulk(SWMM_Engine engine, + char* buf, + int stride, + int count) { + CHECK_HANDLE(engine); + if (!buf || stride < 2 || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_subcatches()); + const std::size_t s = static_cast(stride); + + std::fill_n(buf, s * static_cast(n), '\0'); + for (int i = 0; i < n; ++i) { + const std::string& name = ctx.subcatch_names.name_of(i); + const std::size_t copy_n = std::min(name.size(), s - 1); + std::memcpy(buf + static_cast(i) * s, + name.data(), copy_n); + } + return SWMM_OK; +} + // ============================================================================ // Ponded quality // ============================================================================ diff --git a/src/engine/data/InfraData.hpp b/src/engine/data/InfraData.hpp index 0e72847e9..8fb382a99 100644 --- a/src/engine/data/InfraData.hpp +++ b/src/engine/data/InfraData.hpp @@ -27,13 +27,21 @@ struct TransectStore { int count() const { return static_cast(names.size()); } std::vector names; + std::vector comments; ///< Free-form description per transect (DA-ENG-09) std::vector n_left; std::vector n_right; std::vector n_channel; std::vector x_left_bank; std::vector x_right_bank; + /// Encroachment stations — independent of bank stations (BQ-TR-02). + /// Default to 0.0 at add-time; the GUI surfaces them as a distinct + /// field set. An INP parser extension may default these to the bank + /// stations on legacy files lacking the trailing columns. + std::vector x_left_encroachment; + std::vector x_right_encroachment; std::vector x_factor; std::vector y_factor; + std::vector length_factor; ///< Meander factor (channel/floodplain length ratio); default 1.0 /// Station-elevation pairs per transect std::vector> stations; std::vector> elevations; diff --git a/tests/unit/engine/CMakeLists.txt b/tests/unit/engine/CMakeLists.txt index ac63d890d..d421fe975 100644 --- a/tests/unit/engine/CMakeLists.txt +++ b/tests/unit/engine/CMakeLists.txt @@ -108,9 +108,16 @@ add_gtest_unit(test_engine_treatment test_treatment.cpp) add_gtest_unit(test_engine_rdii test_rdii.cpp) add_gtest_unit(test_engine_hydrograph_mutation test_hydrograph_mutation_api.cpp) add_gtest_unit(test_engine_pattern_mutation test_pattern_mutation_api.cpp) +add_gtest_unit(test_engine_transect_mutation test_transect_mutation_api.cpp) add_gtest_unit(test_engine_da4_api test_da4_engine_api.cpp) add_gtest_unit(test_engine_controls test_controls.cpp) +add_gtest_unit(test_engine_control_rule_validate test_control_rule_validate_api.cpp) add_gtest_unit(test_engine_gap_fixes test_gap_fixes.cpp) +add_gtest_unit(test_engine_links_pump_stats_bulk test_links_pump_stats_bulk.cpp) +add_gtest_unit(test_engine_nodes_bulk_phase3 test_nodes_bulk_phase3.cpp) +add_gtest_unit(test_engine_links_bulk_phase3 test_links_bulk_phase3.cpp) +add_gtest_unit(test_engine_subcatchments_bulk_phase3 test_subcatchments_bulk_phase3.cpp) +add_gtest_unit(test_engine_statistics_bulk_phase3 test_statistics_bulk_phase3.cpp) add_gtest_unit(test_engine_report_section test_report_section.cpp) add_gtest_unit(test_engine_concurrent test_concurrent_engines.cpp) add_gtest_unit(test_engine_object_deletion test_object_deletion.cpp) diff --git a/tests/unit/engine/test_links_bulk_phase3.cpp b/tests/unit/engine/test_links_bulk_phase3.cpp new file mode 100644 index 000000000..08d40db91 --- /dev/null +++ b/tests/unit/engine/test_links_bulk_phase3.cpp @@ -0,0 +1,247 @@ +/** + * @file test_links_bulk_phase3.cpp + * @brief Unit tests for the Phase 3 link-bulk C API additions — + * @ref swmm_link_get_velocities_bulk, @ref swmm_link_get_capacities_bulk, + * @ref swmm_link_get_volumes_bulk, + * @ref swmm_link_get_control_settings_bulk, + * @ref swmm_link_get_target_settings_bulk, + * @ref swmm_link_get_hyd_powers_bulk, and + * @ref swmm_link_get_ids_bulk. + * + * @details Same contract as the Nodes Phase 3 tests + * (@c test_nodes_bulk_phase3.cpp): per-link parity with the scalar + * accessors, stride-packed ID round-trip, and bad-param contracts. + * Velocities, capacities, and hyd_powers are *derived* values whose + * bulk variants do a per-link loop in C — the equivalence tests + * assert bit-for-bit equality with the scalar getters because both + * use the same arithmetic. + * + * Working directory is set to tests/unit/engine/data/ by the + * test CMakeLists.txt. + * + * @see docs/C_API_BINDINGS_MCP_IMPROVEMENT_PLAN.md Phase 3 — Links batch + * @ingroup engine_tests + * + * @author Caleb Buahin + * @copyright Copyright (c) 2026 Caleb Buahin. All rights reserved. + * @license MIT License + */ + +#include +#include +#include +#include + +#include +#include + +namespace { + +class LinksBulkPhase3Test : public ::testing::Test { +protected: + SWMM_Engine engine_ = nullptr; + int n_links_ = 0; + + void SetUp() override { + engine_ = swmm_engine_create(); + ASSERT_NE(engine_, nullptr); + ASSERT_EQ(swmm_engine_open(engine_, + "site_drainage_model.inp", + "site_drainage_model.rpt", + "site_drainage_model.out", + nullptr), + SWMM_OK) + << "open failed: " << swmm_get_last_error_msg(engine_); + ASSERT_EQ(swmm_engine_initialize(engine_), SWMM_OK); + n_links_ = swmm_link_count(engine_); + ASSERT_GT(n_links_, 0); + } + + void TearDown() override { + if (engine_) { + swmm_engine_close(engine_); + swmm_engine_destroy(engine_); + engine_ = nullptr; + } + } +}; + +// --------------------------------------------------------------------------- +// Equivalence — each bulk getter must match the scalar accessor for every +// link, including the derived (per-link computed) ones. +// --------------------------------------------------------------------------- + +TEST_F(LinksBulkPhase3Test, VelocitiesBulkMatchesScalarPerLink) { + std::vector bulk(n_links_, -42.0); + ASSERT_EQ(swmm_link_get_velocities_bulk(engine_, bulk.data(), n_links_), + SWMM_OK); + for (int i = 0; i < n_links_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_link_get_velocity(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "link " << i; + } +} + +TEST_F(LinksBulkPhase3Test, CapacitiesBulkMatchesScalarPerLink) { + std::vector bulk(n_links_, -42.0); + ASSERT_EQ(swmm_link_get_capacities_bulk(engine_, bulk.data(), n_links_), + SWMM_OK); + for (int i = 0; i < n_links_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_link_get_capacity(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "link " << i; + } +} + +TEST_F(LinksBulkPhase3Test, VolumesBulkMatchesScalarPerLink) { + std::vector bulk(n_links_, -42.0); + ASSERT_EQ(swmm_link_get_volumes_bulk(engine_, bulk.data(), n_links_), + SWMM_OK); + for (int i = 0; i < n_links_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_link_get_volume(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "link " << i; + } +} + +TEST_F(LinksBulkPhase3Test, ControlSettingsBulkMatchesScalarPerLink) { + std::vector bulk(n_links_, -42.0); + ASSERT_EQ(swmm_link_get_control_settings_bulk(engine_, bulk.data(), + n_links_), + SWMM_OK); + for (int i = 0; i < n_links_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_link_get_control_setting(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "link " << i; + } +} + +TEST_F(LinksBulkPhase3Test, TargetSettingsBulkMatchesScalarPerLink) { + std::vector bulk(n_links_, -42.0); + ASSERT_EQ(swmm_link_get_target_settings_bulk(engine_, bulk.data(), + n_links_), + SWMM_OK); + for (int i = 0; i < n_links_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_link_get_target_setting(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "link " << i; + } +} + +TEST_F(LinksBulkPhase3Test, HydPowersBulkMatchesScalarPerLink) { + std::vector bulk(n_links_, -42.0); + ASSERT_EQ(swmm_link_get_hyd_powers_bulk(engine_, bulk.data(), n_links_), + SWMM_OK); + for (int i = 0; i < n_links_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_link_get_hyd_power(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "link " << i; + } +} + +// --------------------------------------------------------------------------- +// IDs bulk — stride-packed UTF-8 format, same contract as the Nodes variant. +// --------------------------------------------------------------------------- + +TEST_F(LinksBulkPhase3Test, IdsBulkMatchesScalarPerLink) { + constexpr int kStride = 64; + std::vector buf(static_cast(n_links_) * kStride, '\xAA'); + ASSERT_EQ(swmm_link_get_ids_bulk(engine_, buf.data(), kStride, n_links_), + SWMM_OK); + for (int i = 0; i < n_links_; ++i) { + const char* bulk_id = buf.data() + i * kStride; + const char* scalar_id = swmm_link_id(engine_, i); + ASSERT_NE(scalar_id, nullptr) << "link " << i; + EXPECT_STREQ(bulk_id, scalar_id) << "link " << i; + } +} + +TEST_F(LinksBulkPhase3Test, IdsBulkTruncatesAtStride) { + int longest = 0; + for (int i = 0; i < n_links_; ++i) { + int L = static_cast(std::strlen(swmm_link_id(engine_, i))); + if (L > longest) longest = L; + } + if (longest <= 1) { + GTEST_SKIP() << "Fixture's longest link ID is too short to " + "meaningfully test truncation."; + } + const int stride = longest; // forces 1-char truncation + std::vector buf(static_cast(n_links_) * stride, '\xAA'); + ASSERT_EQ(swmm_link_get_ids_bulk(engine_, buf.data(), stride, n_links_), + SWMM_OK); + for (int i = 0; i < n_links_; ++i) { + const char* slot = buf.data() + i * stride; + const std::size_t len = std::strlen(slot); + EXPECT_LE(len, static_cast(stride - 1)) << "link " << i; + } +} + +// --------------------------------------------------------------------------- +// Bad-param contracts. +// --------------------------------------------------------------------------- + +TEST_F(LinksBulkPhase3Test, RejectsNullBuffer) { + EXPECT_EQ(swmm_link_get_velocities_bulk(engine_, nullptr, n_links_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_link_get_capacities_bulk(engine_, nullptr, n_links_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_link_get_volumes_bulk(engine_, nullptr, n_links_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_link_get_control_settings_bulk(engine_, nullptr, n_links_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_link_get_target_settings_bulk(engine_, nullptr, n_links_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_link_get_hyd_powers_bulk(engine_, nullptr, n_links_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_link_get_ids_bulk(engine_, nullptr, 64, n_links_), + SWMM_ERR_BADPARAM); +} + +TEST_F(LinksBulkPhase3Test, RejectsNonPositiveCount) { + std::vector v(n_links_, 0.0); + EXPECT_EQ(swmm_link_get_velocities_bulk(engine_, v.data(), 0), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_link_get_velocities_bulk(engine_, v.data(), -1), + SWMM_ERR_BADPARAM); +} + +TEST_F(LinksBulkPhase3Test, IdsBulkRejectsTinyStride) { + std::vector buf(n_links_, '\0'); + EXPECT_EQ(swmm_link_get_ids_bulk(engine_, buf.data(), 1, n_links_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_link_get_ids_bulk(engine_, buf.data(), 0, n_links_), + SWMM_ERR_BADPARAM); +} + +TEST_F(LinksBulkPhase3Test, RejectsNullEngine) { + std::vector v(n_links_, 0.0); + EXPECT_EQ(swmm_link_get_velocities_bulk(nullptr, v.data(), n_links_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_link_get_capacities_bulk(nullptr, v.data(), n_links_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_link_get_volumes_bulk(nullptr, v.data(), n_links_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_link_get_hyd_powers_bulk(nullptr, v.data(), n_links_), + SWMM_ERR_BADHANDLE); + std::vector buf(n_links_ * 64, '\0'); + EXPECT_EQ(swmm_link_get_ids_bulk(nullptr, buf.data(), 64, n_links_), + SWMM_ERR_BADHANDLE); +} + +// --------------------------------------------------------------------------- +// Count clipping. +// --------------------------------------------------------------------------- + +TEST_F(LinksBulkPhase3Test, OversizedCountClippedAtNLinks) { + constexpr double kSentinel = -42.0; + const int oversize = n_links_ + 5; + std::vector v(oversize, kSentinel); + ASSERT_EQ(swmm_link_get_volumes_bulk(engine_, v.data(), oversize), + SWMM_OK); + for (int i = n_links_; i < oversize; ++i) { + EXPECT_EQ(v[i], kSentinel) << "tail index " << i; + } +} + +} // namespace diff --git a/tests/unit/engine/test_links_pump_stats_bulk.cpp b/tests/unit/engine/test_links_pump_stats_bulk.cpp new file mode 100644 index 000000000..5c75f6b88 --- /dev/null +++ b/tests/unit/engine/test_links_pump_stats_bulk.cpp @@ -0,0 +1,278 @@ +/** + * @file test_links_pump_stats_bulk.cpp + * @brief Unit tests for the bulk pump-statistics C API + * (`swmm_link_get_pump_stats_bulk`). + * + * @details The bulk getter is the single-call replacement for the three + * scalar accessors + * - swmm_link_get_stat_pump_cycles + * - swmm_link_get_stat_pump_on_time + * - swmm_link_get_stat_pump_volume + * and is intended to eliminate the @c 3N C-ABI crossings that the + * MCP server currently pays per "network-wide pump summary" call. + * + * These tests verify the ABI contract — they do **not** attempt to + * validate the underlying statistics math (that is covered by + * @c test_gap_fixes.cpp / @c DiagPumpStats and by simulation-level + * tests with pump-bearing models). The site_drainage fixture used + * here has only conduit links, which exercises the sentinel path + * (cycles[i] == -1 for non-pumps); equivalence with the scalar + * getters on a pump-bearing model is covered by the matching + * Python test @c TestPumpStatsBulk in @c test_new_api.py. + * + * Working directory is set to tests/unit/engine/data/ by + * @c tests/unit/engine/CMakeLists.txt. + * + * @see swmm_link_get_pump_stats_bulk + * @see docs/C_API_BINDINGS_MCP_IMPROVEMENT_PLAN.md Phase 1 + * @ingroup engine_tests + * + * @author Caleb Buahin + * @copyright Copyright (c) 2026 Caleb Buahin. All rights reserved. + * @license MIT License + */ + +#include +#include + +#include +#include + +namespace { + +// --------------------------------------------------------------------------- +// Fixture — opens and initialises the site_drainage_model.inp fixture so the +// per-link stat_pump_* vectors are sized. The site drainage model contains +// only conduit links; this exclusively exercises the non-pump sentinel path +// of the bulk getter (which is the worst case for sentinel emission). +// --------------------------------------------------------------------------- +class PumpStatsBulkTest : public ::testing::Test { +protected: + SWMM_Engine engine_ = nullptr; + int n_links_ = 0; + + void SetUp() override { + engine_ = swmm_engine_create(); + ASSERT_NE(engine_, nullptr); + ASSERT_EQ(swmm_engine_open(engine_, + "site_drainage_model.inp", + "site_drainage_model.rpt", + "site_drainage_model.out", + nullptr), + SWMM_OK) + << "open failed: " << swmm_get_last_error_msg(engine_); + ASSERT_EQ(swmm_engine_initialize(engine_), SWMM_OK); + n_links_ = swmm_link_count(engine_); + ASSERT_GT(n_links_, 0); + } + + void TearDown() override { + if (engine_) { + swmm_engine_close(engine_); + swmm_engine_destroy(engine_); + engine_ = nullptr; + } + } +}; + +// --------------------------------------------------------------------------- +// Contract 1 — happy path: caller-provided full-length buffers are filled. +// For an all-conduit model every cycles entry must be the documented sentinel +// (-1) and the corresponding on_time / volume must be zero. +// --------------------------------------------------------------------------- +TEST_F(PumpStatsBulkTest, FullBufferAllConduitsYieldsSentinel) { + std::vector cycles(n_links_, 0xDEADBEEF); + std::vector on_time(n_links_, -42.0); + std::vector volume(n_links_, -42.0); + + ASSERT_EQ(swmm_link_get_pump_stats_bulk(engine_, + cycles.data(), + on_time.data(), + volume.data(), + n_links_), + SWMM_OK); + + for (int i = 0; i < n_links_; ++i) { + EXPECT_EQ(cycles[i], -1) << "link " << i; + EXPECT_EQ(on_time[i], 0.0) << "link " << i; + EXPECT_EQ(volume[i], 0.0) << "link " << i; + } +} + +// --------------------------------------------------------------------------- +// Contract 2 — equivalence with scalar getters. For each link, the bulk +// outputs must match what the scalar accessor returns. (For non-pump links, +// the scalar accessor still returns the underlying vector entry — usually 0 — +// while the bulk getter substitutes the documented sentinel. We therefore +// only assert exact equality for pump links, and assert the sentinel for +// non-pump links.) +// --------------------------------------------------------------------------- +TEST_F(PumpStatsBulkTest, EquivalenceWithScalarGetters) { + std::vector cycles(n_links_); + std::vector on_time(n_links_); + std::vector volume(n_links_); + + ASSERT_EQ(swmm_link_get_pump_stats_bulk(engine_, + cycles.data(), + on_time.data(), + volume.data(), + n_links_), + SWMM_OK); + + for (int i = 0; i < n_links_; ++i) { + int scalar_cycles = 0xC0FFEE; + double scalar_ontime = -1.0; + double scalar_volume = -1.0; + ASSERT_EQ(swmm_link_get_stat_pump_cycles(engine_, i, &scalar_cycles), SWMM_OK); + ASSERT_EQ(swmm_link_get_stat_pump_on_time(engine_, i, &scalar_ontime), SWMM_OK); + ASSERT_EQ(swmm_link_get_stat_pump_volume(engine_, i, &scalar_volume), SWMM_OK); + + if (cycles[i] == -1) { + // Non-pump: bulk substitutes the sentinel; scalar returns the + // raw vector entry. Both representations should be consistent + // with "no pump activity recorded". + EXPECT_EQ(scalar_cycles, 0); + EXPECT_EQ(on_time[i], 0.0); + EXPECT_EQ(volume[i], 0.0); + } else { + // Pump: bulk and scalar must agree bit-for-bit. + EXPECT_EQ(cycles[i], scalar_cycles); + EXPECT_EQ(on_time[i], scalar_ontime); + EXPECT_EQ(volume[i], scalar_volume); + } + } +} + +// --------------------------------------------------------------------------- +// Contract 3 — partial buffer (count < n_links). Only the first @c count +// entries are written; the tail of the caller's buffer is left untouched. +// --------------------------------------------------------------------------- +TEST_F(PumpStatsBulkTest, PartialBufferWritesOnlyRequestedPrefix) { + ASSERT_GE(n_links_, 2); + const int half = n_links_ / 2; + + std::vector cycles(n_links_, 0xDEADBEEF); + std::vector on_time(n_links_, -42.0); + std::vector volume(n_links_, -42.0); + + ASSERT_EQ(swmm_link_get_pump_stats_bulk(engine_, + cycles.data(), + on_time.data(), + volume.data(), + half), + SWMM_OK); + + for (int i = 0; i < half; ++i) { + EXPECT_NE(cycles[i], 0xDEADBEEF) << "prefix entry " << i << " not written"; + EXPECT_NE(on_time[i], -42.0) << "prefix entry " << i << " not written"; + EXPECT_NE(volume[i], -42.0) << "prefix entry " << i << " not written"; + } + for (int i = half; i < n_links_; ++i) { + EXPECT_EQ(cycles[i], 0xDEADBEEF) << "suffix entry " << i << " was clobbered"; + EXPECT_EQ(on_time[i], -42.0) << "suffix entry " << i << " was clobbered"; + EXPECT_EQ(volume[i], -42.0) << "suffix entry " << i << " was clobbered"; + } +} + +// --------------------------------------------------------------------------- +// Contract 4 — selective output. Any subset of the three pointers may be +// non-NULL; the others are skipped. The iteration cost is identical (this +// is by design and documented in the header). +// --------------------------------------------------------------------------- +TEST_F(PumpStatsBulkTest, SelectiveOutputCyclesOnly) { + std::vector cycles(n_links_, 0xDEADBEEF); + ASSERT_EQ(swmm_link_get_pump_stats_bulk(engine_, + cycles.data(), + /*on_time=*/nullptr, + /*volume=*/nullptr, + n_links_), + SWMM_OK); + for (int i = 0; i < n_links_; ++i) { + EXPECT_EQ(cycles[i], -1) << "link " << i; + } +} + +TEST_F(PumpStatsBulkTest, SelectiveOutputOnTimeOnly) { + std::vector on_time(n_links_, -42.0); + ASSERT_EQ(swmm_link_get_pump_stats_bulk(engine_, + /*cycles=*/nullptr, + on_time.data(), + /*volume=*/nullptr, + n_links_), + SWMM_OK); + for (int i = 0; i < n_links_; ++i) { + EXPECT_EQ(on_time[i], 0.0) << "link " << i; + } +} + +// --------------------------------------------------------------------------- +// Contract 5 — bad parameters. The function must reject obviously invalid +// inputs at entry without writing to caller memory. +// --------------------------------------------------------------------------- +TEST_F(PumpStatsBulkTest, RejectsNullEngine) { + std::vector cycles(n_links_, 0xDEADBEEF); + std::vector on_time(n_links_, -42.0); + std::vector volume(n_links_, -42.0); + EXPECT_EQ(swmm_link_get_pump_stats_bulk(nullptr, + cycles.data(), + on_time.data(), + volume.data(), + n_links_), + SWMM_ERR_BADHANDLE); + // Buffers must not have been written. + EXPECT_EQ(cycles.front(), 0xDEADBEEF); + EXPECT_EQ(on_time.front(), -42.0); + EXPECT_EQ(volume.front(), -42.0); +} + +TEST_F(PumpStatsBulkTest, RejectsNonPositiveCount) { + int cycles = 0xDEADBEEF; + double on_time = -42.0; + double volume = -42.0; + EXPECT_EQ(swmm_link_get_pump_stats_bulk(engine_, + &cycles, &on_time, &volume, 0), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_link_get_pump_stats_bulk(engine_, + &cycles, &on_time, &volume, -3), + SWMM_ERR_BADPARAM); + // Untouched. + EXPECT_EQ(cycles, 0xDEADBEEF); + EXPECT_EQ(on_time, -42.0); + EXPECT_EQ(volume, -42.0); +} + +TEST_F(PumpStatsBulkTest, RejectsAllNullOutputs) { + // Calling with all three outputs null is almost certainly a programming + // error; the function returns BADPARAM rather than silently succeeding. + EXPECT_EQ(swmm_link_get_pump_stats_bulk(engine_, + nullptr, nullptr, nullptr, + n_links_), + SWMM_ERR_BADPARAM); +} + +// --------------------------------------------------------------------------- +// Contract 6 — count > n_links is clipped (consistent with other *_bulk +// getters in this API, e.g. swmm_link_get_flows_bulk). No write occurs past +// the n_links-th entry. +// --------------------------------------------------------------------------- +TEST_F(PumpStatsBulkTest, OversizedCountIsClippedAtNLinks) { + const int oversized = n_links_ + 7; + std::vector cycles(oversized, 0xDEADBEEF); + std::vector on_time(oversized, -42.0); + std::vector volume(oversized, -42.0); + + ASSERT_EQ(swmm_link_get_pump_stats_bulk(engine_, + cycles.data(), + on_time.data(), + volume.data(), + oversized), + SWMM_OK); + + for (int i = n_links_; i < oversized; ++i) { + EXPECT_EQ(cycles[i], 0xDEADBEEF) << "tail entry " << i << " was clobbered"; + EXPECT_EQ(on_time[i], -42.0) << "tail entry " << i << " was clobbered"; + EXPECT_EQ(volume[i], -42.0) << "tail entry " << i << " was clobbered"; + } +} + +} // namespace diff --git a/tests/unit/engine/test_nodes_bulk_phase3.cpp b/tests/unit/engine/test_nodes_bulk_phase3.cpp new file mode 100644 index 000000000..378bcd073 --- /dev/null +++ b/tests/unit/engine/test_nodes_bulk_phase3.cpp @@ -0,0 +1,238 @@ +/** + * @file test_nodes_bulk_phase3.cpp + * @brief Unit tests for the Phase 3 node-bulk C API additions — + * @ref swmm_node_get_volumes_bulk, @ref swmm_node_get_outflows_bulk, + * @ref swmm_node_get_losses_bulk, + * @ref swmm_node_get_lateral_inflows_bulk, and + * @ref swmm_node_get_ids_bulk. + * + * @details Each test verifies the ABI contract — null handling, count + * clipping, equivalence with the scalar accessor — using the + * site_drainage_model.inp fixture. The numerical content of the + * SoA columns is exercised by the existing simulation-level tests + * (test_site_drainage_model, test_routing); these tests focus on + * the bulk-vs-scalar parity, stride-packed ID format, and + * boundary cases. + * + * Working directory is set to tests/unit/engine/data/ by + * @c tests/unit/engine/CMakeLists.txt. + * + * @see docs/C_API_BINDINGS_MCP_IMPROVEMENT_PLAN.md Phase 3 — Nodes batch + * @ingroup engine_tests + * + * @author Caleb Buahin + * @copyright Copyright (c) 2026 Caleb Buahin. All rights reserved. + * @license MIT License + */ + +#include +#include +#include +#include + +#include +#include + +namespace { + +class NodesBulkPhase3Test : public ::testing::Test { +protected: + SWMM_Engine engine_ = nullptr; + int n_nodes_ = 0; + + void SetUp() override { + engine_ = swmm_engine_create(); + ASSERT_NE(engine_, nullptr); + ASSERT_EQ(swmm_engine_open(engine_, + "site_drainage_model.inp", + "site_drainage_model.rpt", + "site_drainage_model.out", + nullptr), + SWMM_OK) + << "open failed: " << swmm_get_last_error_msg(engine_); + ASSERT_EQ(swmm_engine_initialize(engine_), SWMM_OK); + n_nodes_ = swmm_node_count(engine_); + ASSERT_GT(n_nodes_, 0); + } + + void TearDown() override { + if (engine_) { + swmm_engine_close(engine_); + swmm_engine_destroy(engine_); + engine_ = nullptr; + } + } +}; + +// --------------------------------------------------------------------------- +// Equivalence — each bulk getter must match the scalar accessor for every +// node. This is the headline contract; if a bulk getter ever reads the +// wrong SoA column (the bug class that Phase 3 was created to surface, +// see plan Appendix A item 3) this test will flag it immediately. +// --------------------------------------------------------------------------- + +TEST_F(NodesBulkPhase3Test, VolumesBulkMatchesScalarPerNode) { + std::vector bulk(n_nodes_, -1.0); + ASSERT_EQ(swmm_node_get_volumes_bulk(engine_, bulk.data(), n_nodes_), + SWMM_OK); + for (int i = 0; i < n_nodes_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_node_get_volume(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "node " << i; + } +} + +TEST_F(NodesBulkPhase3Test, OutflowsBulkMatchesScalarPerNode) { + std::vector bulk(n_nodes_, -1.0); + ASSERT_EQ(swmm_node_get_outflows_bulk(engine_, bulk.data(), n_nodes_), + SWMM_OK); + for (int i = 0; i < n_nodes_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_node_get_outflow(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "node " << i; + } +} + +TEST_F(NodesBulkPhase3Test, LossesBulkMatchesScalarPerNode) { + std::vector bulk(n_nodes_, -1.0); + ASSERT_EQ(swmm_node_get_losses_bulk(engine_, bulk.data(), n_nodes_), + SWMM_OK); + for (int i = 0; i < n_nodes_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_node_get_losses(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "node " << i; + } +} + +TEST_F(NodesBulkPhase3Test, LateralInflowsBulkMatchesScalarPerNode) { + std::vector bulk(n_nodes_, -1.0); + ASSERT_EQ(swmm_node_get_lateral_inflows_bulk(engine_, bulk.data(), + n_nodes_), + SWMM_OK); + for (int i = 0; i < n_nodes_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_node_get_lateral_inflow(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "node " << i; + } +} + +// Backward-compat: lateral_inflows_bulk and the older inflows_bulk read +// the same SoA column (see plan Appendix A item 3). +TEST_F(NodesBulkPhase3Test, LateralInflowsBulkMatchesLegacyInflowsBulk) { + std::vector a(n_nodes_, -1.0), b(n_nodes_, -2.0); + ASSERT_EQ(swmm_node_get_lateral_inflows_bulk(engine_, a.data(), n_nodes_), + SWMM_OK); + ASSERT_EQ(swmm_node_get_inflows_bulk(engine_, b.data(), n_nodes_), + SWMM_OK); + for (int i = 0; i < n_nodes_; ++i) { + EXPECT_EQ(a[i], b[i]) << "node " << i; + } +} + +// --------------------------------------------------------------------------- +// IDs bulk — stride-packed UTF-8 format. +// --------------------------------------------------------------------------- + +TEST_F(NodesBulkPhase3Test, IdsBulkMatchesScalarPerNode) { + constexpr int kStride = 64; + std::vector buf(static_cast(n_nodes_) * kStride, '\xAA'); + ASSERT_EQ(swmm_node_get_ids_bulk(engine_, buf.data(), kStride, n_nodes_), + SWMM_OK); + for (int i = 0; i < n_nodes_; ++i) { + const char* bulk_id = buf.data() + i * kStride; + const char* scalar_id = swmm_node_id(engine_, i); + ASSERT_NE(scalar_id, nullptr) << "node " << i; + EXPECT_STREQ(bulk_id, scalar_id) << "node " << i; + } +} + +TEST_F(NodesBulkPhase3Test, IdsBulkTruncatesAtStride) { + // Find the longest existing ID and pick a stride that forces truncation. + int longest = 0; + for (int i = 0; i < n_nodes_; ++i) { + int L = static_cast(std::strlen(swmm_node_id(engine_, i))); + if (L > longest) longest = L; + } + if (longest <= 1) { + GTEST_SKIP() << "Fixture's longest node ID is too short to " + "meaningfully test truncation."; + } + const int stride = longest; // exactly long enough → 1-char truncation + std::vector buf(static_cast(n_nodes_) * stride, '\xAA'); + ASSERT_EQ(swmm_node_get_ids_bulk(engine_, buf.data(), stride, n_nodes_), + SWMM_OK); + for (int i = 0; i < n_nodes_; ++i) { + const char* slot = buf.data() + i * stride; + // Slot must be NUL-terminated and at most stride-1 bytes. + const std::size_t len = std::strlen(slot); + EXPECT_LE(len, static_cast(stride - 1)) << "node " << i; + } +} + +// --------------------------------------------------------------------------- +// Bad-param contracts — every Phase 3 bulk getter rejects obvious misuse. +// --------------------------------------------------------------------------- + +TEST_F(NodesBulkPhase3Test, RejectsNullBuffer) { + EXPECT_EQ(swmm_node_get_volumes_bulk(engine_, nullptr, n_nodes_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_node_get_outflows_bulk(engine_, nullptr, n_nodes_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_node_get_losses_bulk(engine_, nullptr, n_nodes_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_node_get_lateral_inflows_bulk(engine_, nullptr, n_nodes_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_node_get_ids_bulk(engine_, nullptr, 64, n_nodes_), + SWMM_ERR_BADPARAM); +} + +TEST_F(NodesBulkPhase3Test, RejectsNonPositiveCount) { + std::vector v(n_nodes_, 0.0); + EXPECT_EQ(swmm_node_get_volumes_bulk(engine_, v.data(), 0), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_node_get_volumes_bulk(engine_, v.data(), -5), + SWMM_ERR_BADPARAM); +} + +TEST_F(NodesBulkPhase3Test, IdsBulkRejectsTinyStride) { + std::vector buf(n_nodes_, '\0'); + // stride < 2 is invalid (no room for at least one char + NUL). + EXPECT_EQ(swmm_node_get_ids_bulk(engine_, buf.data(), 1, n_nodes_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_node_get_ids_bulk(engine_, buf.data(), 0, n_nodes_), + SWMM_ERR_BADPARAM); +} + +TEST_F(NodesBulkPhase3Test, RejectsNullEngine) { + std::vector v(n_nodes_, 0.0); + EXPECT_EQ(swmm_node_get_volumes_bulk(nullptr, v.data(), n_nodes_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_node_get_outflows_bulk(nullptr, v.data(), n_nodes_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_node_get_losses_bulk(nullptr, v.data(), n_nodes_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_node_get_lateral_inflows_bulk(nullptr, v.data(), n_nodes_), + SWMM_ERR_BADHANDLE); + std::vector buf(n_nodes_ * 64, '\0'); + EXPECT_EQ(swmm_node_get_ids_bulk(nullptr, buf.data(), 64, n_nodes_), + SWMM_ERR_BADHANDLE); +} + +// --------------------------------------------------------------------------- +// Count clipping — passing count > n_nodes only writes the first +// n_nodes entries (consistent with the existing bulk getters). +// --------------------------------------------------------------------------- + +TEST_F(NodesBulkPhase3Test, OversizedCountClippedAtNNodes) { + constexpr double kSentinel = -42.0; + const int oversize = n_nodes_ + 5; + std::vector v(oversize, kSentinel); + ASSERT_EQ(swmm_node_get_volumes_bulk(engine_, v.data(), oversize), + SWMM_OK); + // First n_nodes entries must have been overwritten; tail untouched. + for (int i = n_nodes_; i < oversize; ++i) { + EXPECT_EQ(v[i], kSentinel) << "tail index " << i; + } +} + +} // namespace diff --git a/tests/unit/engine/test_statistics_bulk_phase3.cpp b/tests/unit/engine/test_statistics_bulk_phase3.cpp new file mode 100644 index 000000000..0f336c043 --- /dev/null +++ b/tests/unit/engine/test_statistics_bulk_phase3.cpp @@ -0,0 +1,200 @@ +/** + * @file test_statistics_bulk_phase3.cpp + * @brief Unit tests for the Phase 3 statistics-bulk C API additions — + * @ref swmm_stat_node_max_overflow_bulk, + * @ref swmm_stat_node_vol_flooded_bulk, + * @ref swmm_stat_node_time_flooded_bulk, and + * @ref swmm_stat_subcatch_max_runoff_bulk. + * + * @details These functions read cumulative statistics that are populated by + * the simulation, so the fixture runs the full site_drainage_model + * through to ENDED before the assertions execute. Numerical + * equivalence with the scalar accessors is the primary contract; + * bad-param paths are also exercised. + * + * @see docs/C_API_BINDINGS_MCP_IMPROVEMENT_PLAN.md Phase 3 — Statistics batch + * @ingroup engine_tests + * + * @author Caleb Buahin + * @copyright Copyright (c) 2026 Caleb Buahin. All rights reserved. + * @license MIT License + */ + +#include +#include + +#include +#include +#include +#include + +namespace { + +class StatsBulkPhase3Test : public ::testing::Test { +protected: + SWMM_Engine engine_ = nullptr; + int n_nodes_ = 0; + int n_subs_ = 0; + + void SetUp() override { + engine_ = swmm_engine_create(); + ASSERT_NE(engine_, nullptr); + ASSERT_EQ(swmm_engine_open(engine_, + "site_drainage_model.inp", + "site_drainage_model.rpt", + "site_drainage_model.out", + nullptr), + SWMM_OK) + << "open failed: " << swmm_get_last_error_msg(engine_); + ASSERT_EQ(swmm_engine_initialize(engine_), SWMM_OK); + ASSERT_EQ(swmm_engine_start(engine_, 1), SWMM_OK); + + // Drive the simulation to completion so the cumulative stat + // vectors are populated. The fixture is small enough that this + // is fast (< 1s on a laptop) — see test_site_drainage_model for + // the full simulation contract test. + double elapsed = 0.0; + while (true) { + int rc = swmm_engine_step(engine_, &elapsed); + if (rc != 0) break; + if (elapsed <= 0.0) break; + } + ASSERT_EQ(swmm_engine_end(engine_), SWMM_OK); + + n_nodes_ = swmm_node_count(engine_); + n_subs_ = swmm_subcatch_count(engine_); + ASSERT_GT(n_nodes_, 0); + ASSERT_GT(n_subs_, 0); + } + + void TearDown() override { + if (engine_) { + swmm_engine_close(engine_); + swmm_engine_destroy(engine_); + engine_ = nullptr; + } + } +}; + +// --------------------------------------------------------------------------- +// Equivalence — each bulk getter must match the scalar accessor for every +// element of the model. +// --------------------------------------------------------------------------- + +TEST_F(StatsBulkPhase3Test, NodeMaxOverflowBulkMatchesScalar) { + std::vector bulk(n_nodes_, -42.0); + ASSERT_EQ(swmm_stat_node_max_overflow_bulk(engine_, bulk.data(), n_nodes_), + SWMM_OK); + for (int i = 0; i < n_nodes_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_stat_node_max_overflow(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "node " << i; + } +} + +TEST_F(StatsBulkPhase3Test, NodeVolFloodedBulkMatchesScalar) { + std::vector bulk(n_nodes_, -42.0); + ASSERT_EQ(swmm_stat_node_vol_flooded_bulk(engine_, bulk.data(), n_nodes_), + SWMM_OK); + for (int i = 0; i < n_nodes_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_stat_node_vol_flooded(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "node " << i; + } +} + +TEST_F(StatsBulkPhase3Test, NodeTimeFloodedBulkMatchesScalar) { + std::vector bulk(n_nodes_, -42.0); + ASSERT_EQ(swmm_stat_node_time_flooded_bulk(engine_, bulk.data(), n_nodes_), + SWMM_OK); + for (int i = 0; i < n_nodes_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_stat_node_time_flooded(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "node " << i; + } +} + +TEST_F(StatsBulkPhase3Test, SubcatchMaxRunoffBulkMatchesScalar) { + std::vector bulk(n_subs_, -42.0); + ASSERT_EQ(swmm_stat_subcatch_max_runoff_bulk(engine_, bulk.data(), n_subs_), + SWMM_OK); + for (int i = 0; i < n_subs_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_stat_subcatch_max_runoff(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "subcatch " << i; + } +} + +// --------------------------------------------------------------------------- +// Non-negativity sanity — these are cumulative non-negative quantities. A +// regression that read the wrong SoA column would likely produce negatives. +// --------------------------------------------------------------------------- + +TEST_F(StatsBulkPhase3Test, AllStatsAreNonNegative) { + std::vector mo(n_nodes_), vf(n_nodes_), tf(n_nodes_); + std::vector sr(n_subs_); + ASSERT_EQ(swmm_stat_node_max_overflow_bulk(engine_, mo.data(), n_nodes_), SWMM_OK); + ASSERT_EQ(swmm_stat_node_vol_flooded_bulk(engine_, vf.data(), n_nodes_), SWMM_OK); + ASSERT_EQ(swmm_stat_node_time_flooded_bulk(engine_, tf.data(), n_nodes_), SWMM_OK); + ASSERT_EQ(swmm_stat_subcatch_max_runoff_bulk(engine_, sr.data(), n_subs_), SWMM_OK); + for (int i = 0; i < n_nodes_; ++i) { + EXPECT_GE(mo[i], 0.0) << "node " << i << " max_overflow"; + EXPECT_GE(vf[i], 0.0) << "node " << i << " vol_flooded"; + EXPECT_GE(tf[i], 0.0) << "node " << i << " time_flooded"; + } + for (int i = 0; i < n_subs_; ++i) { + EXPECT_GE(sr[i], 0.0) << "subcatch " << i << " max_runoff"; + } +} + +// --------------------------------------------------------------------------- +// Bad-param contracts. +// --------------------------------------------------------------------------- + +TEST_F(StatsBulkPhase3Test, RejectsNullBuffer) { + EXPECT_EQ(swmm_stat_node_max_overflow_bulk(engine_, nullptr, n_nodes_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_stat_node_vol_flooded_bulk(engine_, nullptr, n_nodes_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_stat_node_time_flooded_bulk(engine_, nullptr, n_nodes_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_stat_subcatch_max_runoff_bulk(engine_, nullptr, n_subs_), + SWMM_ERR_BADPARAM); +} + +TEST_F(StatsBulkPhase3Test, RejectsNonPositiveCount) { + std::vector v(n_nodes_, 0.0); + EXPECT_EQ(swmm_stat_node_max_overflow_bulk(engine_, v.data(), 0), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_stat_node_max_overflow_bulk(engine_, v.data(), -3), + SWMM_ERR_BADPARAM); +} + +TEST_F(StatsBulkPhase3Test, RejectsNullEngine) { + std::vector v(std::max(n_nodes_, n_subs_), 0.0); + EXPECT_EQ(swmm_stat_node_max_overflow_bulk(nullptr, v.data(), n_nodes_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_stat_node_vol_flooded_bulk(nullptr, v.data(), n_nodes_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_stat_node_time_flooded_bulk(nullptr, v.data(), n_nodes_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_stat_subcatch_max_runoff_bulk(nullptr, v.data(), n_subs_), + SWMM_ERR_BADHANDLE); +} + +// --------------------------------------------------------------------------- +// Count clipping. +// --------------------------------------------------------------------------- + +TEST_F(StatsBulkPhase3Test, OversizedCountClippedAtN) { + constexpr double kSentinel = -42.0; + const int oversize = n_nodes_ + 5; + std::vector v(oversize, kSentinel); + ASSERT_EQ(swmm_stat_node_max_overflow_bulk(engine_, v.data(), oversize), + SWMM_OK); + for (int i = n_nodes_; i < oversize; ++i) { + EXPECT_EQ(v[i], kSentinel) << "tail index " << i; + } +} + +} // namespace diff --git a/tests/unit/engine/test_subcatchments_bulk_phase3.cpp b/tests/unit/engine/test_subcatchments_bulk_phase3.cpp new file mode 100644 index 000000000..2ed58fe30 --- /dev/null +++ b/tests/unit/engine/test_subcatchments_bulk_phase3.cpp @@ -0,0 +1,220 @@ +/** + * @file test_subcatchments_bulk_phase3.cpp + * @brief Unit tests for the Phase 3 subcatchment-bulk C API additions — + * @ref swmm_subcatch_get_rainfall_bulk, + * @ref swmm_subcatch_get_evap_bulk, + * @ref swmm_subcatch_get_infil_bulk, + * @ref swmm_subcatch_get_snow_depth_bulk, and + * @ref swmm_subcatch_get_ids_bulk. + * + * @details Mirrors the contract style established by the Nodes / Links + * Phase 3 tests: per-subcatch parity with the scalar accessor, + * stride-packed ID round-trip + truncation, and bad-param contracts. + * + * Snow depth currently returns zeros from both the scalar and bulk + * variants pending full snow-state integration (see the placeholder + * comment in @c swmm_subcatch_get_snow_depth). The equivalence test + * below intentionally exercises that path. + * + * @see docs/C_API_BINDINGS_MCP_IMPROVEMENT_PLAN.md Phase 3 — Subcatchments batch + * @ingroup engine_tests + * + * @author Caleb Buahin + * @copyright Copyright (c) 2026 Caleb Buahin. All rights reserved. + * @license MIT License + */ + +#include +#include +#include +#include + +#include +#include + +namespace { + +class SubcatchmentsBulkPhase3Test : public ::testing::Test { +protected: + SWMM_Engine engine_ = nullptr; + int n_subs_ = 0; + + void SetUp() override { + engine_ = swmm_engine_create(); + ASSERT_NE(engine_, nullptr); + ASSERT_EQ(swmm_engine_open(engine_, + "site_drainage_model.inp", + "site_drainage_model.rpt", + "site_drainage_model.out", + nullptr), + SWMM_OK) + << "open failed: " << swmm_get_last_error_msg(engine_); + ASSERT_EQ(swmm_engine_initialize(engine_), SWMM_OK); + n_subs_ = swmm_subcatch_count(engine_); + ASSERT_GT(n_subs_, 0); + } + + void TearDown() override { + if (engine_) { + swmm_engine_close(engine_); + swmm_engine_destroy(engine_); + engine_ = nullptr; + } + } +}; + +// --------------------------------------------------------------------------- +// Equivalence — each bulk getter must match the scalar accessor for every +// subcatchment. +// --------------------------------------------------------------------------- + +TEST_F(SubcatchmentsBulkPhase3Test, RainfallBulkMatchesScalarPerSubcatch) { + std::vector bulk(n_subs_, -42.0); + ASSERT_EQ(swmm_subcatch_get_rainfall_bulk(engine_, bulk.data(), n_subs_), + SWMM_OK); + for (int i = 0; i < n_subs_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_subcatch_get_rainfall(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "subcatch " << i; + } +} + +TEST_F(SubcatchmentsBulkPhase3Test, EvapBulkMatchesScalarPerSubcatch) { + std::vector bulk(n_subs_, -42.0); + ASSERT_EQ(swmm_subcatch_get_evap_bulk(engine_, bulk.data(), n_subs_), + SWMM_OK); + for (int i = 0; i < n_subs_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_subcatch_get_evap(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "subcatch " << i; + } +} + +TEST_F(SubcatchmentsBulkPhase3Test, InfilBulkMatchesScalarPerSubcatch) { + std::vector bulk(n_subs_, -42.0); + ASSERT_EQ(swmm_subcatch_get_infil_bulk(engine_, bulk.data(), n_subs_), + SWMM_OK); + for (int i = 0; i < n_subs_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_subcatch_get_infil(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "subcatch " << i; + } +} + +TEST_F(SubcatchmentsBulkPhase3Test, SnowDepthBulkMatchesScalarPerSubcatch) { + // Both scalar and bulk currently return 0.0 for every subcatch — the + // test verifies that documented placeholder behaviour explicitly. + std::vector bulk(n_subs_, -42.0); + ASSERT_EQ(swmm_subcatch_get_snow_depth_bulk(engine_, bulk.data(), n_subs_), + SWMM_OK); + for (int i = 0; i < n_subs_; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_subcatch_get_snow_depth(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "subcatch " << i; + EXPECT_EQ(bulk[i], 0.0) << "subcatch " << i + << " (placeholder snow state should be zero)"; + } +} + +// --------------------------------------------------------------------------- +// IDs bulk — stride-packed UTF-8 format. +// --------------------------------------------------------------------------- + +TEST_F(SubcatchmentsBulkPhase3Test, IdsBulkMatchesScalarPerSubcatch) { + constexpr int kStride = 64; + std::vector buf(static_cast(n_subs_) * kStride, '\xAA'); + ASSERT_EQ(swmm_subcatch_get_ids_bulk(engine_, buf.data(), kStride, n_subs_), + SWMM_OK); + for (int i = 0; i < n_subs_; ++i) { + const char* bulk_id = buf.data() + i * kStride; + const char* scalar_id = swmm_subcatch_id(engine_, i); + ASSERT_NE(scalar_id, nullptr) << "subcatch " << i; + EXPECT_STREQ(bulk_id, scalar_id) << "subcatch " << i; + } +} + +TEST_F(SubcatchmentsBulkPhase3Test, IdsBulkTruncatesAtStride) { + int longest = 0; + for (int i = 0; i < n_subs_; ++i) { + int L = static_cast(std::strlen(swmm_subcatch_id(engine_, i))); + if (L > longest) longest = L; + } + if (longest <= 1) { + GTEST_SKIP() << "Fixture's longest subcatch ID is too short to " + "meaningfully test truncation."; + } + const int stride = longest; + std::vector buf(static_cast(n_subs_) * stride, '\xAA'); + ASSERT_EQ(swmm_subcatch_get_ids_bulk(engine_, buf.data(), stride, n_subs_), + SWMM_OK); + for (int i = 0; i < n_subs_; ++i) { + const char* slot = buf.data() + i * stride; + const std::size_t len = std::strlen(slot); + EXPECT_LE(len, static_cast(stride - 1)) << "subcatch " << i; + } +} + +// --------------------------------------------------------------------------- +// Bad-param contracts. +// --------------------------------------------------------------------------- + +TEST_F(SubcatchmentsBulkPhase3Test, RejectsNullBuffer) { + EXPECT_EQ(swmm_subcatch_get_rainfall_bulk(engine_, nullptr, n_subs_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_subcatch_get_evap_bulk(engine_, nullptr, n_subs_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_subcatch_get_infil_bulk(engine_, nullptr, n_subs_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_subcatch_get_snow_depth_bulk(engine_, nullptr, n_subs_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_subcatch_get_ids_bulk(engine_, nullptr, 64, n_subs_), + SWMM_ERR_BADPARAM); +} + +TEST_F(SubcatchmentsBulkPhase3Test, RejectsNonPositiveCount) { + std::vector v(n_subs_, 0.0); + EXPECT_EQ(swmm_subcatch_get_rainfall_bulk(engine_, v.data(), 0), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_subcatch_get_rainfall_bulk(engine_, v.data(), -2), + SWMM_ERR_BADPARAM); +} + +TEST_F(SubcatchmentsBulkPhase3Test, IdsBulkRejectsTinyStride) { + std::vector buf(n_subs_, '\0'); + EXPECT_EQ(swmm_subcatch_get_ids_bulk(engine_, buf.data(), 1, n_subs_), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_subcatch_get_ids_bulk(engine_, buf.data(), 0, n_subs_), + SWMM_ERR_BADPARAM); +} + +TEST_F(SubcatchmentsBulkPhase3Test, RejectsNullEngine) { + std::vector v(n_subs_, 0.0); + EXPECT_EQ(swmm_subcatch_get_rainfall_bulk(nullptr, v.data(), n_subs_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_subcatch_get_evap_bulk(nullptr, v.data(), n_subs_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_subcatch_get_infil_bulk(nullptr, v.data(), n_subs_), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_subcatch_get_snow_depth_bulk(nullptr, v.data(), n_subs_), + SWMM_ERR_BADHANDLE); + std::vector buf(n_subs_ * 64, '\0'); + EXPECT_EQ(swmm_subcatch_get_ids_bulk(nullptr, buf.data(), 64, n_subs_), + SWMM_ERR_BADHANDLE); +} + +// --------------------------------------------------------------------------- +// Count clipping. +// --------------------------------------------------------------------------- + +TEST_F(SubcatchmentsBulkPhase3Test, OversizedCountClippedAtNSubcatches) { + constexpr double kSentinel = -42.0; + const int oversize = n_subs_ + 5; + std::vector v(oversize, kSentinel); + ASSERT_EQ(swmm_subcatch_get_rainfall_bulk(engine_, v.data(), oversize), + SWMM_OK); + for (int i = n_subs_; i < oversize; ++i) { + EXPECT_EQ(v[i], kSentinel) << "tail index " << i; + } +} + +} // namespace diff --git a/tests/unit/engine/test_transect_mutation_api.cpp b/tests/unit/engine/test_transect_mutation_api.cpp new file mode 100644 index 000000000..ea9df9c83 --- /dev/null +++ b/tests/unit/engine/test_transect_mutation_api.cpp @@ -0,0 +1,345 @@ +/** + * @file test_transect_mutation_api.cpp + * @brief DA-ENG-09 + BQ-TR-02 — Unit tests for the transect mutation surface + * used by Slice BQ Phase 6.7.4 TransectEditor (GUI). + * + * @details Covers the new C API additions to ::openswmm_infrastructure.h: + * - swmm_transect_get_roughness (DA-ENG-09) + * - swmm_transect_get/set_bank_stations (DA-ENG-09) + * - swmm_transect_get/set_encroachment_stations (BQ-TR-02) + * - swmm_transect_get/set_modifiers (DA-ENG-09) + * - swmm_transect_get/set_comments (DA-ENG-09) + * - swmm_transect_get_station_count (DA-ENG-09) + * - swmm_transect_get_station (DA-ENG-09) + * - swmm_transect_clear_stations (DA-ENG-09) + * - swmm_transect_rename (DA-ENG-09) + * - swmm_transect_remove (DA-ENG-09) + * + * @see include/openswmm/engine/openswmm_infrastructure.h + * @see docs/GUI_IMPLEMENTATION_PLAN.md (Slice BQ Phase 6.7.4 detail) + */ + +#include + +#include +#include + +#include +#include + +// --------------------------------------------------------------------------- +// Fixture — bare engine with one transect "T1" already added. +// --------------------------------------------------------------------------- + +class TransectMutationTest : public ::testing::Test { +protected: + SWMM_Engine engine = nullptr; + int t1_idx = -1; + + void SetUp() override { + engine = swmm_engine_new(); + ASSERT_NE(engine, nullptr); + ASSERT_EQ(swmm_transect_add(engine, "T1"), SWMM_OK); + t1_idx = swmm_transect_index(engine, "T1"); + ASSERT_GE(t1_idx, 0); + } + + void TearDown() override { if (engine) swmm_engine_destroy(engine); } +}; + +// --------------------------------------------------------------------------- +// Defaults — swmm_transect_add seeds every new field with documented defaults. +// --------------------------------------------------------------------------- + +TEST_F(TransectMutationTest, AddSeedsDefaultsForAllNewFields) { + // Roughness: 0.0 for all three. + double nL = 99, nR = 99, nC = 99; + EXPECT_EQ(swmm_transect_get_roughness(engine, t1_idx, &nL, &nR, &nC), SWMM_OK); + EXPECT_DOUBLE_EQ(nL, 0.0); + EXPECT_DOUBLE_EQ(nR, 0.0); + EXPECT_DOUBLE_EQ(nC, 0.0); + + // Bank stations: 0.0 / 0.0. + double bL = 99, bR = 99; + EXPECT_EQ(swmm_transect_get_bank_stations(engine, t1_idx, &bL, &bR), SWMM_OK); + EXPECT_DOUBLE_EQ(bL, 0.0); + EXPECT_DOUBLE_EQ(bR, 0.0); + + // Encroachment stations: 0.0 / 0.0 (BQ-TR-02). + double eL = 99, eR = 99; + EXPECT_EQ(swmm_transect_get_encroachment_stations(engine, t1_idx, &eL, &eR), SWMM_OK); + EXPECT_DOUBLE_EQ(eL, 0.0); + EXPECT_DOUBLE_EQ(eR, 0.0); + + // Modifiers: x_factor=1.0, y_factor=1.0, length_factor=1.0. + double xF = 0, yF = 0, lF = 0; + EXPECT_EQ(swmm_transect_get_modifiers(engine, t1_idx, &xF, &yF, &lF), SWMM_OK); + EXPECT_DOUBLE_EQ(xF, 1.0); + EXPECT_DOUBLE_EQ(yF, 1.0); + EXPECT_DOUBLE_EQ(lF, 1.0); + + // Comments: empty string. + char buf[64] = "garbage"; + EXPECT_EQ(swmm_transect_get_comments(engine, t1_idx, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, ""); + + // Station count: 0. + EXPECT_EQ(swmm_transect_get_station_count(engine, t1_idx), 0); +} + +// --------------------------------------------------------------------------- +// Roughness — set then read back. +// --------------------------------------------------------------------------- + +TEST_F(TransectMutationTest, SetRoughnessRoundTripsThroughGetter) { + ASSERT_EQ(swmm_transect_set_roughness(engine, t1_idx, 0.04, 0.05, 0.03), SWMM_OK); + + double nL = 0, nR = 0, nC = 0; + EXPECT_EQ(swmm_transect_get_roughness(engine, t1_idx, &nL, &nR, &nC), SWMM_OK); + EXPECT_DOUBLE_EQ(nL, 0.04); + EXPECT_DOUBLE_EQ(nR, 0.05); + EXPECT_DOUBLE_EQ(nC, 0.03); +} + +TEST_F(TransectMutationTest, GetRoughnessAcceptsNullOutParams) { + ASSERT_EQ(swmm_transect_set_roughness(engine, t1_idx, 0.04, 0.05, 0.03), SWMM_OK); + + // Each parameter independently NULLable. + double v = 0; + EXPECT_EQ(swmm_transect_get_roughness(engine, t1_idx, nullptr, nullptr, &v), SWMM_OK); + EXPECT_DOUBLE_EQ(v, 0.03); + EXPECT_EQ(swmm_transect_get_roughness(engine, t1_idx, &v, nullptr, nullptr), SWMM_OK); + EXPECT_DOUBLE_EQ(v, 0.04); + // All three NULL is also OK — caller is essentially just probing validity. + EXPECT_EQ(swmm_transect_get_roughness(engine, t1_idx, nullptr, nullptr, nullptr), SWMM_OK); +} + +// --------------------------------------------------------------------------- +// Bank stations — set then read back, independent of encroachment. +// --------------------------------------------------------------------------- + +TEST_F(TransectMutationTest, SetBankStationsRoundTrips) { + EXPECT_EQ(swmm_transect_set_bank_stations(engine, t1_idx, 10.0, 90.0), SWMM_OK); + + double bL = 0, bR = 0; + EXPECT_EQ(swmm_transect_get_bank_stations(engine, t1_idx, &bL, &bR), SWMM_OK); + EXPECT_DOUBLE_EQ(bL, 10.0); + EXPECT_DOUBLE_EQ(bR, 90.0); + + // Setting bank stations does NOT touch encroachment stations. + double eL = -1, eR = -1; + EXPECT_EQ(swmm_transect_get_encroachment_stations(engine, t1_idx, &eL, &eR), SWMM_OK); + EXPECT_DOUBLE_EQ(eL, 0.0); + EXPECT_DOUBLE_EQ(eR, 0.0); +} + +// --------------------------------------------------------------------------- +// Encroachment stations — BQ-TR-02; independent of bank stations. +// --------------------------------------------------------------------------- + +TEST_F(TransectMutationTest, SetEncroachmentStationsRoundTripsIndependentOfBank) { + ASSERT_EQ(swmm_transect_set_bank_stations(engine, t1_idx, 10.0, 90.0), SWMM_OK); + EXPECT_EQ(swmm_transect_set_encroachment_stations(engine, t1_idx, 5.0, 95.0), SWMM_OK); + + double eL = 0, eR = 0; + EXPECT_EQ(swmm_transect_get_encroachment_stations(engine, t1_idx, &eL, &eR), SWMM_OK); + EXPECT_DOUBLE_EQ(eL, 5.0); + EXPECT_DOUBLE_EQ(eR, 95.0); + + // Bank stations unchanged by the encroachment setter. + double bL = 0, bR = 0; + EXPECT_EQ(swmm_transect_get_bank_stations(engine, t1_idx, &bL, &bR), SWMM_OK); + EXPECT_DOUBLE_EQ(bL, 10.0); + EXPECT_DOUBLE_EQ(bR, 90.0); +} + +// --------------------------------------------------------------------------- +// Modifiers — x_factor, y_factor, length_factor (meander). +// --------------------------------------------------------------------------- + +TEST_F(TransectMutationTest, SetModifiersRoundTrips) { + EXPECT_EQ(swmm_transect_set_modifiers(engine, t1_idx, 2.5, -1.0, 1.25), SWMM_OK); + + double xF = 0, yF = 0, lF = 0; + EXPECT_EQ(swmm_transect_get_modifiers(engine, t1_idx, &xF, &yF, &lF), SWMM_OK); + EXPECT_DOUBLE_EQ(xF, 2.5); + EXPECT_DOUBLE_EQ(yF, -1.0); + EXPECT_DOUBLE_EQ(lF, 1.25); +} + +// --------------------------------------------------------------------------- +// Comments — round-trip + buffer truncation + NULL clear. +// --------------------------------------------------------------------------- + +TEST_F(TransectMutationTest, SetCommentsRoundTrips) { + const char* msg = "Downstream of culvert; field-surveyed 2024-06-12."; + EXPECT_EQ(swmm_transect_set_comments(engine, t1_idx, msg), SWMM_OK); + + char buf[128] = {}; + EXPECT_EQ(swmm_transect_get_comments(engine, t1_idx, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, msg); +} + +TEST_F(TransectMutationTest, GetCommentsTruncatesWithNulTerminator) { + ASSERT_EQ(swmm_transect_set_comments(engine, t1_idx, "abcdefghij"), SWMM_OK); + char buf[6] = {}; + EXPECT_EQ(swmm_transect_get_comments(engine, t1_idx, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, "abcde"); // 5 chars + NUL + EXPECT_EQ(buf[5], '\0'); +} + +TEST_F(TransectMutationTest, SetCommentsNullClearsContent) { + ASSERT_EQ(swmm_transect_set_comments(engine, t1_idx, "something"), SWMM_OK); + EXPECT_EQ(swmm_transect_set_comments(engine, t1_idx, nullptr), SWMM_OK); + char buf[32] = "junk"; + EXPECT_EQ(swmm_transect_get_comments(engine, t1_idx, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, ""); +} + +TEST_F(TransectMutationTest, GetCommentsRejectsBadBuffer) { + char buf[1]; + EXPECT_EQ(swmm_transect_get_comments(engine, t1_idx, nullptr, 64), SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_transect_get_comments(engine, t1_idx, buf, 0), SWMM_ERR_BADPARAM); +} + +// --------------------------------------------------------------------------- +// Stations — count, get, clear. +// --------------------------------------------------------------------------- + +TEST_F(TransectMutationTest, StationsRoundTripWithCountAndGet) { + ASSERT_EQ(swmm_transect_add_station(engine, t1_idx, 0.0, 10.0), SWMM_OK); + ASSERT_EQ(swmm_transect_add_station(engine, t1_idx, 5.0, 2.0), SWMM_OK); + ASSERT_EQ(swmm_transect_add_station(engine, t1_idx, 12.0, 3.0), SWMM_OK); + ASSERT_EQ(swmm_transect_add_station(engine, t1_idx, 20.0, 11.0), SWMM_OK); + + EXPECT_EQ(swmm_transect_get_station_count(engine, t1_idx), 4); + + double s = 0, e = 0; + EXPECT_EQ(swmm_transect_get_station(engine, t1_idx, 0, &s, &e), SWMM_OK); + EXPECT_DOUBLE_EQ(s, 0.0); EXPECT_DOUBLE_EQ(e, 10.0); + EXPECT_EQ(swmm_transect_get_station(engine, t1_idx, 2, &s, &e), SWMM_OK); + EXPECT_DOUBLE_EQ(s, 12.0); EXPECT_DOUBLE_EQ(e, 3.0); +} + +TEST_F(TransectMutationTest, ClearStationsResetsToEmpty) { + ASSERT_EQ(swmm_transect_add_station(engine, t1_idx, 0.0, 10.0), SWMM_OK); + ASSERT_EQ(swmm_transect_add_station(engine, t1_idx, 5.0, 2.0), SWMM_OK); + ASSERT_EQ(swmm_transect_get_station_count(engine, t1_idx), 2); + + EXPECT_EQ(swmm_transect_clear_stations(engine, t1_idx), SWMM_OK); + EXPECT_EQ(swmm_transect_get_station_count(engine, t1_idx), 0); + + // After clear, can re-add (the snapshot-and-rewrite path used by the GUI). + EXPECT_EQ(swmm_transect_add_station(engine, t1_idx, 1.0, 1.0), SWMM_OK); + EXPECT_EQ(swmm_transect_get_station_count(engine, t1_idx), 1); +} + +TEST_F(TransectMutationTest, GetStationOutOfRangeReturnsBadIndex) { + ASSERT_EQ(swmm_transect_add_station(engine, t1_idx, 0.0, 10.0), SWMM_OK); + double s = 0, e = 0; + EXPECT_EQ(swmm_transect_get_station(engine, t1_idx, -1, &s, &e), SWMM_ERR_BADINDEX); + EXPECT_EQ(swmm_transect_get_station(engine, t1_idx, 1, &s, &e), SWMM_ERR_BADINDEX); +} + +// --------------------------------------------------------------------------- +// Rename — case-insensitive collision, same-name no-op, index lookup updated. +// --------------------------------------------------------------------------- + +TEST_F(TransectMutationTest, RenameUpdatesIdAndIndexLookup) { + EXPECT_EQ(swmm_transect_rename(engine, t1_idx, "MainChannel"), SWMM_OK); + EXPECT_STREQ(swmm_transect_id(engine, t1_idx), "MainChannel"); + EXPECT_EQ(swmm_transect_index(engine, "MainChannel"), t1_idx); + EXPECT_EQ(swmm_transect_index(engine, "T1"), -1); +} + +TEST_F(TransectMutationTest, RenameSameNameIsNoop) { + EXPECT_EQ(swmm_transect_rename(engine, t1_idx, "T1"), SWMM_OK); + EXPECT_STREQ(swmm_transect_id(engine, t1_idx), "T1"); +} + +TEST_F(TransectMutationTest, RenameRejectsCaseInsensitiveCollision) { + ASSERT_EQ(swmm_transect_add(engine, "T2"), SWMM_OK); + // Try to rename T1 → "t2" (different case but collides). + EXPECT_EQ(swmm_transect_rename(engine, t1_idx, "t2"), SWMM_ERR_BADPARAM); + // Original name preserved. + EXPECT_STREQ(swmm_transect_id(engine, t1_idx), "T1"); +} + +TEST_F(TransectMutationTest, RenameRejectsNullOrEmpty) { + EXPECT_EQ(swmm_transect_rename(engine, t1_idx, nullptr), SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_transect_rename(engine, t1_idx, ""), SWMM_ERR_BADPARAM); +} + +// --------------------------------------------------------------------------- +// Remove — out-of-range no-op, order preservation, full-field erasure. +// --------------------------------------------------------------------------- + +TEST_F(TransectMutationTest, RemoveOutOfRangeIsNoop) { + EXPECT_EQ(swmm_transect_count(engine), 1); + EXPECT_EQ(swmm_transect_remove(engine, -1), SWMM_OK); + EXPECT_EQ(swmm_transect_remove(engine, 99), SWMM_OK); + EXPECT_EQ(swmm_transect_count(engine), 1); +} + +TEST_F(TransectMutationTest, RemoveDropsTransectAndShiftsIndices) { + ASSERT_EQ(swmm_transect_add(engine, "T2"), SWMM_OK); + ASSERT_EQ(swmm_transect_add(engine, "T3"), SWMM_OK); + EXPECT_EQ(swmm_transect_count(engine), 3); + + // Configure T2 so we can confirm it's the one that survives at idx 1 + // after dropping T1. + const int t2 = swmm_transect_index(engine, "T2"); + ASSERT_EQ(swmm_transect_set_roughness(engine, t2, 0.04, 0.05, 0.03), SWMM_OK); + ASSERT_EQ(swmm_transect_set_bank_stations(engine, t2, 7.5, 22.5), SWMM_OK); + + // Drop T1. + EXPECT_EQ(swmm_transect_remove(engine, t1_idx), SWMM_OK); + EXPECT_EQ(swmm_transect_count(engine), 2); + + // Order preserved: T2 now at idx 0, T3 at idx 1. + EXPECT_STREQ(swmm_transect_id(engine, 0), "T2"); + EXPECT_STREQ(swmm_transect_id(engine, 1), "T3"); + + // Verify the new idx-0 transect still carries T2's data (no shift mix-up). + double nL = 0, nR = 0, nC = 0; + EXPECT_EQ(swmm_transect_get_roughness(engine, 0, &nL, &nR, &nC), SWMM_OK); + EXPECT_DOUBLE_EQ(nL, 0.04); + EXPECT_DOUBLE_EQ(nR, 0.05); + EXPECT_DOUBLE_EQ(nC, 0.03); + + double bL = 0, bR = 0; + EXPECT_EQ(swmm_transect_get_bank_stations(engine, 0, &bL, &bR), SWMM_OK); + EXPECT_DOUBLE_EQ(bL, 7.5); + EXPECT_DOUBLE_EQ(bR, 22.5); +} + +// --------------------------------------------------------------------------- +// Handle / index guards — uniform behaviour across the new surface. +// --------------------------------------------------------------------------- + +TEST_F(TransectMutationTest, BadHandleReturnsBadHandle) { + double v = 0; + EXPECT_EQ(swmm_transect_get_roughness(nullptr, 0, &v, &v, &v), SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_transect_set_bank_stations(nullptr, 0, 1, 2), SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_transect_get_bank_stations(nullptr, 0, &v, &v), SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_transect_set_encroachment_stations(nullptr, 0, 1, 2), SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_transect_get_encroachment_stations(nullptr, 0, &v, &v), SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_transect_set_modifiers(nullptr, 0, 1, 1, 1), SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_transect_get_modifiers(nullptr, 0, &v, &v, &v), SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_transect_set_comments(nullptr, 0, "x"), SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_transect_clear_stations(nullptr, 0), SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_transect_rename(nullptr, 0, "x"), SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_transect_remove(nullptr, 0), SWMM_ERR_BADHANDLE); +} + +TEST_F(TransectMutationTest, OutOfRangeIndexReturnsBadIndex) { + double v = 0; + EXPECT_EQ(swmm_transect_get_roughness(engine, 99, &v, &v, &v), SWMM_ERR_BADINDEX); + EXPECT_EQ(swmm_transect_set_bank_stations(engine, 99, 1, 2), SWMM_ERR_BADINDEX); + EXPECT_EQ(swmm_transect_set_encroachment_stations(engine, 99, 1, 2), SWMM_ERR_BADINDEX); + EXPECT_EQ(swmm_transect_set_modifiers(engine, 99, 1, 1, 1), SWMM_ERR_BADINDEX); + EXPECT_EQ(swmm_transect_set_comments(engine, 99, "x"), SWMM_ERR_BADINDEX); + EXPECT_EQ(swmm_transect_clear_stations(engine, 99), SWMM_ERR_BADINDEX); + EXPECT_EQ(swmm_transect_rename(engine, 99, "X"), SWMM_ERR_BADINDEX); + // remove() is intentionally a no-op for out-of-range (mirrors patterns). +} From 05ad62ac91e1b5726b8ec3464edf716601c17c90 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Mon, 25 May 2026 22:29:34 -0400 Subject: [PATCH 18/26] Expanding API to support GUI development Signed-off-by: cbuahin --- include/openswmm/engine/openswmm_engine.h | 81 ++++ include/openswmm/engine/openswmm_links.h | 224 ++++++++++ include/openswmm/engine/openswmm_output.h | 72 ++++ include/openswmm/engine/openswmm_statistics.h | 39 ++ .../openswmm/plugin_sdk/PluginDiscovery.hpp | 22 + python/docs/guide/solver.rst | 63 +++ python/docs/guide/statistics.rst | 16 + python/openswmm/engine/_common.pxd | 45 ++ python/openswmm/engine/_links.pyi | 102 +++++ python/openswmm/engine/_links.pyx | 199 +++++++++ python/openswmm/engine/_output_reader.pyi | 53 +++ python/openswmm/engine/_output_reader.pyx | 100 +++++ python/openswmm/engine/_solver.pyi | 61 +++ python/openswmm/engine/_solver.pyx | 110 +++++ python/openswmm/engine/_statistics.pyi | 52 +++ python/openswmm/engine/_statistics.pyx | 92 ++++ python/tests/engine/test_output_reader.py | 90 ++++ python/tests/engine/test_runoff_interface.py | 170 ++++++++ python/tests/test_api_coverage.py | 304 ++++++++++++++ src/engine/CMakeLists.txt | 15 +- src/engine/core/InpWriter.cpp | 12 +- src/engine/core/SWMMEngine.cpp | 73 ++++ src/engine/core/SWMMEngine.hpp | 56 +++ src/engine/core/openswmm_engine_impl.cpp | 39 ++ src/engine/core/openswmm_links_impl.cpp | 244 +++++++++++ src/engine/core/openswmm_statistics_impl.cpp | 48 +++ src/engine/input/geopackage/CMakeLists.txt | 18 +- .../input/geopackage/GeoPackagePluginInfo.cpp | 32 +- .../input/geopackage/GeoPackagePluginInfo.hpp | 28 +- src/engine/input/handlers/LinksHandler.cpp | 37 +- src/engine/output/OutputReader.cpp | 105 +++++ src/engine/output/OutputReader.hpp | 10 + src/engine/output/openswmm_output_impl.cpp | 36 ++ src/engine/plugins/PluginDiscovery.cpp | 7 + src/engine/plugins/PluginFactory.cpp | 25 ++ src/engine/plugins/PluginFactory.hpp | 8 + tests/unit/engine/CMakeLists.txt | 9 + .../engine/test_control_rule_validate_api.cpp | 237 +++++++++++ .../unit/engine/test_link_flow_roundtrip.cpp | 394 ++++++++++++++++++ tests/unit/engine/test_output_node_stats.cpp | 182 ++++++++ .../engine/test_pluginfactory_builtins.cpp | 142 +++++++ .../engine/test_runoff_interface_capi.cpp | 198 +++++++++ .../engine/test_statistics_bulk_phase3.cpp | 87 ++++ .../unit/engine/test_transect_inp_parser.cpp | 217 ++++++++++ 44 files changed, 4128 insertions(+), 26 deletions(-) create mode 100644 python/tests/engine/test_runoff_interface.py create mode 100644 python/tests/test_api_coverage.py create mode 100644 tests/unit/engine/test_control_rule_validate_api.cpp create mode 100644 tests/unit/engine/test_link_flow_roundtrip.cpp create mode 100644 tests/unit/engine/test_output_node_stats.cpp create mode 100644 tests/unit/engine/test_pluginfactory_builtins.cpp create mode 100644 tests/unit/engine/test_runoff_interface_capi.cpp create mode 100644 tests/unit/engine/test_transect_inp_parser.cpp diff --git a/include/openswmm/engine/openswmm_engine.h b/include/openswmm/engine/openswmm_engine.h index e533ac289..c0de9e603 100644 --- a/include/openswmm/engine/openswmm_engine.h +++ b/include/openswmm/engine/openswmm_engine.h @@ -344,6 +344,87 @@ SWMM_ENGINE_API int swmm_events_clear(SWMM_Engine engine); SWMM_ENGINE_API int swmm_get_steady_state_skip(SWMM_Engine engine, int* enabled); SWMM_ENGINE_API int swmm_set_steady_state_skip(SWMM_Engine engine, int enabled); +/* ========================================================================= + * Phase 1b: Runoff interface file (legacy "Frunoff"). + * + * Persists per-subcatchment runoff snapshots to a binary file so the + * runoff phase of a long simulation can be cached and replayed in a + * downstream routing-only run. Mirrors the legacy SWMM-5 file format + * (see src/engine/hydrology/RunoffInterface.hpp). + * + * SAVE mode is fully integrated: open the file in SAVE before + * @c swmm_engine_start, run the simulation as normal, then close. The + * engine emits one record per runoff substep automatically. + * + * USE mode currently exposes the file but does NOT yet auto-skip the + * engine's runoff computation — callers must invoke + * @ref swmm_runoff_iface_read_step between simulation steps and + * understand that the engine will overwrite the loaded state if runoff + * still runs. Full USE-mode auto-skip is tracked as a follow-up. + * + * @since 6.0.0 + * ========================================================================= */ + +/** + * @brief Open the runoff interface file for writing (SAVE mode). + * + * @param engine Engine handle (any state). + * @param path Output file path. Existing file is truncated. + * @returns @c SWMM_OK on success. Non-zero error codes include + * @c SWMM_ERR_BADHANDLE (invalid engine), @c SWMM_ERR_BADPARAM + * (null path), and a non-zero file-I/O error code when the + * file could not be opened or a runoff file is already open. + */ +SWMM_ENGINE_API int swmm_runoff_iface_open_write(SWMM_Engine engine, const char* path); + +/** + * @brief Open the runoff interface file for reading (USE mode). + * + * @param engine Engine handle. + * @param path Path to an existing runoff interface file. + * @returns @c SWMM_OK on success; non-zero on file-open failure or + * header mismatch (subcatchment count, pollutant count, or + * flow units differ from the current model). + */ +SWMM_ENGINE_API int swmm_runoff_iface_open_read(SWMM_Engine engine, const char* path); + +/** + * @brief Manually emit one runoff substep record to the open SAVE file. + * + * @details Normally the engine emits records automatically inside + * @c stepRunoff(). This entry point is exposed so plugin + * authors and tests can force a snapshot at well-defined + * times; it is a no-op when no file is open or the file is + * in USE mode. + * + * @param engine Engine handle. + * @param dt Substep duration (seconds) to record in the file for + * this snapshot. + */ +SWMM_ENGINE_API int swmm_runoff_iface_save_step(SWMM_Engine engine, double dt); + +/** + * @brief Read one runoff substep record from the open USE file into + * the current subcatchment state. + * + * @param engine Engine handle. + * @param[out] has_data Set to @c 1 when a record was successfully + * read, @c 0 on EOF. May be NULL. + * @returns @c SWMM_OK in either case (use @p has_data to tell EOF + * apart from a successful read). Returns @c SWMM_ERR_BADHANDLE + * on null engine; @c SWMM_ERR_BADPARAM if no USE-mode file is + * open. + */ +SWMM_ENGINE_API int swmm_runoff_iface_read_step(SWMM_Engine engine, int* has_data); + +/** + * @brief Close the runoff interface file. + * + * Safe to call multiple times; safe to call when no file was opened. + * Also called automatically as part of @c swmm_engine_close. + */ +SWMM_ENGINE_API int swmm_runoff_iface_close(SWMM_Engine engine); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/include/openswmm/engine/openswmm_links.h b/include/openswmm/engine/openswmm_links.h index 65e264707..9fa7e8b4f 100644 --- a/include/openswmm/engine/openswmm_links.h +++ b/include/openswmm/engine/openswmm_links.h @@ -226,6 +226,230 @@ SWMM_ENGINE_API int swmm_link_set_initial_flow(SWMM_Engine engine, int idx, doub */ SWMM_ENGINE_API int swmm_link_set_max_flow(SWMM_Engine engine, int idx, double flow); +/** + * @brief Get the initial flow in a link at simulation start. + * + * @details Symmetric getter for @ref swmm_link_set_initial_flow. Reads the + * same SoA slot the setter writes; safe to call in any + * post-construction engine state. + * + * @param engine Engine handle. + * @param idx Zero-based link index. + * @param[out] flow Receives the initial flow in project flow units. + * @returns SWMM_OK on success, or an error code. + * @since 6.0.0 (engine gap BN-LINK-01a, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_get_initial_flow(SWMM_Engine engine, int idx, double* flow); + +/** + * @brief Get the maximum allowable flow in a link. + * + * @details Symmetric getter for @ref swmm_link_set_max_flow. Returns 0.0 + * when no limit is configured (mirrors the setter's contract). + * + * @param engine Engine handle. + * @param idx Zero-based link index. + * @param[out] flow Receives the maximum flow in project flow units. + * @returns SWMM_OK on success, or an error code. + * @since 6.0.0 (engine gap BN-LINK-01b, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_get_max_flow(SWMM_Engine engine, int idx, double* flow); + +/** + * @brief Orifice flow-attack classification. + * + * @details Used with @ref swmm_link_set_orifice_type and + * @ref swmm_link_get_orifice_type. Order matches the legacy + * SWMM-GUI combo (`SWMM-GUI/Epaswmm5/objprops.txt:862`). + * @since 6.0.0 (engine gap BN-LINK-02, added 2026-05-25) + */ +typedef enum SWMM_OrificeType { + SWMM_ORIFICE_SIDE = 0, /**< Orifice opens on the side of the upstream node. */ + SWMM_ORIFICE_BOTTOM = 1, /**< Orifice opens through the bottom of the upstream node. */ +} SWMM_OrificeType; + +/** + * @brief Set the orifice flow-attack classification (SIDE / BOTTOM). + * + * @details Only valid on links of type @ref SWMM_LINK_ORIFICE; returns + * @c SWMM_ERR_BADPARAM otherwise. + * + * @param engine Engine handle. + * @param idx Zero-based link index. + * @param type Orifice type (see @ref SWMM_OrificeType). + * @returns @c SWMM_OK on success, @c SWMM_ERR_BADPARAM if @p idx names a + * non-orifice link or @p type is out of range. + * @since 6.0.0 (engine gap BN-LINK-02, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_set_orifice_type(SWMM_Engine engine, int idx, int type); + +/** + * @brief Get the orifice flow-attack classification. + * + * @param engine Engine handle. + * @param idx Zero-based link index. + * @param[out] type Receives the orifice type (see @ref SWMM_OrificeType). + * @returns @c SWMM_OK on success, @c SWMM_ERR_BADPARAM if @p idx names a + * non-orifice link. + * @since 6.0.0 (engine gap BN-LINK-02, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_get_orifice_type(SWMM_Engine engine, int idx, int* type); + +/** + * @brief Weir-flow classification. + * + * @details Used with @ref swmm_link_set_weir_type and + * @ref swmm_link_get_weir_type. Numeric order matches the + * legacy WeirType enum in `legacy/engine/enums.h:925` and the + * legacy SWMM-GUI combo (`SWMM-GUI/Epaswmm5/objprops.txt:160`). + * + * The companion "Shape" attribute in the legacy GUI is derived + * from the weir type (see `objprops.txt:162` for the mapping) + * and need not be stored separately; clients that want the + * shape should consult @ref swmm_link_get_xsect. + * + * @since 6.0.0 (engine gap BN-LINK-03, added 2026-05-25) + */ +typedef enum SWMM_WeirType { + SWMM_WEIR_TRANSVERSE = 0, /**< Sharp-crested transverse weir. */ + SWMM_WEIR_SIDEFLOW = 1, /**< Side-flow weir (USBR formula). */ + SWMM_WEIR_VNOTCH = 2, /**< Triangular / V-notch weir. */ + SWMM_WEIR_TRAPEZOIDAL = 3, /**< Trapezoidal weir. */ + SWMM_WEIR_ROADWAY = 4, /**< FHWA HDS-5 roadway weir. */ +} SWMM_WeirType; + +/** + * @brief Set the weir flow classification. + * + * @details Only valid on links of type @ref SWMM_LINK_WEIR; returns + * @c SWMM_ERR_BADPARAM otherwise. + * + * @param engine Engine handle. + * @param idx Zero-based link index. + * @param type Weir type (see @ref SWMM_WeirType). + * @returns @c SWMM_OK on success, @c SWMM_ERR_BADPARAM if @p idx names a + * non-weir link or @p type is out of range. + * @since 6.0.0 (engine gap BN-LINK-03, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_set_weir_type(SWMM_Engine engine, int idx, int type); + +/** + * @brief Get the weir flow classification. + * + * @param engine Engine handle. + * @param idx Zero-based link index. + * @param[out] type Receives the weir type (see @ref SWMM_WeirType). + * @returns @c SWMM_OK on success, @c SWMM_ERR_BADPARAM if @p idx names a + * non-weir link. + * @since 6.0.0 (engine gap BN-LINK-03, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_get_weir_type(SWMM_Engine engine, int idx, int* type); + +/** + * @brief Outlet rating-curve classification. + * + * @details Used with @ref swmm_link_set_outlet_rating_type and + * @ref swmm_link_get_outlet_rating_type. Numeric encoding + * matches the legacy `LinksHandler::handle_outlets` + * convention (`src/engine/input/handlers/LinksHandler.cpp:214-221`) + * and the legacy SWMM-GUI combo order at + * `SWMM-GUI/Epaswmm5/objprops.txt:913`. + * + * FUNCTIONAL types use the @c cd (coefficient) and the + * outlet exponent (see @ref swmm_link_set_outlet_expon) + * to define the rating curve; TABULAR types use the + * curve assigned via @ref swmm_link_set_pump_curve (the + * engine shares the curve-index slot between pumps and + * tabular outlets). + * + * @since 6.0.0 (engine gap BN-LINK-04, added 2026-05-25) + */ +typedef enum SWMM_OutletRatingType { + SWMM_OUTLET_FUNCTIONAL_HEAD = 0, /**< Q = Cd · H^expon (head above invert). */ + SWMM_OUTLET_FUNCTIONAL_DEPTH = 1, /**< Q = Cd · y^expon (depth at upstream node). */ + SWMM_OUTLET_TABULAR_HEAD = 2, /**< Q from rating curve indexed by head. */ + SWMM_OUTLET_TABULAR_DEPTH = 3, /**< Q from rating curve indexed by depth. */ +} SWMM_OutletRatingType; + +/** + * @brief Set the outlet rating-curve classification. + * @returns @c SWMM_OK on success, @c SWMM_ERR_BADPARAM if @p idx names a + * non-outlet link or @p type is out of range. + * @since 6.0.0 (engine gap BN-LINK-04, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_set_outlet_rating_type(SWMM_Engine engine, int idx, int type); + +/** + * @brief Get the outlet rating-curve classification. + * @returns @c SWMM_OK on success, @c SWMM_ERR_BADPARAM if @p idx names a + * non-outlet link. + * @since 6.0.0 (engine gap BN-LINK-04, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_get_outlet_rating_type(SWMM_Engine engine, int idx, int* type); + +/** + * @brief Set the outlet functional-form exponent. + * + * @details Only meaningful for FUNCTIONAL_* rating types — the engine + * ignores the stored value when the type is TABULAR_*. The + * coefficient term (Cd) is accessed via + * @ref swmm_link_set_discharge_coeff / @ref swmm_link_get_discharge_coeff. + * + * @returns @c SWMM_OK on success, @c SWMM_ERR_BADPARAM if @p idx names a + * non-outlet link. + * @since 6.0.0 (engine gap BN-LINK-04, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_set_outlet_expon(SWMM_Engine engine, int idx, double expon); + +/** + * @brief Get the outlet functional-form exponent. + * @returns @c SWMM_OK on success, @c SWMM_ERR_BADPARAM if @p idx names a + * non-outlet link. + * @since 6.0.0 (engine gap BN-LINK-04, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_get_outlet_expon(SWMM_Engine engine, int idx, double* expon); + +/** + * @brief Set the pump startup depth (depth at upstream node when the + * pump turns on, project length units). + * @returns @c SWMM_OK on success, @c SWMM_ERR_BADPARAM if @p idx names a + * non-pump link. + * @since 6.0.0 (engine gap BN-LINK-05, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_set_pump_startup_depth(SWMM_Engine engine, int idx, double depth); + +/** @brief Get the pump startup depth. @since 6.0.0 (BN-LINK-05) */ +SWMM_ENGINE_API int swmm_link_get_pump_startup_depth(SWMM_Engine engine, int idx, double* depth); + +/** + * @brief Set the pump shutoff depth (depth at upstream node when the + * pump turns off, project length units). + * @returns @c SWMM_OK on success, @c SWMM_ERR_BADPARAM if @p idx names a + * non-pump link. + * @since 6.0.0 (engine gap BN-LINK-05, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_set_pump_shutoff_depth(SWMM_Engine engine, int idx, double depth); + +/** @brief Get the pump shutoff depth. @since 6.0.0 (BN-LINK-05) */ +SWMM_ENGINE_API int swmm_link_get_pump_shutoff_depth(SWMM_Engine engine, int idx, double* depth); + +/** + * @brief Set the orifice open/close rate (fraction per second). + * + * @details 0 means instantaneous open/close. The legacy SWMM-GUI surfaces + * this field as "Time to Open/Close" measured in hours; clients + * that want the hours-based UX should compute + * @c rate = 1.0 / (3600 * hours) before calling this setter. + * + * @returns @c SWMM_OK on success, @c SWMM_ERR_BADPARAM if @p idx names a + * non-orifice link. + * @since 6.0.0 (engine gap BN-LINK-06, added 2026-05-25) + */ +SWMM_ENGINE_API int swmm_link_set_orifice_open_close_rate(SWMM_Engine engine, int idx, double rate); + +/** @brief Get the orifice open/close rate (fraction per second). @since 6.0.0 (BN-LINK-06) */ +SWMM_ENGINE_API int swmm_link_get_orifice_open_close_rate(SWMM_Engine engine, int idx, double* rate); + /* ========================================================================= * Cross-section (BUILDING or OPENED) * ========================================================================= */ diff --git a/include/openswmm/engine/openswmm_output.h b/include/openswmm/engine/openswmm_output.h index bba34603a..1c57f61d5 100644 --- a/include/openswmm/engine/openswmm_output.h +++ b/include/openswmm/engine/openswmm_output.h @@ -453,6 +453,78 @@ SWMM_ENGINE_API int swmm_output_get_period_time(SWMM_Output handle, int period, double* time); +/* ========================================================================= + * Per-node summary statistics — Slice QA-01 + * + * The SWMM 5.x binary output format does not include a stats block; these + * functions reconstruct the four flooding statistics at read time by + * walking the per-period node results stored in the file. The semantics + * mirror the engine-side `swmm_node_get_stat_*` accessors (which read + * from SimulationContext.nodes.stat_*), except sourced from a SWMM_Output + * handle so per-output comparisons work across multiple loaded .out + * files. Implementations are O(n_periods) per call. + * + * Caveat: aggregation runs over REPORT-step samples, not the engine's + * internal routing-step samples. For runs where the report step is much + * coarser than the routing step (e.g. report every hour vs. route every + * 5 s), `time_flooded` and `vol_flooded` will under-count brief + * sub-report-step flooding events. The engine-side getters + * (`swmm_node_get_stat_*`) retain the legacy routing-step precision — + * use those when a fresh in-process run is the source. + * ========================================================================= */ + +/** + * @brief Maximum node depth across all reporting periods. + * + * @details Computes `max(SWMM_OUT_NODE_DEPTH)` over the open file's + * period range. Result is in the model's length units (ft / m). + * + * @param handle Output reader handle. + * @param node_idx Zero-based node index (0 .. node_count - 1). + * @param value [out] Maximum depth on success; unchanged on error. + * @returns 0 on success, -1 on error (bad handle, index out of range, or + * I/O failure during aggregation). + */ +SWMM_ENGINE_API int swmm_output_get_node_stat_max_depth(SWMM_Output handle, + int node_idx, + double* value); + +/** + * @brief Maximum node overflow rate across all reporting periods. + * + * @details Computes `max(SWMM_OUT_NODE_OVERFLOW)` over the open file's + * period range. Result is in the file's flow units + * (see swmm_output_get_flow_units). + */ +SWMM_ENGINE_API int swmm_output_get_node_stat_max_overflow(SWMM_Output handle, + int node_idx, + double* value); + +/** + * @brief Total flood volume at the node across the simulation. + * + * @details Aggregates `sum(overflow_i * report_step)` over the periods + * where overflow > 0. Result is in cubic feet (US) or cubic + * metres (SI) — matching `swmm_node_get_stat_vol_flooded` units. + * Returns 0 when the file has no positive-overflow periods. + */ +SWMM_ENGINE_API int swmm_output_get_node_stat_vol_flooded(SWMM_Output handle, + int node_idx, + double* value); + +/** + * @brief Total time the node was flooded across the simulation. + * + * @details Counts the report periods where overflow > 0 and multiplies + * by the file's report-step seconds. Result is in seconds — + * matching `swmm_node_get_stat_time_flooded` units. Divide by + * 3600 for hours (the convention SWMM's statsrpt uses for + * display). + */ +SWMM_ENGINE_API int swmm_output_get_node_stat_time_flooded(SWMM_Output handle, + int node_idx, + double* value); + /* ========================================================================= * Error reporting * ========================================================================= */ diff --git a/include/openswmm/engine/openswmm_statistics.h b/include/openswmm/engine/openswmm_statistics.h index a859aeb49..821ae2045 100644 --- a/include/openswmm/engine/openswmm_statistics.h +++ b/include/openswmm/engine/openswmm_statistics.h @@ -119,6 +119,45 @@ SWMM_ENGINE_API int swmm_stat_node_time_flooded_bulk(SWMM_Engine engine, double* */ SWMM_ENGINE_API int swmm_stat_subcatch_max_runoff_bulk(SWMM_Engine engine, double* buf, int count); +/* ------------------------------------------------------------------------- + * Phase 4e link-stat bulks — complete the per-link statistics surface so + * the MCP server's capacity_summary tool can collapse to a single-pass + * shape (same as flooding_summary). Each is a simple SoA memcpy from + * the matching scalar accessor's column. + * ------------------------------------------------------------------------- */ + +/** + * @brief Get peak velocity for all links into a caller-supplied buffer. + * @details Bulk variant of @ref swmm_stat_link_max_velocity. SoA copy of + * @c ctx.links.stat_max_veloc. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_stat_link_max_velocity_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get peak depth-to-full-depth ratio for all links into a buffer. + * @details Bulk variant of @ref swmm_stat_link_max_filling. SoA copy of + * @c ctx.links.stat_max_filling. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_stat_link_max_filling_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get cumulative flow volume per link into a caller-supplied buffer. + * @details Bulk variant of @ref swmm_stat_link_vol_flow. SoA copy of + * @c ctx.links.stat_vol_flow. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_stat_link_vol_flow_bulk(SWMM_Engine engine, double* buf, int count); + +/** + * @brief Get cumulative surcharge time per link into a caller-supplied buffer. + * @details Bulk variant of @ref swmm_stat_link_surcharge_time. SoA copy of + * @c ctx.links.stat_time_surcharged. + * @since 6.0.0 + */ +SWMM_ENGINE_API int swmm_stat_link_surcharge_time_bulk(SWMM_Engine engine, double* buf, int count); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/include/openswmm/plugin_sdk/PluginDiscovery.hpp b/include/openswmm/plugin_sdk/PluginDiscovery.hpp index 67092717d..e6f78d61f 100644 --- a/include/openswmm/plugin_sdk/PluginDiscovery.hpp +++ b/include/openswmm/plugin_sdk/PluginDiscovery.hpp @@ -42,6 +42,13 @@ struct DiscoveredFilter { std::string plugin_version; ///< IPluginComponentInfo::version() of the source plugin std::string plugin_caption; ///< IPluginComponentInfo::caption() — human-readable FileFilter filter; ///< The advertised filter + + /// Slice RC.3 — true when the source plugin is an engine built-in + /// (statically linked into the engine, registered via + /// PluginFactory::register_builtin_infos). False for plugins + /// discovered through the on-disk shared-library scan. Propagated + /// up to DiscoveredPlugin's matching field by discover_plugins_by_id. + bool is_builtin = false; }; /** @@ -78,6 +85,21 @@ struct DiscoveredPlugin { std::string plugin_caption; ///< IPluginComponentInfo::caption() std::vector roles; ///< Distinct roles advertised across all filters std::vector filters; ///< Every filter the plugin advertises + + /** + * @brief True when the plugin was registered as an engine built-in + * (via PluginFactory::register_builtin_infos) rather than + * discovered through the on-disk shared-library scan. + * + * @details Slice RC.3 (APPROVED 2026-05-25). Hosts use this to gate + * UI affordances that don't make sense for built-ins — e.g. + * the Simulation Options Plugins-tab Remove button cannot + * dlclose a statically-linked plugin, so it's greyed out + * when `is_builtin == true`. The default (`false`) keeps + * older callers' behavior unchanged: any plugin discovered + * via the directory scan is treated as a non-builtin. + */ + bool is_builtin = false; }; /** diff --git a/python/docs/guide/solver.rst b/python/docs/guide/solver.rst index 79ed52172..a8b54f9c7 100644 --- a/python/docs/guide/solver.rst +++ b/python/docs/guide/solver.rst @@ -409,6 +409,69 @@ For the full list see :class:`~openswmm.engine.ErrorCode`. ---- +Runoff interface file (Phase 1b) +================================ + +Persists per-subcatchment runoff snapshots to a binary file matching +the legacy SWMM-5 ``Frunoff`` format. Useful when a slow-running +runoff phase needs to be cached and replayed against a downstream +routing-only run (e.g. design-storm sensitivity analyses). + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Method + - Description + * - :meth:`open_runoff_iface_write(path)` + - Open the file in SAVE mode. The engine then auto-emits one + record per runoff substep until :meth:`close_runoff_iface` + is called. + * - :meth:`open_runoff_iface_read(path)` + - Open the file in USE mode. Currently exposes the file but + does **not** yet skip the engine's runoff computation — + see the note below. + * - :meth:`save_runoff_step(dt)` + - Manually force one snapshot. Normally unnecessary because the + engine auto-saves; provided for plugin authors and tests. + * - :meth:`read_runoff_step()` + - Read one record from the open USE file into the current + subcatchment runoff/quality vectors. Returns ``False`` on EOF. + * - :meth:`close_runoff_iface()` + - Close the file (idempotent). Called automatically when the + solver is closed. + +.. note:: + + USE-mode auto-skip — making the engine bypass its own runoff + computation when a USE file is open — is a follow-up to Phase 1b. + Today's USE mode is an advanced manual feature: callers must invoke + :meth:`read_runoff_step` between :meth:`Solver.step` calls *and* + understand that the engine will overwrite the loaded state when it + runs its own runoff phase. + +End-to-end SAVE example: + +.. code-block:: python + + from openswmm.engine import EngineState, Solver + + with Solver("design_storm.inp", "design_storm.rpt", "design_storm.out") as s: + s.open() + s.initialize() + s.start() + s.open_runoff_iface_write("design_storm.rfi") + while s.state == EngineState.RUNNING: + if s.step() != 0: + break + s.end() + s.close_runoff_iface() + # design_storm.rfi now contains one record per runoff substep and + # can be reopened by a downstream routing-only run via + # ``s.open_runoff_iface_read(...)``. + +---- + See also ======== diff --git a/python/docs/guide/statistics.rst b/python/docs/guide/statistics.rst index 29943be43..e50675ed2 100644 --- a/python/docs/guide/statistics.rst +++ b/python/docs/guide/statistics.rst @@ -123,6 +123,14 @@ C call. - All-node cumulative time-flooded *(added 6.0.0)*. * - :meth:`subcatch_max_runoff_bulk()` - All-subcatchment peak runoff *(added 6.0.0)*. + * - :meth:`link_max_velocity_bulk()` + - All-link peak velocity *(added 6.0.0)*. + * - :meth:`link_max_filling_bulk()` + - All-link peak depth/full-depth ratio *(added 6.0.0)*. + * - :meth:`link_vol_flow_bulk()` + - All-link cumulative flow volume *(added 6.0.0)*. + * - :meth:`link_surcharge_time_bulk()` + - All-link cumulative surcharge time *(added 6.0.0)*. Whole-network flooding summary in 4 C calls (replaces a ``4 * n_nodes`` Python loop): @@ -271,6 +279,14 @@ released during the underlying C call. - ``(n_nodes,)`` *(added 6.0.0)* * - :meth:`subcatch_max_runoff_bulk` - ``(n_subcatch,)`` *(added 6.0.0)* + * - :meth:`link_max_velocity_bulk` + - ``(n_links,)`` *(added 6.0.0)* + * - :meth:`link_max_filling_bulk` + - ``(n_links,)`` *(added 6.0.0)* + * - :meth:`link_vol_flow_bulk` + - ``(n_links,)`` *(added 6.0.0)* + * - :meth:`link_surcharge_time_bulk` + - ``(n_links,)`` *(added 6.0.0)* Memory-aliasing rule: the three pre-6.0 bulk methods share scratch buffers with engine state — ``.copy()`` if you need to retain them diff --git a/python/openswmm/engine/_common.pxd b/python/openswmm/engine/_common.pxd index 6b61ab2df..5b1e97b19 100644 --- a/python/openswmm/engine/_common.pxd +++ b/python/openswmm/engine/_common.pxd @@ -54,6 +54,14 @@ cdef extern from "openswmm_engine.h": cdef int swmm_get_event_count(SWMM_Engine e, int* count) cdef int swmm_get_steady_state_skip(SWMM_Engine e, int* enabled) cdef int swmm_set_steady_state_skip(SWMM_Engine e, int enabled) + # Phase 1b: runoff interface file (legacy "Frunoff"). I/O-bound, + # so each is declared nogil to allow the GIL to be released around + # the C call (the wrappers in _solver.pyx wrap them in `with nogil:`). + cdef int swmm_runoff_iface_open_write(SWMM_Engine e, const char* path) nogil + cdef int swmm_runoff_iface_open_read(SWMM_Engine e, const char* path) nogil + cdef int swmm_runoff_iface_save_step(SWMM_Engine e, double dt) nogil + cdef int swmm_runoff_iface_read_step(SWMM_Engine e, int* has_data) nogil + cdef int swmm_runoff_iface_close(SWMM_Engine e) nogil # --- [EVENTS] section editor (Slice CW, 2026-05-21) --- cdef int swmm_events_count(SWMM_Engine e, int* count) @@ -229,6 +237,28 @@ cdef extern from "openswmm_links.h": cdef int swmm_link_set_offset_dn(SWMM_Engine e, int idx, double offset) cdef int swmm_link_set_initial_flow(SWMM_Engine e, int idx, double flow) cdef int swmm_link_set_max_flow(SWMM_Engine e, int idx, double flow) + # Engine gaps BN-LINK-01a / -01b — symmetric getters added 2026-05-25. + cdef int swmm_link_get_initial_flow(SWMM_Engine e, int idx, double* flow) + cdef int swmm_link_get_max_flow(SWMM_Engine e, int idx, double* flow) + # Engine gap BN-LINK-02 — orifice TYPE (SIDE=0 / BOTTOM=1) — added 2026-05-25. + cdef int swmm_link_get_orifice_type(SWMM_Engine e, int idx, int* type_) + cdef int swmm_link_set_orifice_type(SWMM_Engine e, int idx, int type_) + # Engine gap BN-LINK-03 — weir TYPE (5 values) — added 2026-05-25. + cdef int swmm_link_get_weir_type(SWMM_Engine e, int idx, int* type_) + cdef int swmm_link_set_weir_type(SWMM_Engine e, int idx, int type_) + # Engine gap BN-LINK-04 — outlet rating type (4 values) + exponent — 2026-05-25. + cdef int swmm_link_get_outlet_rating_type(SWMM_Engine e, int idx, int* type_) + cdef int swmm_link_set_outlet_rating_type(SWMM_Engine e, int idx, int type_) + cdef int swmm_link_get_outlet_expon(SWMM_Engine e, int idx, double* expon) + cdef int swmm_link_set_outlet_expon(SWMM_Engine e, int idx, double expon) + # Engine gap BN-LINK-05 — pump startup / shutoff depth — added 2026-05-25. + cdef int swmm_link_get_pump_startup_depth(SWMM_Engine e, int idx, double* depth) + cdef int swmm_link_set_pump_startup_depth(SWMM_Engine e, int idx, double depth) + cdef int swmm_link_get_pump_shutoff_depth(SWMM_Engine e, int idx, double* depth) + cdef int swmm_link_set_pump_shutoff_depth(SWMM_Engine e, int idx, double depth) + # Engine gap BN-LINK-06 — orifice open/close rate (1/s) — added 2026-05-25. + cdef int swmm_link_get_orifice_open_close_rate(SWMM_Engine e, int idx, double* rate) + cdef int swmm_link_set_orifice_open_close_rate(SWMM_Engine e, int idx, double rate) # Cross-section cdef int swmm_link_set_xsect(SWMM_Engine e, int idx, int shape, double g1, double g2, double g3, double g4) @@ -653,6 +683,11 @@ cdef extern from "openswmm_statistics.h": cdef int swmm_stat_node_vol_flooded_bulk(SWMM_Engine e, double* buf, int count) nogil cdef int swmm_stat_node_time_flooded_bulk(SWMM_Engine e, double* buf, int count) nogil cdef int swmm_stat_subcatch_max_runoff_bulk(SWMM_Engine e, double* buf, int count) nogil + # Phase 4e link-stat bulks — completes the per-link statistics surface. + cdef int swmm_stat_link_max_velocity_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_stat_link_max_filling_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_stat_link_vol_flow_bulk(SWMM_Engine e, double* buf, int count) nogil + cdef int swmm_stat_link_surcharge_time_bulk(SWMM_Engine e, double* buf, int count) nogil cdef extern from "openswmm_spatial.h": # CRS @@ -726,6 +761,16 @@ cdef extern from "openswmm_output.h": cdef int swmm_output_get_period_time(SWMM_Output handle, int period, double* time) # Error cdef int swmm_output_get_error_code(SWMM_Output handle) + # Post-run node statistics aggregated from the .out file + # (added 2026-05 to the engine; bound here in the Phase 5 drift sweep). + cdef int swmm_output_get_node_stat_max_depth(SWMM_Output handle, + int node_idx, double* value) nogil + cdef int swmm_output_get_node_stat_max_overflow(SWMM_Output handle, + int node_idx, double* value) nogil + cdef int swmm_output_get_node_stat_vol_flooded(SWMM_Output handle, + int node_idx, double* value) nogil + cdef int swmm_output_get_node_stat_time_flooded(SWMM_Output handle, + int node_idx, double* value) nogil cdef extern from "openswmm_edit.h": diff --git a/python/openswmm/engine/_links.pyi b/python/openswmm/engine/_links.pyi index b7a88be98..70d876b6e 100644 --- a/python/openswmm/engine/_links.pyi +++ b/python/openswmm/engine/_links.pyi @@ -337,6 +337,108 @@ class Links: """ ... + def get_initial_flow(self, idx: Union[int, str]) -> float: + """Return the initial flow assigned to a link. + + Symmetric reader for L{set_initial_flow} — engine gap BN-LINK-01a. + """ + ... + + def get_max_flow(self, idx: Union[int, str]) -> float: + """Return the maximum flow limit assigned to a link (0 = no limit). + + Symmetric reader for L{set_max_flow} — engine gap BN-LINK-01b. + """ + ... + + def get_orifice_type(self, idx: Union[int, str]) -> int: + """Return the orifice flow-attack classification (0=SIDE, 1=BOTTOM). + + Engine gap BN-LINK-02. Returns an error for non-orifice links. + """ + ... + + def set_orifice_type(self, idx: Union[int, str], type_: int) -> None: + """Set the orifice flow-attack classification (0=SIDE, 1=BOTTOM). + + Engine gap BN-LINK-02. Returns an error for non-orifice links. + """ + ... + + def get_weir_type(self, idx: Union[int, str]) -> int: + """Return the weir flow classification (0..4). + + Engine gap BN-LINK-03. 0=TRANSVERSE, 1=SIDEFLOW, 2=VNOTCH, + 3=TRAPEZOIDAL, 4=ROADWAY. Errors for non-weir links. + """ + ... + + def set_weir_type(self, idx: Union[int, str], type_: int) -> None: + """Set the weir flow classification (0..4). + + Engine gap BN-LINK-03. Errors for non-weir links or out-of-range values. + """ + ... + + def get_outlet_rating_type(self, idx: Union[int, str]) -> int: + """Return the outlet rating-curve classification (0..3). + + Engine gap BN-LINK-04. 0=FUNCTIONAL_HEAD, 1=FUNCTIONAL_DEPTH, + 2=TABULAR_HEAD, 3=TABULAR_DEPTH. Errors for non-outlet links. + """ + ... + + def set_outlet_rating_type(self, idx: Union[int, str], type_: int) -> None: + """Set the outlet rating-curve classification (0..3). + + Engine gap BN-LINK-04. Errors for non-outlet links or out-of-range values. + """ + ... + + def get_outlet_expon(self, idx: Union[int, str]) -> float: + """Return the outlet functional-form exponent. + + Engine gap BN-LINK-04. Errors for non-outlet links. + """ + ... + + def set_outlet_expon(self, idx: Union[int, str], expon: float) -> None: + """Set the outlet functional-form exponent. + + Engine gap BN-LINK-04. Errors for non-outlet links. + """ + ... + + def get_pump_startup_depth(self, idx: Union[int, str]) -> float: + """Return the pump startup depth (engine gap BN-LINK-05). + + Errors for non-pump links. + """ + ... + + def set_pump_startup_depth(self, idx: Union[int, str], depth: float) -> None: + """Set the pump startup depth (engine gap BN-LINK-05).""" + ... + + def get_pump_shutoff_depth(self, idx: Union[int, str]) -> float: + """Return the pump shutoff depth (engine gap BN-LINK-05).""" + ... + + def set_pump_shutoff_depth(self, idx: Union[int, str], depth: float) -> None: + """Set the pump shutoff depth (engine gap BN-LINK-05).""" + ... + + def get_orifice_open_close_rate(self, idx: Union[int, str]) -> float: + """Return the orifice open/close rate (1/s, engine gap BN-LINK-06). + + 0 = instantaneous. Errors for non-orifice links. + """ + ... + + def set_orifice_open_close_rate(self, idx: Union[int, str], rate: float) -> None: + """Set the orifice open/close rate (engine gap BN-LINK-06).""" + ... + # ==================================================================== # Per-element flow/depth state # ==================================================================== diff --git a/python/openswmm/engine/_links.pyx b/python/openswmm/engine/_links.pyx index 7bcc0e2d4..5fa0f96a3 100644 --- a/python/openswmm/engine/_links.pyx +++ b/python/openswmm/engine/_links.pyx @@ -441,6 +441,205 @@ class Links: cdef SWMM_Engine h = self._solver.handle _check(swmm_link_set_max_flow(h, i, flow)) + def get_initial_flow(self, idx) -> float: + """Return the initial flow assigned to a link. + + Symmetric reader for L{set_initial_flow} — engine gap BN-LINK-01a + (added 2026-05-25). + + @param idx: Link index (int) or link ID (str). + @type idx: Union[int, str] + @raise KeyError: If C{idx} is a string and the link ID is not found. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + cdef double v = 0.0 + _check(swmm_link_get_initial_flow(h, i, &v)) + return v + + def get_max_flow(self, idx) -> float: + """Return the maximum flow limit assigned to a link (0 = no limit). + + Symmetric reader for L{set_max_flow} — engine gap BN-LINK-01b + (added 2026-05-25). + + @param idx: Link index (int) or link ID (str). + @type idx: Union[int, str] + @raise KeyError: If C{idx} is a string and the link ID is not found. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + cdef double v = 0.0 + _check(swmm_link_get_max_flow(h, i, &v)) + return v + + def get_orifice_type(self, idx) -> int: + """Return the orifice flow-attack classification (0=SIDE, 1=BOTTOM). + + Engine gap BN-LINK-02 (added 2026-05-25). Returns SWMM_ERR_BADPARAM + when C{idx} names a non-orifice link. + + @param idx: Link index (int) or link ID (str). + @type idx: Union[int, str] + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + cdef int t = 0 + _check(swmm_link_get_orifice_type(h, i, &t)) + return t + + def set_orifice_type(self, idx, int type_): + """Set the orifice flow-attack classification. + + Engine gap BN-LINK-02 (added 2026-05-25). + + @param idx: Link index (int) or link ID (str). + @type idx: Union[int, str] + @param type_: 0 for SIDE, 1 for BOTTOM. + @type type_: int + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + _check(swmm_link_set_orifice_type(h, i, type_)) + + def get_weir_type(self, idx) -> int: + """Return the weir flow classification. + + Engine gap BN-LINK-03 (added 2026-05-25). Returns one of + 0=TRANSVERSE, 1=SIDEFLOW, 2=VNOTCH, 3=TRAPEZOIDAL, 4=ROADWAY. + Errors if C{idx} names a non-weir link. + + @param idx: Link index (int) or link ID (str). + @type idx: Union[int, str] + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + cdef int t = 0 + _check(swmm_link_get_weir_type(h, i, &t)) + return t + + def set_weir_type(self, idx, int type_): + """Set the weir flow classification. + + Engine gap BN-LINK-03 (added 2026-05-25). Accepts 0..4. + + @param idx: Link index (int) or link ID (str). + @type idx: Union[int, str] + @param type_: 0=TRANSVERSE, 1=SIDEFLOW, 2=VNOTCH, 3=TRAPEZOIDAL, 4=ROADWAY. + @type type_: int + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + _check(swmm_link_set_weir_type(h, i, type_)) + + def get_outlet_rating_type(self, idx) -> int: + """Return the outlet rating-curve classification (0..3). + + Engine gap BN-LINK-04 (added 2026-05-25). + 0=FUNCTIONAL_HEAD, 1=FUNCTIONAL_DEPTH, 2=TABULAR_HEAD, 3=TABULAR_DEPTH. + Errors for non-outlet links. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + cdef int t = 0 + _check(swmm_link_get_outlet_rating_type(h, i, &t)) + return t + + def set_outlet_rating_type(self, idx, int type_): + """Set the outlet rating-curve classification (0..3). + + Engine gap BN-LINK-04 (added 2026-05-25). Errors for non-outlet + links or out-of-range values. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + _check(swmm_link_set_outlet_rating_type(h, i, type_)) + + def get_outlet_expon(self, idx) -> float: + """Return the outlet functional-form exponent. + + Engine gap BN-LINK-04 (added 2026-05-25). Meaningful only for + FUNCTIONAL_* rating types; engine ignores the value when the + type is TABULAR_*. Errors for non-outlet links. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + cdef double v = 0.0 + _check(swmm_link_get_outlet_expon(h, i, &v)) + return v + + def set_outlet_expon(self, idx, double expon): + """Set the outlet functional-form exponent. + + Engine gap BN-LINK-04 (added 2026-05-25). Errors for non-outlet + links. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + _check(swmm_link_set_outlet_expon(h, i, expon)) + + def get_pump_startup_depth(self, idx) -> float: + """Return the pump startup depth (project length units). + + Engine gap BN-LINK-05 (added 2026-05-25). Errors for non-pump links. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + cdef double v = 0.0 + _check(swmm_link_get_pump_startup_depth(h, i, &v)) + return v + + def set_pump_startup_depth(self, idx, double depth): + """Set the pump startup depth. + + Engine gap BN-LINK-05 (added 2026-05-25). Errors for non-pump links. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + _check(swmm_link_set_pump_startup_depth(h, i, depth)) + + def get_pump_shutoff_depth(self, idx) -> float: + """Return the pump shutoff depth (project length units). + + Engine gap BN-LINK-05 (added 2026-05-25). Errors for non-pump links. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + cdef double v = 0.0 + _check(swmm_link_get_pump_shutoff_depth(h, i, &v)) + return v + + def set_pump_shutoff_depth(self, idx, double depth): + """Set the pump shutoff depth. + + Engine gap BN-LINK-05 (added 2026-05-25). Errors for non-pump links. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + _check(swmm_link_set_pump_shutoff_depth(h, i, depth)) + + def get_orifice_open_close_rate(self, idx) -> float: + """Return the orifice open/close rate (fraction per second). + + Engine gap BN-LINK-06 (added 2026-05-25). 0 = instantaneous. + Errors for non-orifice links. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + cdef double v = 0.0 + _check(swmm_link_get_orifice_open_close_rate(h, i, &v)) + return v + + def set_orifice_open_close_rate(self, idx, double rate): + """Set the orifice open/close rate (fraction per second). + + Engine gap BN-LINK-06 (added 2026-05-25). 0 = instantaneous. + Errors for non-orifice links. + """ + cdef int i = self._resolve(idx) + cdef SWMM_Engine h = self._solver.handle + _check(swmm_link_set_orifice_open_close_rate(h, i, rate)) + # ==================================================================== # Per-element flow/depth state # ==================================================================== diff --git a/python/openswmm/engine/_output_reader.pyi b/python/openswmm/engine/_output_reader.pyi index 6c1adb31d..35cb47298 100644 --- a/python/openswmm/engine/_output_reader.pyi +++ b/python/openswmm/engine/_output_reader.pyi @@ -474,3 +474,56 @@ class OutputReader: @raise RuntimeError: If the underlying read fails. """ ... + + # ==================================================================== + # Post-run node statistics aggregated from the .out file. + # Each releases the GIL during the C call. + # ==================================================================== + + def get_node_stat_max_depth(self, node_idx: int) -> float: + """Maximum node depth across all reporting periods. + + Wraps C{swmm_output_get_node_stat_max_depth}. GIL is released + during the C call. + + @param node_idx: Zero-based node index. + @type node_idx: int + @return: Peak depth in the model's length units. + + .. versionadded:: 6.0.0 + """ + ... + + def get_node_stat_max_overflow(self, node_idx: int) -> float: + """Maximum node overflow rate across all reporting periods. + + Wraps C{swmm_output_get_node_stat_max_overflow}. GIL is released + during the C call. + + @return: Peak overflow rate in the file's flow units. + + .. versionadded:: 6.0.0 + """ + ... + + def get_node_stat_vol_flooded(self, node_idx: int) -> float: + """Total flood volume at the node across the simulation. + + Wraps C{swmm_output_get_node_stat_vol_flooded}. GIL is released + during the C call. + + @return: Total flooded volume in ft³ (US) or m³ (SI). + + .. versionadded:: 6.0.0 + """ + ... + + def get_node_stat_time_flooded(self, node_idx: int) -> float: + """Total flooded time in seconds. Divide by 3600 for hours. + + Wraps C{swmm_output_get_node_stat_time_flooded}. GIL is released + during the C call. + + .. versionadded:: 6.0.0 + """ + ... diff --git a/python/openswmm/engine/_output_reader.pyx b/python/openswmm/engine/_output_reader.pyx index 3770e2a49..08f4f964a 100644 --- a/python/openswmm/engine/_output_reader.pyx +++ b/python/openswmm/engine/_output_reader.pyx @@ -558,3 +558,103 @@ cdef class OutputReader: if rc != 0: raise RuntimeError(f"Output read error {rc}") return buf + + # ==================================================================== + # Post-run node statistics aggregated from the .out file. + # + # These mirror the live-engine ``Statistics`` accessors + # (node_max_depth, node_max_overflow, node_vol_flooded, + # node_time_flooded) but read from a closed .out file rather than + # the running engine. Useful in post-processing workflows where the + # engine handle has already been destroyed and only the binary + # output remains. Each releases the GIL during the C call — the + # aggregation reads many periods sequentially from disk and is + # worth parallelising. + # ==================================================================== + + def get_node_stat_max_depth(self, int node_idx) -> float: + """Maximum node depth across all reporting periods. + + Wraps C{swmm_output_get_node_stat_max_depth}. GIL is released + during the C call. + + @param node_idx: Zero-based node index (0 .. node_count - 1). + @type node_idx: int + @return: Peak depth in the model's length units (ft / m). + @rtype: float + @raise RuntimeError: On I/O failure or out-of-range index. + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Output h = self._handle + cdef double v = 0.0 + cdef int rc + with nogil: + rc = swmm_output_get_node_stat_max_depth(h, node_idx, &v) + if rc != 0: + raise RuntimeError(f"Output read error {rc}") + return v + + def get_node_stat_max_overflow(self, int node_idx) -> float: + """Maximum node overflow rate across all reporting periods. + + Wraps C{swmm_output_get_node_stat_max_overflow}. GIL is released + during the C call. + + @return: Peak overflow rate in the file's flow units (see + :meth:`get_flow_units`). + @rtype: float + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Output h = self._handle + cdef double v = 0.0 + cdef int rc + with nogil: + rc = swmm_output_get_node_stat_max_overflow(h, node_idx, &v) + if rc != 0: + raise RuntimeError(f"Output read error {rc}") + return v + + def get_node_stat_vol_flooded(self, int node_idx) -> float: + """Total flood volume at the node across the simulation. + + Aggregates ``sum(overflow_i * report_step)`` over periods where + overflow > 0. Wraps C{swmm_output_get_node_stat_vol_flooded}. + GIL is released during the C call. + + @return: Total flooded volume in ft³ (US) or m³ (SI). Matches + the units of the live-engine ``stat_vol_flooded``. + @rtype: float + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Output h = self._handle + cdef double v = 0.0 + cdef int rc + with nogil: + rc = swmm_output_get_node_stat_vol_flooded(h, node_idx, &v) + if rc != 0: + raise RuntimeError(f"Output read error {rc}") + return v + + def get_node_stat_time_flooded(self, int node_idx) -> float: + """Total time the node was flooded across the simulation + (seconds). Divide by 3600 for hours (the statsrpt convention). + + Wraps C{swmm_output_get_node_stat_time_flooded}. GIL is released + during the C call. + + @return: Total flooded time in seconds. + @rtype: float + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Output h = self._handle + cdef double v = 0.0 + cdef int rc + with nogil: + rc = swmm_output_get_node_stat_time_flooded(h, node_idx, &v) + if rc != 0: + raise RuntimeError(f"Output read error {rc}") + return v diff --git a/python/openswmm/engine/_solver.pyi b/python/openswmm/engine/_solver.pyi index 919b19d9e..61d3da946 100644 --- a/python/openswmm/engine/_solver.pyi +++ b/python/openswmm/engine/_solver.pyi @@ -487,6 +487,67 @@ class Solver: """ ... + # ========================================================================= + # Phase 1b: Runoff interface file (legacy "Frunoff"). + # Each method releases the GIL during the C call. + # ========================================================================= + + def open_runoff_iface_write(self, path: str) -> None: + """Open the runoff interface file in SAVE mode. The engine auto-emits + one record per runoff substep until ``close_runoff_iface`` is called. + GIL is released during the C call. + + @raise EngineError: On file-open failure or when a runoff iface + file is already open. + + .. versionadded:: 6.0.0 + """ + ... + + def open_runoff_iface_read(self, path: str) -> None: + """Open the runoff interface file in USE mode. The engine does NOT + yet auto-skip runoff in USE mode — see :py:meth:`read_runoff_step`. + GIL is released during the C call. + + @raise EngineError: On file-open failure or header mismatch + (subcatchment count, pollutant count, or flow units differ + from the current model). + + .. versionadded:: 6.0.0 + """ + ... + + def save_runoff_step(self, dt: float) -> None: + """Manually force one runoff substep snapshot to the open SAVE + file (no-op when no file is open or the file is in USE mode). + GIL is released during the C call. + + .. versionadded:: 6.0.0 + """ + ... + + def read_runoff_step(self) -> bool: + """Read one runoff substep record from the open USE file into + the current subcatchment state. + + @return: ``True`` on success, ``False`` on EOF. + @rtype: bool + + GIL is released during the C call. + + .. versionadded:: 6.0.0 + """ + ... + + def close_runoff_iface(self) -> None: + """Close the runoff interface file (idempotent). GIL is released + during the C call. Also invoked automatically when the solver + is closed. + + .. versionadded:: 6.0.0 + """ + ... + # ========================================================================= # Step callbacks # ========================================================================= diff --git a/python/openswmm/engine/_solver.pyx b/python/openswmm/engine/_solver.pyx index e26b0f618..9c0dc3742 100644 --- a/python/openswmm/engine/_solver.pyx +++ b/python/openswmm/engine/_solver.pyx @@ -660,6 +660,116 @@ cdef class Solver: """ _check(swmm_set_steady_state_skip(self._handle, 1 if enabled else 0)) + # ========================================================================= + # Phase 1b: Runoff interface file (legacy "Frunoff") + # ========================================================================= + # + # Persist per-subcatchment runoff to a binary file (SAVE mode) so a + # downstream routing-only run can replay the runoff phase (USE mode). + # SAVE mode is fully auto-integrated — the engine emits one record per + # runoff substep from inside ``stepRunoff``. USE-mode auto-skip is a + # follow-up; today's USE mode requires the caller to invoke + # :py:meth:`read_runoff_step` between ``step`` calls. + + def open_runoff_iface_write(self, str path): + """Open the runoff interface file in SAVE mode. + + :param path: Output file path. Existing content is truncated. + :type path: str + + :raises EngineError: If the file cannot be opened, or a runoff + interface file is already open and must be closed first. + + .. versionadded:: 6.0.0 + """ + cdef bytes b = path.encode('utf-8') + cdef SWMM_Engine h = self._handle + cdef const char* p = b + cdef int err + with nogil: + err = swmm_runoff_iface_open_write(h, p) + _check(err) + + def open_runoff_iface_read(self, str path): + """Open the runoff interface file in USE mode. + + :param path: Path to an existing runoff interface file. + :type path: str + + :raises EngineError: On file-open failure or header mismatch + (subcatchment count, pollutant count, or flow units differ + from the current model). + + .. note:: + + The engine does not yet auto-skip runoff in USE mode. After + opening, the caller must invoke :py:meth:`read_runoff_step` + between simulation steps; the engine will still run its own + runoff computation and overwrite the loaded state if you do + not handle that yourself. Full USE-mode auto-skip is tracked + as a follow-up to Phase 1b. + + .. versionadded:: 6.0.0 + """ + cdef bytes b = path.encode('utf-8') + cdef SWMM_Engine h = self._handle + cdef const char* p = b + cdef int err + with nogil: + err = swmm_runoff_iface_open_read(h, p) + _check(err) + + def save_runoff_step(self, double dt): + """Force one runoff substep snapshot to the open SAVE file. + + :param dt: Substep duration in seconds recorded with the snapshot. + :type dt: float + + Typically unnecessary — the engine emits records automatically + from inside ``stepRunoff``. This method is exposed for plugin + authors and tests that want to force a snapshot at a specific + time. No-op when no file is open or the file is in USE mode. + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._handle + cdef int err + with nogil: + err = swmm_runoff_iface_save_step(h, dt) + _check(err) + + def read_runoff_step(self) -> bool: + """Read one runoff substep record from the open USE file into + the current subcatchment state. + + :returns: ``True`` when a record was read; ``False`` on EOF. + :rtype: bool + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._handle + cdef int has = 0 + cdef int err + with nogil: + err = swmm_runoff_iface_read_step(h, &has) + _check(err) + return bool(has) + + def close_runoff_iface(self): + """Close the runoff interface file (idempotent). + + Also invoked automatically when the solver is closed; calling it + explicitly is useful in tests or when reusing the same solver + for a second runoff run. + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._handle + cdef int err + with nogil: + err = swmm_runoff_iface_close(h) + _check(err) + # ========================================================================= # Step callbacks # ========================================================================= diff --git a/python/openswmm/engine/_statistics.pyi b/python/openswmm/engine/_statistics.pyi index 25c87eab4..b903e62a4 100644 --- a/python/openswmm/engine/_statistics.pyi +++ b/python/openswmm/engine/_statistics.pyi @@ -310,3 +310,55 @@ class Statistics: .. versionadded:: 6.0.0 """ ... + + # ==================================================================== + # Phase 4e link-stat bulks — completes the per-link statistics + # surface. Each releases the GIL during the C call. + # ==================================================================== + + def link_max_velocity_bulk(self) -> npt.NDArray[np.float64]: + """Return peak velocities for all links as a NumPy array. + + Wraps C{swmm_stat_link_max_velocity_bulk}. GIL is released + during the C call. + + @return: Array of shape C{(n_links,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... + + def link_max_filling_bulk(self) -> npt.NDArray[np.float64]: + """Return peak depth-to-full-depth ratios for all links as a + NumPy array. Wraps C{swmm_stat_link_max_filling_bulk}. GIL is + released during the C call. + + @return: Array of shape C{(n_links,)} with dtype C{float64} + (dimensionless ratio). + + .. versionadded:: 6.0.0 + """ + ... + + def link_vol_flow_bulk(self) -> npt.NDArray[np.float64]: + """Return cumulative flow volumes for all links as a NumPy + array. Wraps C{swmm_stat_link_vol_flow_bulk}. GIL is released + during the C call. + + @return: Array of shape C{(n_links,)} with dtype C{float64}. + + .. versionadded:: 6.0.0 + """ + ... + + def link_surcharge_time_bulk(self) -> npt.NDArray[np.float64]: + """Return cumulative surcharge time for all links as a NumPy + array. Wraps C{swmm_stat_link_surcharge_time_bulk}. GIL is + released during the C call. + + @return: Array of shape C{(n_links,)} with dtype C{float64} + (hours). + + .. versionadded:: 6.0.0 + """ + ... diff --git a/python/openswmm/engine/_statistics.pyx b/python/openswmm/engine/_statistics.pyx index 67bf75497..610a11795 100644 --- a/python/openswmm/engine/_statistics.pyx +++ b/python/openswmm/engine/_statistics.pyx @@ -410,3 +410,95 @@ class Statistics: err = swmm_stat_subcatch_max_runoff_bulk(h, p, n) _check(err) return buf + + # ------------------------------------------------------------------ + # Phase 4e link-stat bulks — completes the per-link statistics + # surface so MCP-side ``capacity_summary`` can fetch each column in + # a single C call instead of looping the scalar accessor per link. + # GIL is released for each C call. + # ------------------------------------------------------------------ + + def link_max_velocity_bulk(self) -> np.ndarray: + """Return peak velocities for all links as a NumPy array. + + Wraps C{swmm_stat_link_max_velocity_bulk}. GIL is released + during the C call. + + :returns: Array of shape ``(n_links,)``, dtype ``float64``, in + project length/time units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_stat_link_max_velocity_bulk(h, p, n) + _check(err) + return buf + + def link_max_filling_bulk(self) -> np.ndarray: + """Return peak depth-to-full-depth ratios for all links as a + NumPy array. Wraps C{swmm_stat_link_max_filling_bulk}. GIL is + released during the C call. + + :returns: Array of shape ``(n_links,)``, dtype ``float64``, + dimensionless ratio (>1 = surcharged). + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_stat_link_max_filling_bulk(h, p, n) + _check(err) + return buf + + def link_vol_flow_bulk(self) -> np.ndarray: + """Return cumulative flow volumes for all links as a NumPy array. + Wraps C{swmm_stat_link_vol_flow_bulk}. GIL is released during + the C call. + + :returns: Array of shape ``(n_links,)``, dtype ``float64``, in + project volume units. + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_stat_link_vol_flow_bulk(h, p, n) + _check(err) + return buf + + def link_surcharge_time_bulk(self) -> np.ndarray: + """Return cumulative surcharge time for all links as a NumPy + array. Wraps C{swmm_stat_link_surcharge_time_bulk}. GIL is + released during the C call. + + :returns: Array of shape ``(n_links,)``, dtype ``float64``, in + hours (consistent with the scalar accessor). + :rtype: numpy.ndarray + + .. versionadded:: 6.0.0 + """ + cdef SWMM_Engine h = self._solver.handle + cdef int n = swmm_link_count(h) + cdef np.ndarray[double, ndim=1] buf = np.empty(n, dtype=np.float64) + cdef double* p = buf.data + cdef int err + with nogil: + err = swmm_stat_link_surcharge_time_bulk(h, p, n) + _check(err) + return buf diff --git a/python/tests/engine/test_output_reader.py b/python/tests/engine/test_output_reader.py index 2d704dab1..e1ddc030a 100644 --- a/python/tests/engine/test_output_reader.py +++ b/python/tests/engine/test_output_reader.py @@ -176,3 +176,93 @@ def test_get_subcatch_attribute(self, output_file): arr = r.get_subcatch_attribute(0, 0) assert isinstance(arr, np.ndarray) assert len(arr) > 0 + + +class TestOutputReaderNodeStatistics: + """Post-run node statistics aggregated from the .out file. + + These four accessors (``get_node_stat_max_depth`` / + ``get_node_stat_max_overflow`` / ``get_node_stat_vol_flooded`` / + ``get_node_stat_time_flooded``) were added to the C API after the + initial binding sweep and surfaced through the drift test in + ``python/tests/test_api_coverage.py``. + + Tests assert: (1) the methods exist; (2) they return ``float``; + (3) they return non-negative values (these are cumulative + quantities — a negative value would indicate a wrong-column read); + (4) the per-period series and the aggregate are mutually consistent + (peak depth aggregate must equal the max of the + SWMM_OUT_NODE_DEPTH=0 series for the same node). + """ + + def test_methods_exist(self, output_file): + with OutputReader(output_file) as r: + assert hasattr(r, "get_node_stat_max_depth") + assert hasattr(r, "get_node_stat_max_overflow") + assert hasattr(r, "get_node_stat_vol_flooded") + assert hasattr(r, "get_node_stat_time_flooded") + + def test_max_depth_returns_float(self, output_file): + with OutputReader(output_file) as r: + v = r.get_node_stat_max_depth(0) + assert isinstance(v, float) + assert v >= 0.0 + + def test_max_overflow_returns_float(self, output_file): + with OutputReader(output_file) as r: + v = r.get_node_stat_max_overflow(0) + assert isinstance(v, float) + assert v >= 0.0 + + def test_vol_flooded_returns_float(self, output_file): + with OutputReader(output_file) as r: + v = r.get_node_stat_vol_flooded(0) + assert isinstance(v, float) + assert v >= 0.0 + + def test_time_flooded_returns_float(self, output_file): + with OutputReader(output_file) as r: + v = r.get_node_stat_time_flooded(0) + assert isinstance(v, float) + assert v >= 0.0 + + def test_max_depth_matches_series_aggregate(self, output_file): + """Sanity: max(depth_series) == get_node_stat_max_depth. + + Catches the regression where the aggregator reads the wrong + SWMM_Out_NodeAttribute column or accidentally walks links.""" + with OutputReader(output_file) as r: + n_periods = r.get_period_count() + assert n_periods > 0 + # SWMM_OUT_NODE_DEPTH = 0 (see openswmm_output.h). + series = r.get_node_series(0, 0, 0, n_periods - 1) + agg = r.get_node_stat_max_depth(0) + # series is float32, agg is float64; compare with tolerance. + assert agg == pytest.approx(float(series.max()), rel=1e-5, + abs=1e-9) + + def test_stats_consistent_across_all_nodes(self, output_file): + """All nodes should yield non-negative stats; a wrong-column + read or off-by-one would produce a negative somewhere.""" + with OutputReader(output_file) as r: + n = r.get_node_count() + for i in range(n): + assert r.get_node_stat_max_depth(i) >= 0.0 + assert r.get_node_stat_max_overflow(i) >= 0.0 + assert r.get_node_stat_vol_flooded(i) >= 0.0 + assert r.get_node_stat_time_flooded(i) >= 0.0 + + def test_time_flooded_units_are_seconds(self, output_file): + """Per the header doc the unit is seconds (not hours). Bound + as a sanity-check: total flooded seconds must be <= simulation + duration.""" + with OutputReader(output_file) as r: + n_periods = r.get_period_count() + report_step = r.get_report_step() + sim_duration_sec = n_periods * report_step + n = r.get_node_count() + for i in range(n): + t = r.get_node_stat_time_flooded(i) + assert 0.0 <= t <= sim_duration_sec + 1e-6, ( + f"node {i}: flooded {t}s > sim duration " + f"{sim_duration_sec}s") diff --git a/python/tests/engine/test_runoff_interface.py b/python/tests/engine/test_runoff_interface.py new file mode 100644 index 000000000..ecb75a2db --- /dev/null +++ b/python/tests/engine/test_runoff_interface.py @@ -0,0 +1,170 @@ +"""Tests for the Phase 1b runoff interface Python bindings. + +The bindings wrap the new C API entry points (``swmm_runoff_iface_*``). +SAVE-mode is fully integrated with the engine's runoff loop — the engine +emits one record per substep automatically. USE-mode currently requires +the caller to drive ``read_runoff_step`` themselves; this test exercises +the C API without claiming the engine yet skips its own runoff +computation in USE mode (that integration is tracked as a follow-up). +""" + +from __future__ import annotations + +import os +import pytest + +from openswmm.engine import EngineState, Solver + +from tests.engine.conftest import SITE_DRAINAGE_INP + + +def _drive_to_end(solver): + while solver.state == EngineState.RUNNING: + rc = solver.step() + if rc != 0: + break + + +# --------------------------------------------------------------------------- +# SAVE round trip +# --------------------------------------------------------------------------- + + +class TestRunoffInterfaceSaveMode: + """SAVE mode is the headline path — open the file, run the + simulation, the engine auto-emits records, close the file.""" + + def test_save_mode_writes_a_non_trivial_file(self, tmp_path): + rpt = str(tmp_path / "rfi_save.rpt") + out = str(tmp_path / "rfi_save.out") + rfi = str(tmp_path / "phase1b.rfi") + + s = Solver(SITE_DRAINAGE_INP, rpt, out) + try: + s.open() + s.initialize() + s.start() + s.open_runoff_iface_write(rfi) + _drive_to_end(s) + s.end() + s.close_runoff_iface() + finally: + try: + s.close() + except Exception: + pass + s.destroy() + + # File must exist and be larger than the 28-byte header. + assert os.path.exists(rfi) + assert os.path.getsize(rfi) > 28, ( + "Expected at least one substep record beyond the header") + + def test_save_then_read_round_trip(self, tmp_path): + rpt = str(tmp_path / "rfi_rt.rpt") + out = str(tmp_path / "rfi_rt.out") + rfi = str(tmp_path / "phase1b_rt.rfi") + + # Phase 1: SAVE mode — drive the simulation and write the file. + s = Solver(SITE_DRAINAGE_INP, rpt, out) + try: + s.open() + s.initialize() + s.start() + s.open_runoff_iface_write(rfi) + _drive_to_end(s) + s.end() + s.close_runoff_iface() + finally: + try: + s.close() + except Exception: + pass + s.destroy() + + # Phase 2: USE mode — reopen, count records by polling read_step + # until it returns False (EOF). The bindings expose the file but + # do not yet skip the engine's runoff — that's a follow-up. For + # this test we just verify the read API can drain the file + # cleanly without errors. + s2 = Solver(SITE_DRAINAGE_INP, rpt, out) + records = 0 + try: + s2.open() + s2.initialize() + s2.start() + s2.open_runoff_iface_read(rfi) + for _ in range(100_000): # generous upper bound + if not s2.read_runoff_step(): + break + records += 1 + else: + pytest.fail("read_runoff_step appears to loop past EOF") + s2.close_runoff_iface() + s2.end() + finally: + try: + s2.close() + except Exception: + pass + s2.destroy() + + assert records > 0 + + +# --------------------------------------------------------------------------- +# Contract tests — no engine work needed beyond initialize(). +# --------------------------------------------------------------------------- + + +class TestRunoffInterfaceContracts: + """Lifecycle + idempotency contracts that don't require a full run.""" + + def test_close_is_idempotent(self, tmp_path): + rpt = str(tmp_path / "rfi_close.rpt") + out = str(tmp_path / "rfi_close.out") + s = Solver(SITE_DRAINAGE_INP, rpt, out) + try: + s.open() + s.initialize() + # Never opened — close should still succeed. + s.close_runoff_iface() + s.close_runoff_iface() + finally: + try: + s.close() + except Exception: + pass + s.destroy() + + def test_read_step_returns_false_when_no_file_open(self, tmp_path): + rpt = str(tmp_path / "rfi_nofile.rpt") + out = str(tmp_path / "rfi_nofile.out") + s = Solver(SITE_DRAINAGE_INP, rpt, out) + try: + s.open() + s.initialize() + # No file ever opened. + assert s.read_runoff_step() is False + finally: + try: + s.close() + except Exception: + pass + s.destroy() + + def test_save_step_is_noop_when_no_file_open(self, tmp_path): + rpt = str(tmp_path / "rfi_nosave.rpt") + out = str(tmp_path / "rfi_nosave.out") + s = Solver(SITE_DRAINAGE_INP, rpt, out) + try: + s.open() + s.initialize() + # No file open — explicit save must succeed silently. + s.save_runoff_step(1.0) + finally: + try: + s.close() + except Exception: + pass + s.destroy() diff --git a/python/tests/test_api_coverage.py b/python/tests/test_api_coverage.py new file mode 100644 index 000000000..6e10b670a --- /dev/null +++ b/python/tests/test_api_coverage.py @@ -0,0 +1,304 @@ +"""C API ↔ Cython binding coverage drift test. + +Per :file:`docs/C_API_BINDINGS_MCP_IMPROVEMENT_PLAN.md` §Phase 6.1 — assert +that every ``SWMM_ENGINE_API`` symbol declared in +``include/openswmm/engine/*.h`` has a matching ``cdef extern`` (or other +direct reference) in the Cython ``.pxd`` / ``.pyx`` files under +``python/openswmm/engine/``. + +The test does **not** import the compiled extension — it is a pure-text +analysis that runs even in environments where the C extension has not +been built (CI source-only jobs, IDE linting, etc.). + +If you intentionally add a new C symbol that does not need a Python +binding (e.g. an experimental / internal helper), add its name to +``KNOWN_UNBOUND`` below with a one-line justification. The set's only +job is to prevent **accidental** binding gaps — every entry here is a +conscious choice, not a TODO. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +import pytest + + +# --------------------------------------------------------------------------- +# Repository layout +# --------------------------------------------------------------------------- +# This file lives at ``python/tests/test_api_coverage.py``. The C headers +# live at ``include/openswmm/engine/*.h`` two directories up; the Cython +# sources at ``python/openswmm/engine/*.{pxd,pyx}``. + +_TESTS_DIR = Path(__file__).resolve().parent +_PYTHON_DIR = _TESTS_DIR.parent +_REPO_ROOT = _PYTHON_DIR.parent + +_HEADER_GLOB = _REPO_ROOT / "include" / "openswmm" / "engine" +_CYTHON_DIR = _PYTHON_DIR / "openswmm" / "engine" + + +# --------------------------------------------------------------------------- +# Allowlist of symbols intentionally not bound by Cython (Phase 6.1 baseline). +# +# Each entry is justified. When binding any of these, just remove the line — +# the test will then enforce that the binding stays in place. When adding +# brand-new C symbols that should be bound, the test will fail until you +# either add the binding or extend this allowlist (with justification). +# --------------------------------------------------------------------------- +KNOWN_UNBOUND: frozenset[str] = frozenset({ + # ---- Aquifer editor (3) ------------------------------------------------- + # GW aquifer table management surfaces; no Python consumers yet — GW + # users edit via INP for now. Track as a Phase-3-style binding batch. + "swmm_aquifer_add", + "swmm_aquifer_count", + "swmm_aquifer_index", + + # ---- Control rule validation (1) ---------------------------------------- + # Standalone validator for the rule mini-language. The Python + # ``Controls`` class exposes ``add_rule`` which already validates + # implicitly; standalone validation is a niche feature. + "swmm_control_validate_rule", + + # ---- DWF / external-inflow editors (4) ---------------------------------- + # Read/remove half of the DWF and ExtInflow editor surface. Python + # bindings expose ``add`` and ``count`` for both; ``get`` / ``remove`` + # are tracked as a follow-up binding batch. + "swmm_dwf_get", + "swmm_dwf_remove", + "swmm_ext_inflow_get", + "swmm_ext_inflow_remove", + + # ---- Hydrograph (RDII) editor (7) --------------------------------------- + # The Python ``Inflows`` class binds the add/count surface; the + # finer-grained edit/remove API is a follow-up. + "swmm_hydrograph_clear_group_months", + "swmm_hydrograph_group_rename", + "swmm_hydrograph_remove_entry", + "swmm_hydrograph_remove_group", + "swmm_hydrograph_set_gage", + "swmm_hydrograph_set_ia", + "swmm_hydrograph_set_rtk", + + # ---- By-name index lookups (3) ------------------------------------------ + # The Python classes for inlets / LIDs / streets expose ``add`` / + # ``count`` but not ``index``. Adding ``get_index(name)`` is a thin + # follow-up. + "swmm_inlet_index", + "swmm_lid_index", + "swmm_street_index", + + # ---- Tag editors (6) ---------------------------------------------------- + # [TAGS] section round-trip is implemented at the engine level but the + # Python classes do not yet surface get/set methods. Useful for GUI + # builders; tracked as a follow-up binding batch. + "swmm_link_get_tag", + "swmm_link_set_tag", + "swmm_node_get_tag", + "swmm_node_set_tag", + "swmm_subcatch_get_tag", + "swmm_subcatch_set_tag", + + # ---- Outfall stage-data readers (2) ------------------------------------- + # The setters for tidal / timeseries outfall stages are bound; the + # corresponding readers are tested at the C++ level but the Python + # ``Nodes`` class does not surface them yet. + "swmm_node_get_outfall_tidal", + "swmm_node_get_outfall_timeseries", + + # ---- Pattern editor (6) ------------------------------------------------- + # The Python ``Tables`` class binds ``pattern_add`` / + # ``pattern_count`` / ``pattern_set_factors``; the per-element + # accessors and remove/rename surface are a follow-up. + "swmm_pattern_get_factor", + "swmm_pattern_get_factor_count", + "swmm_pattern_get_type", + "swmm_pattern_index", + "swmm_pattern_remove", + "swmm_pattern_rename", + + # ---- RDII per-element remove/set (3) ------------------------------------ + "swmm_rdii_decay_remove", + "swmm_rdii_decay_set", + "swmm_rdii_remove", + + # ---- Snowpack editor (3) ------------------------------------------------ + # Snow state is currently a placeholder (see audit Appendix on + # ``swmm_subcatch_get_snow_depth``); the snowpack editor will land + # alongside full snow-state integration. + "swmm_snowpack_add", + "swmm_snowpack_count", + "swmm_snowpack_index", + + # ---- Table type query (1) ----------------------------------------------- + # ``Tables`` binds the value-level get/set surface; the meta-level + # ``get_type`` is a one-line follow-up. + "swmm_table_get_type", + + # ---- Transect editor (15) ----------------------------------------------- + # Read-back / remove / rename of the [TRANSECTS] section. The Python + # ``Infrastructure`` class binds ``add_transect`` / + # ``add_transect_station`` / ``count`` / ``set_*_params``; the rest of + # the editor surface is queued as a follow-up batch. + "swmm_transect_clear_stations", + "swmm_transect_get_bank_stations", + "swmm_transect_get_comments", + "swmm_transect_get_encroachment_stations", + "swmm_transect_get_modifiers", + "swmm_transect_get_roughness", + "swmm_transect_get_station", + "swmm_transect_get_station_count", + "swmm_transect_index", + "swmm_transect_remove", + "swmm_transect_rename", + "swmm_transect_set_bank_stations", + "swmm_transect_set_comments", + "swmm_transect_set_encroachment_stations", + "swmm_transect_set_modifiers", +}) + + +# Regex to extract C API symbol names from header declarations. Handles +# the common multi-line shape ``SWMM_ENGINE_API (`` +# where ```` may span multiple identifiers (e.g. +# ``SWMM_ENGINE_API const char* swmm_x(``) and may be followed by +# whitespace, newlines, or a ``*``. +_C_API_PATTERN = re.compile( + r"SWMM_ENGINE_API\s+(?:\w+\s+)+\*?\s*(swmm_[a-z_0-9]+)\s*\(", + re.MULTILINE, +) + +# Any reference to a ``swmm_*`` identifier in Cython files is treated as a +# binding — we don't try to distinguish ``cdef extern`` declarations from +# call sites because the latter implies the former somewhere upstream. +_CYTHON_REF_PATTERN = re.compile(r"\b(swmm_[a-z_0-9]+)\b") + + +def _collect_c_symbols() -> set[str]: + """Return every C API symbol declared with ``SWMM_ENGINE_API``.""" + assert _HEADER_GLOB.is_dir(), ( + f"Header directory not found: {_HEADER_GLOB}. Has the project " + f"layout changed?") + symbols: set[str] = set() + for header in sorted(_HEADER_GLOB.glob("*.h")): + src = header.read_text(encoding="utf-8") + for m in _C_API_PATTERN.finditer(src): + symbols.add(m.group(1)) + return symbols + + +def _collect_cython_refs() -> set[str]: + """Return every ``swmm_*`` identifier referenced in Cython files.""" + assert _CYTHON_DIR.is_dir(), ( + f"Cython source directory not found: {_CYTHON_DIR}.") + refs: set[str] = set() + for path in sorted(_CYTHON_DIR.glob("*.pxd")): + src = path.read_text(encoding="utf-8") + for m in _CYTHON_REF_PATTERN.finditer(src): + refs.add(m.group(1)) + for path in sorted(_CYTHON_DIR.glob("*.pyx")): + src = path.read_text(encoding="utf-8") + for m in _CYTHON_REF_PATTERN.finditer(src): + refs.add(m.group(1)) + return refs + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestApiCoverage: + """Drift guards for the C API ↔ Cython binding surface.""" + + def test_extraction_finds_a_large_surface(self): + """Sanity: if either side returns near-zero symbols the regexes + have broken and the rest of this file is silently meaningless.""" + c_symbols = _collect_c_symbols() + cython_refs = _collect_cython_refs() + # Soft floors — the project has hundreds of API symbols. These + # numbers are well below current counts (~626 C / ~593 Cython at + # time of writing) so a routine refactor won't trip them, but a + # broken regex extracting 0 or 5 symbols will. + assert len(c_symbols) > 200, ( + f"Only {len(c_symbols)} C API symbols extracted — regex broken?") + assert len(cython_refs) > 200, ( + f"Only {len(cython_refs)} Cython refs extracted — regex broken?") + + def test_every_c_symbol_is_bound_or_allowlisted(self): + """The headline drift assertion. + + Every ``SWMM_ENGINE_API`` symbol must either be referenced from a + Cython ``.pxd`` / ``.pyx`` file (i.e. callable from Python) or be + explicitly listed in ``KNOWN_UNBOUND`` with a justification. + """ + c_symbols = _collect_c_symbols() + cython_refs = _collect_cython_refs() + unbound = c_symbols - cython_refs - KNOWN_UNBOUND + if unbound: + joined = "\n - " + "\n - ".join(sorted(unbound)) + pytest.fail( + f"{len(unbound)} new C API symbol(s) are not bound by " + f"Cython and are not in the allowlist:{joined}\n\n" + "Either add a `cdef extern` declaration in " + "`python/openswmm/engine/_common.pxd` (or the appropriate " + ".pxd / .pyx), or extend `KNOWN_UNBOUND` in " + "test_api_coverage.py with a one-line justification.") + + def test_allowlist_does_not_contain_phantoms(self): + """If an allowlisted symbol no longer exists in the headers (e.g. + it was renamed or removed), the test should flag it so the + allowlist stays clean.""" + c_symbols = _collect_c_symbols() + phantoms = KNOWN_UNBOUND - c_symbols + if phantoms: + joined = "\n - " + "\n - ".join(sorted(phantoms)) + pytest.fail( + f"{len(phantoms)} allowlisted symbol(s) do not exist in " + f"any header — please remove them from " + f"``KNOWN_UNBOUND``:{joined}") + + def test_allowlist_does_not_shadow_bound_symbols(self): + """If a symbol on the allowlist actually IS bound, the allowlist + entry is misleading — flag it so the entry is removed. + + This catches the case where someone binds a symbol but forgets to + remove its allowlist entry.""" + cython_refs = _collect_cython_refs() + shadowed = KNOWN_UNBOUND & cython_refs + if shadowed: + joined = "\n - " + "\n - ".join(sorted(shadowed)) + pytest.fail( + f"{len(shadowed)} symbol(s) on the allowlist are actually " + f"bound — please remove them from ``KNOWN_UNBOUND``:" + f"{joined}") + + +# --------------------------------------------------------------------------- +# A diagnostic that's useful to inspect manually: +# +# python -m pytest python/tests/test_api_coverage.py::test_print_summary -s +# +# ...prints how many symbols on each side and how many are bound. Not a +# regression assertion; just a friendly diagnostic. +# --------------------------------------------------------------------------- + + +def test_print_summary(capsys): + """Diagnostic dump (always passes).""" + c_symbols = _collect_c_symbols() + cython_refs = _collect_cython_refs() + intersect = c_symbols & cython_refs + unbound = c_symbols - cython_refs + unjustified = unbound - KNOWN_UNBOUND + with capsys.disabled(): + print() + print(f" C API symbols (SWMM_ENGINE_API): {len(c_symbols):>4}") + print(f" Cython references: {len(cython_refs):>4}") + print(f" Bound (intersection): {len(intersect):>4}") + print(f" Unbound total: {len(unbound):>4}") + print(f" of which allowlisted: {len(KNOWN_UNBOUND):>4}") + print(f" of which unjustified: {len(unjustified):>4}") diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index 2638ace57..8f80a3cc8 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -395,7 +395,20 @@ endif() # ---- Optional GeoPackage I/O library ---- if(OPENSWMM_WITH_GEOPACKAGE) add_subdirectory(input/geopackage) - message(STATUS "OpenSWMM Engine: GeoPackage I/O library enabled") + # Slice RC.1 (APPROVED 2026-05-25, see §R.3 of openswmm.gui's + # GUI_IMPLEMENTATION_PLAN.md) — link the static GeoPackage lib into + # the engine SHARED so its IPluginComponentInfo singleton is + # resolvable from PluginFactory::register_builtin_infos AND so the + # PUBLIC OPENSWMM_HAS_GEOPACKAGE define propagates to the engine's + # own TUs (gating the explicit register_one call). + # PRIVATE because the cycle (engine ←→ geopackage PRIVATE both + # directions) collapses cleanly when geopackage's STATIC archive is + # absorbed into the engine SHARED at link time. + target_link_libraries(openswmm_engine PRIVATE openswmm_geopackage) + if(TARGET openswmm_engine_internal) + target_link_libraries(openswmm_engine_internal PRIVATE openswmm_geopackage) + endif() + message(STATUS "OpenSWMM Engine: GeoPackage I/O library enabled (built-in plugin)") else() message(STATUS "OpenSWMM Engine: GeoPackage I/O disabled (set -DOPENSWMM_WITH_GEOPACKAGE=ON to enable)") endif() diff --git a/src/engine/core/InpWriter.cpp b/src/engine/core/InpWriter.cpp index 56e9db79b..9defded0e 100644 --- a/src/engine/core/InpWriter.cpp +++ b/src/engine/core/InpWriter.cpp @@ -784,9 +784,17 @@ int writeInpFile(const SimulationContext& ctx, const std::string& path) { for(int t=0;t(t); std::fprintf(f,"NC %10.4f %10.4f %10.4f\n",ctx.transects.n_left[ut],ctx.transects.n_right[ut],ctx.transects.n_channel[ut]); int nsta=static_cast(ctx.transects.stations[ut].size()); - std::fprintf(f,"X1 %-16s %10d %10.4f %10.4f 0 0 0 %10.4f %10.4f\n", + // X1 layout per EPA SWMM 5 (transect.c::setParams): + // X1 Name Nsta Xleft Xright 0 0 Lfactor Xfactor Yfactor + // i.e. TWO placeholder zeros between Xright and Lfactor (not three). + // An extra zero shifts Lfactor/Xfactor/Yfactor by one column, which + // every SWMM-5 parser then misreads. + std::fprintf(f,"X1 %-16s %10d %10.4f %10.4f 0 0 %10.4f %10.4f %10.4f\n", ctx.transects.names[ut].c_str(),nsta,ctx.transects.x_left_bank[ut], - ctx.transects.x_right_bank[ut],ctx.transects.x_factor[ut],ctx.transects.y_factor[ut]); + ctx.transects.x_right_bank[ut], + ctx.transects.length_factor[ut], + ctx.transects.x_factor[ut], + ctx.transects.y_factor[ut]); for(int k=0;k(k); if(k%5==0)std::fprintf(f,"GR"); std::fprintf(f," %10.4f %10.4f",ctx.transects.elevations[ut][uk],ctx.transects.stations[ut][uk]); diff --git a/src/engine/core/SWMMEngine.cpp b/src/engine/core/SWMMEngine.cpp index 8eb90fa37..3da61500e 100644 --- a/src/engine/core/SWMMEngine.cpp +++ b/src/engine/core/SWMMEngine.cpp @@ -790,6 +790,12 @@ void SWMMEngine::stepRunoff(double dt_routing) noexcept { // A4b. Accumulate runoff mass balance totals accumulateRunoffMassBalance(dt_runoff); + // A4b'. Phase 1b auto-save hook — when the runoff interface file + // is open in SAVE mode, emit one record per substep. saveResults + // is a cheap no-op when the file is not in SAVE mode, so the + // unconditional call here costs nothing for ordinary runs. + saveRunoffIfaceStep(dt_runoff); + // A4c. Surface quality: buildup + washoff stepSurfaceQuality(dt_runoff); @@ -2730,6 +2736,11 @@ int SWMMEngine::close() noexcept { // Close routing interface files iface_.closeFiles(); + // Phase 1b: close the runoff interface file (no-op if never opened). + // Done before plugins_.unload_all so that any plugin holding the + // runoff file open via swmm_runoff_iface_* sees a clean shutdown. + closeRunoffIface(); + // Unload all dynamically loaded plugin libraries plugins_.unload_all(); @@ -2870,6 +2881,68 @@ void SWMMEngine::applyForcings(double dt) noexcept { } } +// ============================================================================ +// Phase 1b: runoff interface file management +// ============================================================================ +// +// Thin wrappers around runoff_iface::RunoffInterfaceFile. The auto-save +// hook lives in stepRunoff() right after accumulateRunoffMassBalance — +// see the call site in this file for the in-loop emit. + +int SWMMEngine::openRunoffIfaceWrite(const std::string& path) noexcept { + if (runoff_iface_file_ && runoff_iface_file_->isOpen()) { + // Refuse to silently leak the previous file — caller must close + // explicitly so it's obvious in tests / debug logs. + return -10; + } + runoff_iface_file_ = std::make_unique(); + const int rc = runoff_iface_file_->openForWrite( + path, + ctx_.n_subcatches(), + ctx_.n_pollutants(), + static_cast(ctx_.options.flow_units)); + if (rc != 0) runoff_iface_file_.reset(); + return rc; +} + +int SWMMEngine::openRunoffIfaceRead(const std::string& path) noexcept { + if (runoff_iface_file_ && runoff_iface_file_->isOpen()) return -10; + runoff_iface_file_ = std::make_unique(); + const int rc = runoff_iface_file_->openForRead( + path, + ctx_.n_subcatches(), + ctx_.n_pollutants(), + static_cast(ctx_.options.flow_units)); + if (rc != 0) runoff_iface_file_.reset(); + return rc; +} + +void SWMMEngine::saveRunoffIfaceStep(double dt) noexcept { + if (!runoff_iface_file_) return; + // saveResults is a no-op if the file is not in SAVE mode. + runoff_iface_file_->saveResults(ctx_, dt); +} + +bool SWMMEngine::readRunoffIfaceStep() noexcept { + if (!runoff_iface_file_) return false; + return runoff_iface_file_->readResults(ctx_); +} + +void SWMMEngine::closeRunoffIface() noexcept { + if (!runoff_iface_file_) return; + runoff_iface_file_->close(); + runoff_iface_file_.reset(); +} + +FileMode SWMMEngine::runoffIfaceMode() const noexcept { + if (!runoff_iface_file_ || !runoff_iface_file_->isOpen()) + return FileMode::NONE; + // RunoffInterfaceFile doesn't expose its mode directly; infer from + // the FilesSpec which is set by the C API entry points before + // calling open*. Falling back to SAVE keeps the diagnostic non-NONE. + return ctx_.files.runoff_mode; +} + // ============================================================================ // Callback registration // ============================================================================ diff --git a/src/engine/core/SWMMEngine.hpp b/src/engine/core/SWMMEngine.hpp index be339c432..a9e08d3c8 100644 --- a/src/engine/core/SWMMEngine.hpp +++ b/src/engine/core/SWMMEngine.hpp @@ -49,6 +49,7 @@ #include "../hydrology/LID.hpp" #include "../hydrology/Inflow.hpp" #include "../hydrology/RDII.hpp" +#include "../hydrology/RunoffInterface.hpp" #include "../quality/QualityRouting.hpp" #include "../quality/Landuse.hpp" #include "../controls/Controls.hpp" @@ -199,6 +200,53 @@ class SWMMEngine { SimulationContext& context() noexcept { return ctx_; } const SimulationContext& context() const noexcept { return ctx_; } + /** + * @brief Open the runoff interface file in SAVE mode (Phase 1b). + * + * @details Allocates a fresh @ref runoff_iface::RunoffInterfaceFile, + * opens @p path for binary writing, and stamps the header + * with the current subcatchment count, pollutant count, + * and flow units. Subsequent runoff substeps emit one + * record each via the auto-save hook in + * @ref SWMMEngine::stepRunoff. + * + * @returns 0 on success, non-zero on failure (file could not be + * opened, or a runoff interface file was already open and + * must be closed first). + */ + int openRunoffIfaceWrite(const std::string& path) noexcept; + + /** + * @brief Open the runoff interface file in USE mode. + * + * @details Opens @p path for binary reading and verifies that the + * header matches the current model's subcatchment count, + * pollutant count, and flow units. The engine does not + * auto-read records yet — callers invoke + * @ref swmm_runoff_iface_read_step manually between + * @c step calls. USE-mode auto-skip is a follow-up. + */ + int openRunoffIfaceRead(const std::string& path) noexcept; + + /// Write one record to the open runoff interface file (no-op when + /// the file is not open in SAVE mode). Called automatically once + /// per runoff substep; also callable explicitly via the C API. + void saveRunoffIfaceStep(double dt) noexcept; + + /// Read one record from the open runoff interface file into the + /// subcatchment runoff/quality vectors. Returns @c true on + /// success, @c false on EOF or when no file is open in READ mode. + bool readRunoffIfaceStep() noexcept; + + /// Close and reset the runoff interface file (safe to call multiple + /// times; safe to call when no file was opened). + void closeRunoffIface() noexcept; + + /// Accessor used by tests / diagnostics — returns the current mode + /// of the runoff interface file, or @c FileMode::NONE if no file is + /// open. + FileMode runoffIfaceMode() const noexcept; + /** @brief Access the runoff solver (for hot start infil state save/restore). */ runoff::RunoffSolver& runoff_solver() noexcept { return runoff_; } const runoff::RunoffSolver& runoff_solver() const noexcept { return runoff_; } @@ -256,6 +304,14 @@ class SWMMEngine { hydstruct::StructureSolver hydstruct_; ///< Pumps, orifices, weirs, outlets iface::InterfaceManager iface_; ///< Routing interface file I/O + // Phase 1b: optional runoff interface file (legacy "Frunoff"). + // When in SAVE mode, the engine auto-emits one record per runoff substep + // from inside stepRunoff(). When in USE mode, no engine-side integration + // happens yet — the C API exposes the file but the caller is responsible + // for invoking swmm_runoff_iface_read_step() between simulation steps + // (USE-mode auto-skip is tracked as a follow-up). + std::unique_ptr runoff_iface_file_; + // Event and steady-state tracking int next_event_ = 0; ///< Index of next event in ctx_.events bool isBetweenEvents(double current_date) const; ///< Check if between routing events diff --git a/src/engine/core/openswmm_engine_impl.cpp b/src/engine/core/openswmm_engine_impl.cpp index a8ece72e2..e268e0377 100644 --- a/src/engine/core/openswmm_engine_impl.cpp +++ b/src/engine/core/openswmm_engine_impl.cpp @@ -346,4 +346,43 @@ SWMM_ENGINE_API int swmm_set_steady_state_skip(SWMM_Engine engine, int enabled) return SWMM_OK; } +// ============================================================================ +// Phase 1b: Runoff interface file (legacy "Frunoff") +// ============================================================================ +// +// Thin pass-throughs to SWMMEngine's runoff-iface methods. The non-trivial +// work (header validation, per-substep auto-save) lives in SWMMEngine.cpp; +// here we just translate between the public C ABI and the C++ helpers. + +SWMM_ENGINE_API int swmm_runoff_iface_open_write(SWMM_Engine engine, const char* path) { + CHECK_HANDLE(engine); + if (!path || path[0] == '\0') return SWMM_ERR_BADPARAM; + return to_engine(engine)->openRunoffIfaceWrite(path); +} + +SWMM_ENGINE_API int swmm_runoff_iface_open_read(SWMM_Engine engine, const char* path) { + CHECK_HANDLE(engine); + if (!path || path[0] == '\0') return SWMM_ERR_BADPARAM; + return to_engine(engine)->openRunoffIfaceRead(path); +} + +SWMM_ENGINE_API int swmm_runoff_iface_save_step(SWMM_Engine engine, double dt) { + CHECK_HANDLE(engine); + to_engine(engine)->saveRunoffIfaceStep(dt); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_runoff_iface_read_step(SWMM_Engine engine, int* has_data) { + CHECK_HANDLE(engine); + const bool ok = to_engine(engine)->readRunoffIfaceStep(); + if (has_data) *has_data = ok ? 1 : 0; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_runoff_iface_close(SWMM_Engine engine) { + CHECK_HANDLE(engine); + to_engine(engine)->closeRunoffIface(); + return SWMM_OK; +} + } /* extern "C" */ diff --git a/src/engine/core/openswmm_links_impl.cpp b/src/engine/core/openswmm_links_impl.cpp index 5e5988099..3c36a4660 100644 --- a/src/engine/core/openswmm_links_impl.cpp +++ b/src/engine/core/openswmm_links_impl.cpp @@ -185,6 +185,250 @@ SWMM_ENGINE_API int swmm_link_set_max_flow(SWMM_Engine engine, int idx, double f return SWMM_OK; } +// Engine gap BN-LINK-01a (added 2026-05-25) — symmetric getter for +// swmm_link_set_initial_flow. Reads the same SoA slot the setter writes. +// Read-only: usable in any post-construction state (no CHECK_GEOMETRY). +SWMM_ENGINE_API int swmm_link_get_initial_flow(SWMM_Engine engine, int idx, double* flow) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + if (flow) *flow = ctx.links.flow[static_cast(idx)]; + return SWMM_OK; +} + +// Engine gap BN-LINK-01b (added 2026-05-25) — symmetric getter for +// swmm_link_set_max_flow. Reads the `q_limit` SoA slot. 0.0 means no +// limit (mirrors the setter's contract documented in openswmm_links.h:223). +SWMM_ENGINE_API int swmm_link_get_max_flow(SWMM_Engine engine, int idx, double* flow) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + if (flow) *flow = ctx.links.q_limit[static_cast(idx)]; + return SWMM_OK; +} + +// Engine gap BN-LINK-02 (added 2026-05-25) — orifice TYPE (SIDE / BOTTOM) +// accessors. Stored in `links.param1` per the legacy convention documented +// in LinkData.hpp:379 and LinksHandler.cpp:138 — 0.0 = BOTTOM, 1.0 = SIDE. +// The integer ABI here uses 0 = SIDE, 1 = BOTTOM to match the legacy +// SWMM-GUI combo order (SWMM-GUI/Epaswmm5/objprops.txt:862 lists +// 'SIDE' first, 'BOTTOM' second). The engine-internal float storage +// stays as-is; the int↔float mapping lives in this accessor pair so +// callers always see the legacy enum ordering. +// +// Returns SWMM_ERR_BADPARAM if the link type isn't ORIFICE; tests pin the +// contract so callers don't silently mutate a conduit's `param1` slot +// (which means something else for conduits). +SWMM_ENGINE_API int swmm_link_set_orifice_type(SWMM_Engine engine, int idx, int type) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_GEOMETRY(ctx); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::ORIFICE) + return SWMM_ERR_BADPARAM; + if (type != 0 && type != 1) return SWMM_ERR_BADPARAM; + // GUI 0=SIDE → engine 1.0=SIDE ; GUI 1=BOTTOM → engine 0.0=BOTTOM. + ctx.links.param1[uidx] = (type == 0) ? 1.0 : 0.0; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_orifice_type(SWMM_Engine engine, int idx, int* type) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::ORIFICE) + return SWMM_ERR_BADPARAM; + if (type) { + // Engine 1.0=SIDE → GUI 0=SIDE; Engine 0.0=BOTTOM → GUI 1=BOTTOM. + *type = (ctx.links.param1[uidx] >= 0.5) ? 0 : 1; + } + return SWMM_OK; +} + +// Engine gap BN-LINK-03 (added 2026-05-25) — weir TYPE accessors. Five +// values matching the legacy WeirType enum order in +// `legacy/engine/enums.h:925`: TRANSVERSE=0, SIDEFLOW=1, VNOTCH=2, +// TRAPEZOIDAL=3, ROADWAY=4. Stored in `links.param1` per the legacy +// convention (LinksHandler.cpp:171-178). ROADWAY is accepted by the +// accessor even though the .inp parser doesn't yet recognise the +// "ROADWAY" keyword — the engine simulation code (legacy/engine/link.c) +// has the case branch, so an interactively-set ROADWAY weir does work. +// Filing the .inp parser-side ROADWAY tokenisation as a separate gap. +SWMM_ENGINE_API int swmm_link_set_weir_type(SWMM_Engine engine, int idx, int type) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_GEOMETRY(ctx); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::WEIR) + return SWMM_ERR_BADPARAM; + if (type < 0 || type > 4) return SWMM_ERR_BADPARAM; + ctx.links.param1[uidx] = static_cast(type); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_weir_type(SWMM_Engine engine, int idx, int* type) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::WEIR) + return SWMM_ERR_BADPARAM; + if (type) { + // Round to nearest int — param1 is a double slot but only + // discrete integer-valued weir-type codes are stored. + const int raw = static_cast(ctx.links.param1[uidx] + 0.5); + *type = (raw < 0 || raw > 4) ? 0 : raw; + } + return SWMM_OK; +} + +// Engine gap BN-LINK-04 (added 2026-05-25) — outlet RATING CURVE TYPE +// accessors. Four values: 0=FUNCTIONAL_HEAD, 1=FUNCTIONAL_DEPTH, +// 2=TABULAR_HEAD, 3=TABULAR_DEPTH. Encoding matches the existing +// LinksHandler.cpp:214-221 convention; stored in `links.param1` per the +// established legacy pattern. +// +// Functional coefficient (`links.cd`) and tabular curve index +// (`links.pump_curve`) reuse the existing scalar accessors +// (`swmm_link_set_discharge_coeff` / `swmm_link_set_pump_curve`); only +// the type code and the functional exponent (`links.param2`) need +// new outlet-typed accessor pairs to be unambiguous about which +// link kind they apply to. +SWMM_ENGINE_API int swmm_link_set_outlet_rating_type(SWMM_Engine engine, int idx, int type) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_GEOMETRY(ctx); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::OUTLET) + return SWMM_ERR_BADPARAM; + if (type < 0 || type > 3) return SWMM_ERR_BADPARAM; + ctx.links.param1[uidx] = static_cast(type); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_outlet_rating_type(SWMM_Engine engine, int idx, int* type) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::OUTLET) + return SWMM_ERR_BADPARAM; + if (type) { + const int raw = static_cast(ctx.links.param1[uidx] + 0.5); + *type = (raw < 0 || raw > 3) ? 0 : raw; + } + return SWMM_OK; +} + +// Outlet functional exponent (`links.param2`). Only valid for outlets; +// for the TABULAR/* rating types the engine ignores the stored value. +SWMM_ENGINE_API int swmm_link_set_outlet_expon(SWMM_Engine engine, int idx, double expon) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_GEOMETRY(ctx); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::OUTLET) + return SWMM_ERR_BADPARAM; + ctx.links.param2[uidx] = expon; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_outlet_expon(SWMM_Engine engine, int idx, double* expon) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::OUTLET) + return SWMM_ERR_BADPARAM; + if (expon) *expon = ctx.links.param2[uidx]; + return SWMM_OK; +} + +// Engine gap BN-LINK-05 (added 2026-05-25) — pump startup / shutoff +// depth accessors. Engine state already exists at LinkData.hpp:310-313 +// (`pump_startup`, `pump_shutoff`); these accessors expose it through +// the public ABI. Non-pump links rejected. +SWMM_ENGINE_API int swmm_link_set_pump_startup_depth(SWMM_Engine engine, int idx, double depth) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_GEOMETRY(ctx); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::PUMP) + return SWMM_ERR_BADPARAM; + ctx.links.pump_startup[uidx] = depth; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_pump_startup_depth(SWMM_Engine engine, int idx, double* depth) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::PUMP) + return SWMM_ERR_BADPARAM; + if (depth) *depth = ctx.links.pump_startup[uidx]; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_set_pump_shutoff_depth(SWMM_Engine engine, int idx, double depth) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_GEOMETRY(ctx); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::PUMP) + return SWMM_ERR_BADPARAM; + ctx.links.pump_shutoff[uidx] = depth; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_pump_shutoff_depth(SWMM_Engine engine, int idx, double* depth) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::PUMP) + return SWMM_ERR_BADPARAM; + if (depth) *depth = ctx.links.pump_shutoff[uidx]; + return SWMM_OK; +} + +// Engine gap BN-LINK-06 (added 2026-05-25) — orifice open/close rate +// accessor pair. Engine state exists at LinkData.hpp:390 (`orate`). +// Stores fraction per second (0 = instantaneous) per the legacy +// convention; legacy SWMM-GUI uses the "Time to Open/Close" label in +// hours, but the engine stores the rate. The GUI's +// `setOrificeOrateHours` helper converts the legacy hours-based value +// to the engine's rate-per-second on write. +SWMM_ENGINE_API int swmm_link_set_orifice_open_close_rate(SWMM_Engine engine, int idx, double rate) { + CHECK_HANDLE(engine); + auto& ctx = to_engine(engine)->context(); + CHECK_GEOMETRY(ctx); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::ORIFICE) + return SWMM_ERR_BADPARAM; + ctx.links.orate[uidx] = rate; + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_link_get_orifice_open_close_rate(SWMM_Engine engine, int idx, double* rate) { + CHECK_HANDLE(engine); + const auto& ctx = to_engine(engine)->context(); + CHECK_INDEX(idx >= 0 && idx < ctx.n_links()); + const auto uidx = static_cast(idx); + if (ctx.links.type[uidx] != openswmm::LinkType::ORIFICE) + return SWMM_ERR_BADPARAM; + if (rate) *rate = ctx.links.orate[uidx]; + return SWMM_OK; +} + // ============================================================================ // Cross-section // ============================================================================ diff --git a/src/engine/core/openswmm_statistics_impl.cpp b/src/engine/core/openswmm_statistics_impl.cpp index 7deab85b5..30113524b 100644 --- a/src/engine/core/openswmm_statistics_impl.cpp +++ b/src/engine/core/openswmm_statistics_impl.cpp @@ -207,4 +207,52 @@ SWMM_ENGINE_API int swmm_stat_subcatch_max_runoff_bulk(SWMM_Engine engine, doubl return SWMM_OK; } +// ---------------------------------------------------------------------------- +// Phase 4e: link-stat bulks (max_velocity, max_filling, vol_flow, +// surcharge_time). All simple SoA memcpys from the corresponding scalar +// accessor's column. Added to complete the per-link statistics surface so +// the MCP server's capacity_summary tool can be collapsed to a single-pass +// shape. +// ---------------------------------------------------------------------------- + +SWMM_ENGINE_API int swmm_stat_link_max_velocity_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_links()); + std::copy(ctx.links.stat_max_veloc.begin(), + ctx.links.stat_max_veloc.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_stat_link_max_filling_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_links()); + std::copy(ctx.links.stat_max_filling.begin(), + ctx.links.stat_max_filling.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_stat_link_vol_flow_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_links()); + std::copy(ctx.links.stat_vol_flow.begin(), + ctx.links.stat_vol_flow.begin() + n, buf); + return SWMM_OK; +} + +SWMM_ENGINE_API int swmm_stat_link_surcharge_time_bulk(SWMM_Engine engine, double* buf, int count) { + CHECK_HANDLE(engine); + if (!buf || count <= 0) return SWMM_ERR_BADPARAM; + const auto& ctx = to_engine(engine)->context(); + const int n = std::min(count, ctx.n_links()); + std::copy(ctx.links.stat_time_surcharged.begin(), + ctx.links.stat_time_surcharged.begin() + n, buf); + return SWMM_OK; +} + } /* extern "C" */ diff --git a/src/engine/input/geopackage/CMakeLists.txt b/src/engine/input/geopackage/CMakeLists.txt index 31b925972..b3d470663 100644 --- a/src/engine/input/geopackage/CMakeLists.txt +++ b/src/engine/input/geopackage/CMakeLists.txt @@ -36,6 +36,17 @@ set_target_properties(openswmm_geopackage PROPERTIES CXX_STANDARD 20 CXX_STANDARD_REQUIRED ON POSITION_INDEPENDENT_CODE ON + # Slice RC.2 (see §R.3 of openswmm.gui's GUI_IMPLEMENTATION_PLAN.md) — + # default-hide GeoPackage symbols so the historically-leaked + # `openswmm_plugin_info` C symbol no longer appears as a dlsym hit + # on the engine's SHARED library. The PluginFactory registers + # GeoPackagePluginInfo explicitly through register_builtin_infos + # instead; this hardening removes the duplicate-discovery side + # effect and any third-party consumer that was resolving the + # symbol via dlsym should switch to discover_plugins_by_id(). + CXX_VISIBILITY_PRESET hidden + C_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON ) target_include_directories(openswmm_geopackage @@ -53,9 +64,12 @@ target_link_libraries(openswmm_geopackage PUBLIC openswmm_common $,unofficial::sqlite3::sqlite3,SQLite::SQLite3> - PRIVATE - openswmm_engine ) +# Slice RC.1 follow-up — geopackage no longer links openswmm_engine. +# Engine now PRIVATE-links geopackage (absorbing the STATIC archive +# into the SHARED), which made the reverse link both redundant and a +# CMake target cycle (SHARED ↔ STATIC is not allowed). Engine headers +# remain reachable via the PUBLIC include paths above (lines 54-57). target_compile_definitions(openswmm_geopackage PUBLIC OPENSWMM_HAS_GEOPACKAGE=1 diff --git a/src/engine/input/geopackage/GeoPackagePluginInfo.cpp b/src/engine/input/geopackage/GeoPackagePluginInfo.cpp index 547170e52..f3a49c90c 100644 --- a/src/engine/input/geopackage/GeoPackagePluginInfo.cpp +++ b/src/engine/input/geopackage/GeoPackagePluginInfo.cpp @@ -33,9 +33,37 @@ bool GeoPackagePluginInfo::register_plugin(const RegistrationInfo& info) { } // namespace openswmm::gpkg // ============================================================================ -// C export +// C export — Slice RC.2 // ============================================================================ +// +// This factory function used to be the discovery entry point: PluginFactory +// scanned the engine's library directory, dlsym'd `openswmm_plugin_info` on +// each loadable .so / .dylib / .dll, and if non-null registered the returned +// info pointer. Because GeoPackage is statically linked into the engine +// SHARED, the symbol was visible from the engine binary itself — and the +// scan picked it up, producing a "phantom" GeoPackage plugin row alongside +// the explicit-built-ins. +// +// Slice RC.1 (APPROVED 2026-05-25) registers GeoPackagePluginInfo +// explicitly via PluginFactory::register_builtin_infos. The function below +// is preserved for compatibility with any third-party tool that resolved +// the symbol via dlsym, BUT it is now annotated `hidden` so it no longer +// leaks out of the engine SHARED. Combined with the +// CXX_VISIBILITY_PRESET=hidden / VISIBILITY_INLINES_HIDDEN=ON properties +// on the openswmm_geopackage target, this closes the symbol leak that +// PluginFactory::discover() was inadvertently picking up. +// +// On MSVC, visibility attributes are no-ops; the absence of +// __declspec(dllexport) (and the lack of a corresponding .def entry) is +// what keeps the symbol off the engine DLL export table. -extern "C" openswmm::IPluginComponentInfo* openswmm_plugin_info(void) { +#if defined(__GNUC__) || defined(__clang__) +# define OPENSWMM_GPKG_HIDDEN __attribute__((visibility("hidden"))) +#else +# define OPENSWMM_GPKG_HIDDEN +#endif + +extern "C" OPENSWMM_GPKG_HIDDEN +openswmm::IPluginComponentInfo* openswmm_plugin_info(void) { return &openswmm::gpkg::GeoPackagePluginInfo::instance(); } diff --git a/src/engine/input/geopackage/GeoPackagePluginInfo.hpp b/src/engine/input/geopackage/GeoPackagePluginInfo.hpp index 7b25a2168..c61b17ce0 100644 --- a/src/engine/input/geopackage/GeoPackagePluginInfo.hpp +++ b/src/engine/input/geopackage/GeoPackagePluginInfo.hpp @@ -148,20 +148,20 @@ class GeoPackagePluginInfo : public IPluginComponentInfo { } // namespace openswmm::gpkg // ============================================================================ -// C export for plugin discovery +// Slice RC.2 — `openswmm_plugin_info` is no longer a public symbol. // ============================================================================ - -extern "C" { - -/** - * @brief Plugin discovery entry point. - * - * @details The PluginFactory calls this after dlopen() to obtain the - * component info singleton. The returned pointer is NOT owned - * by the caller — it points to a static singleton. - */ -openswmm::IPluginComponentInfo* openswmm_plugin_info(void); - -} +// +// The C factory function in GeoPackagePluginInfo.cpp is annotated with +// __attribute__((visibility("hidden"))) and the GeoPackage CMake target +// ships with CXX_VISIBILITY_PRESET=hidden / VISIBILITY_INLINES_HIDDEN=ON. +// Together these prevent the dlsym leak that PluginFactory::discover() +// was picking up on the engine's SHARED binary. +// +// Hosts that need the GeoPackage plugin should obtain it via the +// PluginFactory (it is now registered as a built-in in +// register_builtin_infos when OPENSWMM_HAS_GEOPACKAGE is defined) or via +// openswmm::discover_plugins_by_id() — NOT via dlsym on the engine +// library. See §R.3 of GUI_IMPLEMENTATION_PLAN.md (in the +// openswmm.gui repo) for the full rationale. #endif // OPENSWMM_GEOPACKAGE_PLUGIN_INFO_HPP diff --git a/src/engine/input/handlers/LinksHandler.cpp b/src/engine/input/handlers/LinksHandler.cpp index 7ba8dd99e..d8f108da4 100644 --- a/src/engine/input/handlers/LinksHandler.cpp +++ b/src/engine/input/handlers/LinksHandler.cpp @@ -383,12 +383,31 @@ void handle_transects(SimulationContext& ctx, const std::vector& li if (tok.size() > 3) nc_channel = to_double(tok[3]); } else if (keyword == "X1") { - // X1 name nSta xLeftBank xRightBank 0 0 0 xFactor yFactor + // Per EPA SWMM 5 (transect.c::setParams) the X1 layout is: + // X1 Name Nsta Xleft Xright 0 0 Lfactor Xfactor Yfactor + // Tokens: 1 2 3 4 5 6 7 8 9 + // (Only two placeholder zeros sit between Xright and Lfactor — + // NOT three; the SWMM 5 user-manual line "0 0 0 Lfactor Wfactor + // Eoff" misled an earlier read of this code.) + // + // SWMM also treats Lfactor==0 / Xfactor==0 as "use 1.0" — both + // are multiplicative modifiers, and EPA SWMM-generated files + // routinely emit zero placeholders here. Without the same + // default-to-one fallback the chart collapses every station to + // x*0 = 0 (the "vertical line" cross-section bug). if (tok.size() < 3) continue; const std::string& name = tok[1]; ctx.transects.names.push_back(name); + // All parallel arrays in TransectStore must be kept in lock-step + // with `names` — count() reports names.size() and downstream + // accessors (swmm_transect_get_comments / _encroachment / + // _modifiers) index every array by the same `ui`. Missing a + // push_back here lets a subsequent get_*() read past end of an + // empty vector and crash. INP doesn't supply comments / + // encroachment, so default them at parse time. + ctx.transects.comments.emplace_back(); ctx.transects.n_left.push_back(nc_left); ctx.transects.n_right.push_back(nc_right); ctx.transects.n_channel.push_back(nc_channel); @@ -396,10 +415,18 @@ void handle_transects(SimulationContext& ctx, const std::vector& li (tok.size() > 3) ? to_double(tok[3]) : 0.0); ctx.transects.x_right_bank.push_back( (tok.size() > 4) ? to_double(tok[4]) : 0.0); - ctx.transects.x_factor.push_back( - (tok.size() > 8) ? to_double(tok[8]) : 1.0); - ctx.transects.y_factor.push_back( - (tok.size() > 9) ? to_double(tok[9]) : 1.0); + ctx.transects.x_left_encroachment.push_back(0.0); + ctx.transects.x_right_encroachment.push_back(0.0); + + double lFactor = (tok.size() > 7) ? to_double(tok[7]) : 1.0; + double xFactor = (tok.size() > 8) ? to_double(tok[8]) : 1.0; + double yFactor = (tok.size() > 9) ? to_double(tok[9]) : 0.0; + if (lFactor == 0.0) lFactor = 1.0; + if (xFactor == 0.0) xFactor = 1.0; + ctx.transects.length_factor.push_back(lFactor); + ctx.transects.x_factor.push_back(xFactor); + ctx.transects.y_factor.push_back(yFactor); + ctx.transects.stations.emplace_back(); ctx.transects.elevations.emplace_back(); } diff --git a/src/engine/output/OutputReader.cpp b/src/engine/output/OutputReader.cpp index b47f4fc7d..66d4345eb 100644 --- a/src/engine/output/OutputReader.cpp +++ b/src/engine/output/OutputReader.cpp @@ -522,4 +522,109 @@ bool OutputReader::readReal8(double& value) const { return std::fread(&value, sizeof(double), 1, file_) == 1; } +// ---------------------------------------------------------------------------- +// Slice QA-01 — per-node summary statistics (computed on-demand) +// ---------------------------------------------------------------------------- +// +// All four aggregators walk the node's full period range via +// get_node_series(). For a typical project (a few thousand periods) the +// cost is sub-millisecond — well below the human-perceptible threshold +// for an attribute-panel refresh. The same shape is reused across the +// four functions because the math collapses to a single fold; pulling +// it into a helper would obscure the variable selection and unit +// conventions documented per-function in the header. +// +// Var indices (from openswmm_output.h SWMM_OutNodeVar): +// SWMM_OUT_NODE_DEPTH = 0 +// SWMM_OUT_NODE_OVERFLOW = 5 +// +// Hard-coded here to keep this TU free of a header coupling — the .out +// format's variable ordering is part of the binary contract and only +// changes with a file-format version bump. + +namespace { +constexpr int kNodeVarDepth = 0; +constexpr int kNodeVarOverflow = 5; +} // anonymous + +bool OutputReader::get_node_stat_max_depth(int node_idx, double* value) const { + if (!value || !is_open()) return false; + if (node_idx < 0 || node_idx >= n_nodes_) return false; + if (n_periods_ <= 0) { *value = 0.0; return true; } + + std::vector series(static_cast(n_periods_)); + if (!get_node_series(node_idx, kNodeVarDepth, 0, n_periods_ - 1, series.data())) + return false; + + double maxV = static_cast(series[0]); + for (size_t i = 1; i < series.size(); ++i) { + if (series[i] > maxV) maxV = static_cast(series[i]); + } + *value = maxV; + return true; +} + +bool OutputReader::get_node_stat_max_overflow(int node_idx, double* value) const { + if (!value || !is_open()) return false; + if (node_idx < 0 || node_idx >= n_nodes_) return false; + if (n_periods_ <= 0) { *value = 0.0; return true; } + + std::vector series(static_cast(n_periods_)); + if (!get_node_series(node_idx, kNodeVarOverflow, 0, n_periods_ - 1, series.data())) + return false; + + double maxV = static_cast(series[0]); + for (size_t i = 1; i < series.size(); ++i) { + if (series[i] > maxV) maxV = static_cast(series[i]); + } + *value = maxV; + return true; +} + +bool OutputReader::get_node_stat_vol_flooded(int node_idx, double* value) const { + if (!value || !is_open()) return false; + if (node_idx < 0 || node_idx >= n_nodes_) return false; + if (n_periods_ <= 0) { *value = 0.0; return true; } + + std::vector overflow(static_cast(n_periods_)); + if (!get_node_series(node_idx, kNodeVarOverflow, 0, n_periods_ - 1, overflow.data())) + return false; + + // Sum overflow * report_step over periods with positive overflow. + // The legacy engine accumulates this at routing-step resolution + // (`NodeStats[j].volFlooded += Node[j].overflow * tStep` in + // stats.c:554); we accumulate at report-step resolution because + // that's the only granularity the .out file preserves. + const double dt = static_cast(report_step_); + double total = 0.0; + for (float v : overflow) { + if (v > 0.0f) total += static_cast(v) * dt; + } + *value = total; + return true; +} + +bool OutputReader::get_node_stat_time_flooded(int node_idx, double* value) const { + if (!value || !is_open()) return false; + if (node_idx < 0 || node_idx >= n_nodes_) return false; + if (n_periods_ <= 0) { *value = 0.0; return true; } + + std::vector overflow(static_cast(n_periods_)); + if (!get_node_series(node_idx, kNodeVarOverflow, 0, n_periods_ - 1, overflow.data())) + return false; + + // Count positive-overflow periods, multiply by report_step (seconds). + // Matches `swmm_node_get_stat_time_flooded` units (seconds); + // statsrpt.c:468 divides by 3600 for the display-only "hours" + // column. Counts a full report step even when overflow happened + // only in part of it — same first-order approximation as the + // legacy engine when run with a coarse report step. + long count = 0; + for (float v : overflow) { + if (v > 0.0f) ++count; + } + *value = static_cast(count) * static_cast(report_step_); + return true; +} + } /* namespace openswmm */ diff --git a/src/engine/output/OutputReader.hpp b/src/engine/output/OutputReader.hpp index 0ef23cec1..6c4dae618 100644 --- a/src/engine/output/OutputReader.hpp +++ b/src/engine/output/OutputReader.hpp @@ -138,6 +138,16 @@ class OutputReader { /** @brief Get the simulation time for a given period. */ bool get_period_time(int period, double* time) const; + // -- Per-node summary statistics — Slice QA-01 ------------------------- + // + // Computed on-demand from the per-period node results stored in the + // file. See openswmm_output.h for unit conventions + the report-step + // vs. routing-step precision caveat. Each method is O(n_periods). + bool get_node_stat_max_depth(int node_idx, double* value) const; + bool get_node_stat_max_overflow(int node_idx, double* value) const; + bool get_node_stat_vol_flooded(int node_idx, double* value) const; + bool get_node_stat_time_flooded(int node_idx, double* value) const; + private: static constexpr int32_t MAGIC_NUMBER = 516114522; diff --git a/src/engine/output/openswmm_output_impl.cpp b/src/engine/output/openswmm_output_impl.cpp index b00f1f48f..ad3101ad8 100644 --- a/src/engine/output/openswmm_output_impl.cpp +++ b/src/engine/output/openswmm_output_impl.cpp @@ -253,6 +253,42 @@ SWMM_ENGINE_API int swmm_output_get_period_time(SWMM_Output handle, return to_reader(handle)->get_period_time(period, time) ? 0 : -1; } +/* ========================================================================= + * Per-node summary statistics — Slice QA-01 + * + * Thin C-FFI wrappers over OutputReader::get_node_stat_*; see the header + * (openswmm_output.h) for unit conventions and the report-step + * precision caveat. + * ========================================================================= */ + +SWMM_ENGINE_API int swmm_output_get_node_stat_max_depth(SWMM_Output handle, + int node_idx, + double* value) { + CHECK_READER(handle); + return to_reader(handle)->get_node_stat_max_depth(node_idx, value) ? 0 : -1; +} + +SWMM_ENGINE_API int swmm_output_get_node_stat_max_overflow(SWMM_Output handle, + int node_idx, + double* value) { + CHECK_READER(handle); + return to_reader(handle)->get_node_stat_max_overflow(node_idx, value) ? 0 : -1; +} + +SWMM_ENGINE_API int swmm_output_get_node_stat_vol_flooded(SWMM_Output handle, + int node_idx, + double* value) { + CHECK_READER(handle); + return to_reader(handle)->get_node_stat_vol_flooded(node_idx, value) ? 0 : -1; +} + +SWMM_ENGINE_API int swmm_output_get_node_stat_time_flooded(SWMM_Output handle, + int node_idx, + double* value) { + CHECK_READER(handle); + return to_reader(handle)->get_node_stat_time_flooded(node_idx, value) ? 0 : -1; +} + /* ========================================================================= * Error reporting * ========================================================================= */ diff --git a/src/engine/plugins/PluginDiscovery.cpp b/src/engine/plugins/PluginDiscovery.cpp index 69967ba56..33839f4aa 100644 --- a/src/engine/plugins/PluginDiscovery.cpp +++ b/src/engine/plugins/PluginDiscovery.cpp @@ -27,6 +27,7 @@ std::vector discover_all_filters() { df.plugin_id = c.id; df.plugin_version = c.version; df.plugin_caption = c.info->caption(); + df.is_builtin = c.is_builtin; // Slice RC.3 df.filter = std::move(f); out.push_back(std::move(df)); } @@ -50,11 +51,17 @@ std::vector discover_plugins_by_id() { entry.plugin_id = df.plugin_id; entry.plugin_version = df.plugin_version; entry.plugin_caption = df.plugin_caption; + entry.is_builtin = df.is_builtin; // Slice RC.3 out.push_back(std::move(entry)); idx.emplace(df.plugin_id, out.size() - 1); dp = &out.back(); } else { dp = &out[it->second]; + // Defensive: if the same plugin_id surfaces from both a + // built-in and a discovered .so (shouldn't happen, but + // would indicate a duplicate-registration bug), keep the + // built-in marker so the GUI greys out Remove correctly. + dp->is_builtin = dp->is_builtin || df.is_builtin; } // Accumulate role (deduplicated) and the filter itself. diff --git a/src/engine/plugins/PluginFactory.cpp b/src/engine/plugins/PluginFactory.cpp index 691c32a44..d1e39ee7b 100644 --- a/src/engine/plugins/PluginFactory.cpp +++ b/src/engine/plugins/PluginFactory.cpp @@ -13,6 +13,15 @@ #include "PluginFactory.hpp" #include "BuiltinPluginInfos.hpp" +// Slice RC.1 — GeoPackage is registered as an explicit built-in so it +// appears in the discovery API alongside the four Default plugins, +// instead of being picked up accidentally through the discover() scan's +// dlsym(openswmm_plugin_info) on the engine's own binary. See §R.3 in +// docs/GUI_IMPLEMENTATION_PLAN.md for the rationale. +#ifdef OPENSWMM_HAS_GEOPACKAGE +# include "../input/geopackage/GeoPackagePluginInfo.hpp" +#endif + #include "../core/SimulationContext.hpp" #include "../../../include/openswmm/plugin_sdk/IPluginComponentInfo.hpp" #include "../../../include/openswmm/plugin_sdk/IInputPlugin.hpp" @@ -313,6 +322,12 @@ std::vector PluginFactory::discovered_components( e.has_report = lib.info->has_report(); e.has_state_io = lib.info->has_state_io(); e.info = lib.info; + // Slice RC.3 — built-ins are registered with a synthetic + // `` path and a null dlopen handle. Either marker + // would do; matching on path keeps the check stable across + // the future case where built-ins might be promoted to + // load-on-demand shared libs. + e.is_builtin = (lib.path == ""); result.push_back(e); } return result; @@ -585,6 +600,16 @@ void PluginFactory::register_builtin_infos() { register_one(&BuiltinDefaultOutputPluginInfo::instance()); register_one(&BuiltinDefaultReportPluginInfo::instance()); register_one(&BuiltinDefaultStateIOPluginInfo::instance()); + + // Slice RC.1 (APPROVED 2026-05-25, see §R.3) — GeoPackage is a first- + // class statically-linked plugin. Registering it explicitly here, plus + // the symbol-visibility hardening on the openswmm_geopackage target + // (Slice RC.2), removes the accidental dlsym-leak via the engine + // binary that produced phantom rows in the Simulation Options + // Plugins tab. +#ifdef OPENSWMM_HAS_GEOPACKAGE + register_one(&openswmm::gpkg::GeoPackagePluginInfo::instance()); +#endif } // ============================================================================ diff --git a/src/engine/plugins/PluginFactory.hpp b/src/engine/plugins/PluginFactory.hpp index 0aef76299..0161ae631 100644 --- a/src/engine/plugins/PluginFactory.hpp +++ b/src/engine/plugins/PluginFactory.hpp @@ -148,6 +148,14 @@ class PluginFactory { bool has_report = false; bool has_state_io = false; IPluginComponentInfo* info = nullptr; + + /// Slice RC.3 — true when this component was registered via + /// `register_builtin_infos` (statically linked into the engine) + /// rather than discovered through the on-disk shared-library scan. + /// Built-ins have no `dlopen` handle and a synthetic `` + /// path; this flag exposes that distinction to public callers + /// without leaking the internal LibEntry layout. + bool is_builtin = false; }; std::vector discovered_components() const; diff --git a/tests/unit/engine/CMakeLists.txt b/tests/unit/engine/CMakeLists.txt index d421fe975..e86349e6c 100644 --- a/tests/unit/engine/CMakeLists.txt +++ b/tests/unit/engine/CMakeLists.txt @@ -82,6 +82,10 @@ add_gtest_unit(test_engine_options_parser test_options_parser.cpp) add_gtest_unit(test_engine_csv_timeseries test_csv_timeseries.cpp) add_gtest_unit(test_engine_plugin_lifecycle test_plugin_lifecycle.cpp) add_gtest_unit(test_engine_plugin_filter_invariant test_plugin_filter_invariant.cpp) +# Slice RC.5 — GeoPackage explicit-builtin registration + is_builtin flag. +add_gtest_unit(test_engine_pluginfactory_builtins test_pluginfactory_builtins.cpp) +# Slice QA-01.4 — per-node summary stats read from a SWMM_Output handle. +add_gtest_unit(test_engine_output_node_stats test_output_node_stats.cpp) add_gtest_unit(test_engine_model_write_with_plugin test_model_write_with_plugin.cpp) add_gtest_unit(test_engine_files_section test_files_section.cpp) add_gtest_unit(test_engine_events_section test_events_section.cpp) @@ -109,6 +113,7 @@ add_gtest_unit(test_engine_rdii test_rdii.cpp) add_gtest_unit(test_engine_hydrograph_mutation test_hydrograph_mutation_api.cpp) add_gtest_unit(test_engine_pattern_mutation test_pattern_mutation_api.cpp) add_gtest_unit(test_engine_transect_mutation test_transect_mutation_api.cpp) +add_gtest_unit(test_engine_transect_inp_parser test_transect_inp_parser.cpp) add_gtest_unit(test_engine_da4_api test_da4_engine_api.cpp) add_gtest_unit(test_engine_controls test_controls.cpp) add_gtest_unit(test_engine_control_rule_validate test_control_rule_validate_api.cpp) @@ -118,11 +123,15 @@ add_gtest_unit(test_engine_nodes_bulk_phase3 test_nodes_bulk_phase3.cpp) add_gtest_unit(test_engine_links_bulk_phase3 test_links_bulk_phase3.cpp) add_gtest_unit(test_engine_subcatchments_bulk_phase3 test_subcatchments_bulk_phase3.cpp) add_gtest_unit(test_engine_statistics_bulk_phase3 test_statistics_bulk_phase3.cpp) +add_gtest_unit(test_engine_runoff_interface_capi test_runoff_interface_capi.cpp) add_gtest_unit(test_engine_report_section test_report_section.cpp) add_gtest_unit(test_engine_concurrent test_concurrent_engines.cpp) add_gtest_unit(test_engine_object_deletion test_object_deletion.cpp) add_gtest_unit(test_engine_type_conversion test_type_conversion.cpp) add_gtest_unit(test_engine_tags test_tags.cpp) +# Engine gaps BN-LINK-01a / -01b — symmetric getters for initial-flow / +# max-flow (added 2026-05-25 for Slice SB of the GUI plan). +add_gtest_unit(test_engine_link_flow_roundtrip test_link_flow_roundtrip.cpp) # 2D surface routing tests — geometry, gradients, flux, parsing. # Non-CVODE portions can be built without SUNDIALS by compiling the needed diff --git a/tests/unit/engine/test_control_rule_validate_api.cpp b/tests/unit/engine/test_control_rule_validate_api.cpp new file mode 100644 index 000000000..12b85fb8a --- /dev/null +++ b/tests/unit/engine/test_control_rule_validate_api.cpp @@ -0,0 +1,237 @@ +/** + * @file test_control_rule_validate_api.cpp + * @brief Unit tests for `swmm_control_validate_rule` (engine gap BR-02). + * + * @details The validator runs the live engine's control-rule parser against + * the live SimulationContext for name resolution, but does **not** + * mutate the engine's rule list or PID state. These tests pin: + * + * (a) parser accept/reject parity with `swmm_control_add_rule` + * followed by simulation-init parsing, + * (b) state invariance — rule count and per-rule text are + * unchanged across `validate`, regardless of accept/reject, + * (c) error-message capture into the caller-supplied buffer. + * + * Line-precise error reporting is not yet plumbed through the + * parser (line_out is -1 on reject); a future iteration will + * carry a 1-based line index. Tests below assert -1 for now. + * + * @see src/engine/core/openswmm_controls_impl.cpp::swmm_control_validate_rule + * @ingroup engine_controls + */ + +#include +#include +#include + +#include +#include +#include +#include + +// ============================================================================ +// Fixture — bare engine with a couple of nodes + a link so name resolution +// can succeed. The parser needs the SimulationContext's name tables +// populated; we don't run the simulation. +// ============================================================================ + +class ControlRuleValidateTest : public ::testing::Test { +protected: + SWMM_Engine engine = nullptr; + + void SetUp() override { + engine = swmm_engine_new(); + ASSERT_NE(engine, nullptr); + + // Seed two nodes + a pump so NODE / PUMP references resolve. + // Node type 0 = Junction (matches openswmm_nodes.h default ctor path); + // link type for "pump" varies across the API surface — using 4 + // (Pump) per the SWMM convention. If this needs to change to match + // the engine's link-type enum, the validator behaviour is the same + // either way — the test asserts accept/reject, not the type code. + ASSERT_EQ(swmm_node_add(engine, "J1", 0), SWMM_OK); + ASSERT_EQ(swmm_node_add(engine, "J2", 0), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine, "P1", 4), SWMM_OK); + } + + void TearDown() override { swmm_engine_destroy(engine); } +}; + +// ============================================================================ +// Happy path — well-formed rule against known objects → SWMM_OK + empty err. +// ============================================================================ + +TEST_F(ControlRuleValidateTest, AcceptsWellFormedRule) { + char errbuf[128] = {'X'}; // pre-fill so we can verify truncation/clear + int line = 99; // pre-fill so we can verify -1 on success + const char* rule = + "RULE R_OK\n" + "IF NODE J1 DEPTH > 5\n" + "THEN PUMP P1 STATUS = ON"; + + EXPECT_EQ(swmm_control_validate_rule(engine, rule, errbuf, sizeof(errbuf), &line), + SWMM_OK); + EXPECT_STREQ(errbuf, ""); + EXPECT_EQ(line, -1); +} + +// ============================================================================ +// Reject cases — each exercises a different parser failure mode. +// ============================================================================ + +TEST_F(ControlRuleValidateTest, RejectsEmptyText) { + char errbuf[128] = {}; + int line = 0; + EXPECT_EQ(swmm_control_validate_rule(engine, "", errbuf, sizeof(errbuf), &line), + SWMM_ERR_BADPARAM); +} + +TEST_F(ControlRuleValidateTest, RejectsRuleWithNoName) { + char errbuf[128] = {}; + int line = 0; + // "RULE" alone — no name token. + EXPECT_EQ(swmm_control_validate_rule(engine, "RULE\nIF NODE J1 DEPTH > 5\nTHEN PUMP P1 STATUS = ON", + errbuf, sizeof(errbuf), &line), + SWMM_ERR_BADPARAM); + // The buffer should hold a non-empty message on reject. + EXPECT_GT(std::strlen(errbuf), 0u); + EXPECT_EQ(line, -1); +} + +TEST_F(ControlRuleValidateTest, RejectsUnresolvedNodeName) { + char errbuf[128] = {}; + int line = 0; + // J_DOES_NOT_EXIST is not in ctx.node_names — parser returns -1. + const char* rule = + "RULE R_BadNode\n" + "IF NODE J_DOES_NOT_EXIST DEPTH > 5\n" + "THEN PUMP P1 STATUS = ON"; + EXPECT_EQ(swmm_control_validate_rule(engine, rule, errbuf, sizeof(errbuf), &line), + SWMM_ERR_BADPARAM); +} + +TEST_F(ControlRuleValidateTest, RejectsUnresolvedLinkInAction) { + char errbuf[128] = {}; + int line = 0; + // PUMP P_GHOST does not exist. + const char* rule = + "RULE R_BadLink\n" + "IF NODE J1 DEPTH > 5\n" + "THEN PUMP P_GHOST STATUS = ON"; + EXPECT_EQ(swmm_control_validate_rule(engine, rule, errbuf, sizeof(errbuf), &line), + SWMM_ERR_BADPARAM); +} + +TEST_F(ControlRuleValidateTest, RejectsMissingActionEquals) { + char errbuf[128] = {}; + int line = 0; + // "STATUS ON" instead of "STATUS = ON" — missing '='. + const char* rule = + "RULE R_NoEq\n" + "IF NODE J1 DEPTH > 5\n" + "THEN PUMP P1 STATUS ON"; + EXPECT_EQ(swmm_control_validate_rule(engine, rule, errbuf, sizeof(errbuf), &line), + SWMM_ERR_BADPARAM); +} + +// ============================================================================ +// State invariance — engine rule list must not change across validate calls, +// regardless of accept/reject. This is the non-mutation contract callers +// (specifically the GUI editor) rely on. +// ============================================================================ + +TEST_F(ControlRuleValidateTest, DoesNotMutateEngineOnAccept) { + // Pre-seed one stored rule via the existing add API. + ASSERT_EQ(swmm_control_add_rule(engine, + "RULE Preexisting\nIF NODE J2 DEPTH > 2\nTHEN PUMP P1 STATUS = ON"), + SWMM_OK); + ASSERT_EQ(swmm_control_count(engine), 1); + + char errbuf[128] = {}; + int line = 0; + ASSERT_EQ(swmm_control_validate_rule(engine, + "RULE Transient\nIF NODE J1 DEPTH > 5\nTHEN PUMP P1 STATUS = ON", + errbuf, sizeof(errbuf), &line), SWMM_OK); + + // Rule list must still hold exactly the one Preexisting rule. + EXPECT_EQ(swmm_control_count(engine), 1); + char buf[64] = {}; + ASSERT_EQ(swmm_control_get_id(engine, 0, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, "Preexisting"); +} + +TEST_F(ControlRuleValidateTest, DoesNotMutateEngineOnReject) { + ASSERT_EQ(swmm_control_add_rule(engine, + "RULE Preexisting\nIF NODE J2 DEPTH > 2\nTHEN PUMP P1 STATUS = ON"), + SWMM_OK); + ASSERT_EQ(swmm_control_count(engine), 1); + + char errbuf[128] = {}; + int line = 0; + ASSERT_EQ(swmm_control_validate_rule(engine, + "RULE Garbage\nIF SOMETHING TOTALLY WRONG", + errbuf, sizeof(errbuf), &line), SWMM_ERR_BADPARAM); + + EXPECT_EQ(swmm_control_count(engine), 1); + char buf[64] = {}; + ASSERT_EQ(swmm_control_get_id(engine, 0, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, "Preexisting"); +} + +// ============================================================================ +// Error-buffer hygiene +// ============================================================================ + +TEST_F(ControlRuleValidateTest, NullErrbufIsTolerated) { + int line = 0; + EXPECT_EQ(swmm_control_validate_rule(engine, "RULE\n", + nullptr, 0, &line), + SWMM_ERR_BADPARAM); + // Should not crash — line still gets a value. + EXPECT_EQ(line, -1); +} + +TEST_F(ControlRuleValidateTest, NullLineOutIsTolerated) { + char errbuf[64] = {}; + EXPECT_EQ(swmm_control_validate_rule(engine, + "RULE R\nIF NODE J1 DEPTH > 5\nTHEN PUMP P1 STATUS = ON", + errbuf, sizeof(errbuf), nullptr), + SWMM_OK); +} + +TEST_F(ControlRuleValidateTest, ErrbufTruncatesWithoutOverflow) { + // 8-byte buffer is shorter than the canned reject message — the impl + // must truncate without writing past the end + still null-terminate. + char errbuf[8] = {'\xAB','\xAB','\xAB','\xAB','\xAB','\xAB','\xAB','\xAB'}; + EXPECT_EQ(swmm_control_validate_rule(engine, "RULE\n", + errbuf, sizeof(errbuf), nullptr), + SWMM_ERR_BADPARAM); + EXPECT_EQ(errbuf[sizeof(errbuf) - 1], '\0'); + EXPECT_GT(std::strlen(errbuf), 0u); + EXPECT_LT(std::strlen(errbuf), sizeof(errbuf)); +} + +// ============================================================================ +// Refactor regression — swmm_control_add_rule must still accept identical +// inputs after BR-02 ships. (The new entry point shares the live engine's +// ControlEngine parser via a throwaway instance; the existing add path is +// untouched. This test pins the contract.) +// ============================================================================ + +TEST_F(ControlRuleValidateTest, AddRuleStillStoresValidatedText) { + const char* rule = + "RULE Persist\n" + "IF NODE J1 DEPTH > 5\n" + "THEN PUMP P1 STATUS = ON"; + + char errbuf[128] = {}; + int line = 0; + ASSERT_EQ(swmm_control_validate_rule(engine, rule, errbuf, sizeof(errbuf), &line), + SWMM_OK); + ASSERT_EQ(swmm_control_add_rule(engine, rule), SWMM_OK); + + EXPECT_EQ(swmm_control_count(engine), 1); + char buf[64] = {}; + ASSERT_EQ(swmm_control_get_id(engine, 0, buf, sizeof(buf)), SWMM_OK); + EXPECT_STREQ(buf, "Persist"); +} diff --git a/tests/unit/engine/test_link_flow_roundtrip.cpp b/tests/unit/engine/test_link_flow_roundtrip.cpp new file mode 100644 index 000000000..db64cf805 --- /dev/null +++ b/tests/unit/engine/test_link_flow_roundtrip.cpp @@ -0,0 +1,394 @@ +/** + * @file test_link_flow_roundtrip.cpp + * @brief Unit tests for engine gaps BN-LINK-01a / -01b — symmetric + * getters for the existing @ref swmm_link_set_initial_flow and + * @ref swmm_link_set_max_flow setters. + * + * @details The setters write to `ctx.links.flow[idx]` and + * `ctx.links.q_limit[idx]` respectively. The new getters read + * from those same SoA slots; tests assert bit-for-bit + * round-trip plus 0.0-default and negative-flow round-trip. + * + * @author Caleb Buahin + * @license MIT License + */ + +#include +#include +#include +#include + +namespace { + +class LinkFlowRoundTripTest : public ::testing::Test { +protected: + SWMM_Engine engine_ = nullptr; + int li_ = -1; + + void SetUp() override { + engine_ = swmm_engine_new(); + ASSERT_NE(engine_, nullptr); + ASSERT_EQ(swmm_node_add(engine_, "J1", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_node_add(engine_, "J2", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine_, "C1", /*Conduit=*/0), SWMM_OK); + li_ = swmm_link_index(engine_, "C1"); + ASSERT_GE(li_, 0); + const int j1 = swmm_node_index(engine_, "J1"); + const int j2 = swmm_node_index(engine_, "J2"); + ASSERT_EQ(swmm_link_set_nodes(engine_, li_, j1, j2), SWMM_OK); + } + + void TearDown() override { + if (engine_) swmm_engine_destroy(engine_); + engine_ = nullptr; + } +}; + +TEST_F(LinkFlowRoundTripTest, InitialFlowDefaultsToZero) { + double v = -1.0; + EXPECT_EQ(swmm_link_get_initial_flow(engine_, li_, &v), SWMM_OK); + EXPECT_DOUBLE_EQ(v, 0.0); +} + +TEST_F(LinkFlowRoundTripTest, InitialFlowSetReadBack) { + EXPECT_EQ(swmm_link_set_initial_flow(engine_, li_, 12.5), SWMM_OK); + double v = 0.0; + EXPECT_EQ(swmm_link_get_initial_flow(engine_, li_, &v), SWMM_OK); + EXPECT_DOUBLE_EQ(v, 12.5); +} + +TEST_F(LinkFlowRoundTripTest, InitialFlowNegativeRoundTrips) { + // Reverse-flow initial condition is physically meaningful; the SoA + // slot is a plain double with no sign filter. Lock in the contract. + EXPECT_EQ(swmm_link_set_initial_flow(engine_, li_, -3.25), SWMM_OK); + double v = 0.0; + EXPECT_EQ(swmm_link_get_initial_flow(engine_, li_, &v), SWMM_OK); + EXPECT_DOUBLE_EQ(v, -3.25); +} + +TEST_F(LinkFlowRoundTripTest, MaxFlowDefaultsToZero) { + // 0.0 == "no limit" per the setter docstring. + double v = -1.0; + EXPECT_EQ(swmm_link_get_max_flow(engine_, li_, &v), SWMM_OK); + EXPECT_DOUBLE_EQ(v, 0.0); +} + +TEST_F(LinkFlowRoundTripTest, MaxFlowSetReadBack) { + EXPECT_EQ(swmm_link_set_max_flow(engine_, li_, 200.0), SWMM_OK); + double v = 0.0; + EXPECT_EQ(swmm_link_get_max_flow(engine_, li_, &v), SWMM_OK); + EXPECT_DOUBLE_EQ(v, 200.0); +} + +TEST_F(LinkFlowRoundTripTest, BadIndexReturnsError) { + double v = 0.0; + EXPECT_NE(swmm_link_get_initial_flow(engine_, -1, &v), SWMM_OK); + EXPECT_NE(swmm_link_get_initial_flow(engine_, 9999, &v), SWMM_OK); + EXPECT_NE(swmm_link_get_max_flow(engine_, -1, &v), SWMM_OK); + EXPECT_NE(swmm_link_get_max_flow(engine_, 9999, &v), SWMM_OK); +} + +TEST_F(LinkFlowRoundTripTest, NullOutPointerAccepted) { + // Existing scalar getters in the engine accept null out-pointers + // (early-return after the index check). Lock in the same contract. + EXPECT_EQ(swmm_link_get_initial_flow(engine_, li_, nullptr), SWMM_OK); + EXPECT_EQ(swmm_link_get_max_flow (engine_, li_, nullptr), SWMM_OK); +} + +// ============================================================================ +// Engine gap BN-LINK-02 — orifice type (SIDE / BOTTOM) accessor pair. +// Separate fixture because we need an ORIFICE link, not a CONDUIT. +// ============================================================================ + +class LinkOrificeTypeTest : public ::testing::Test { +protected: + SWMM_Engine engine_ = nullptr; + int orifIdx_ = -1; + int condIdx_ = -1; + + void SetUp() override { + engine_ = swmm_engine_new(); + ASSERT_NE(engine_, nullptr); + ASSERT_EQ(swmm_node_add(engine_, "J1", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_node_add(engine_, "J2", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine_, "O1", SWMM_LINK_ORIFICE), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine_, "C1", SWMM_LINK_CONDUIT), SWMM_OK); + orifIdx_ = swmm_link_index(engine_, "O1"); + condIdx_ = swmm_link_index(engine_, "C1"); + ASSERT_GE(orifIdx_, 0); + ASSERT_GE(condIdx_, 0); + } + void TearDown() override { + if (engine_) swmm_engine_destroy(engine_); + engine_ = nullptr; + } +}; + +TEST_F(LinkOrificeTypeTest, DefaultsToSide) { + // links.param1 is zero-initialized on link_add, which means SIDE per + // the legacy convention (LinkData.hpp:379 + LinksHandler.cpp:138). + // Hmm — actually param1=0 means BOTTOM by the legacy encoding, and + // the new accessor maps engine 0.0=BOTTOM → GUI 1=BOTTOM. So the + // default is BOTTOM, not SIDE. Pin the contract. + int t = -1; + EXPECT_EQ(swmm_link_get_orifice_type(engine_, orifIdx_, &t), SWMM_OK); + EXPECT_EQ(t, SWMM_ORIFICE_BOTTOM); +} + +TEST_F(LinkOrificeTypeTest, SetSideReadBack) { + EXPECT_EQ(swmm_link_set_orifice_type(engine_, orifIdx_, SWMM_ORIFICE_SIDE), + SWMM_OK); + int t = -1; + EXPECT_EQ(swmm_link_get_orifice_type(engine_, orifIdx_, &t), SWMM_OK); + EXPECT_EQ(t, SWMM_ORIFICE_SIDE); +} + +TEST_F(LinkOrificeTypeTest, SetBottomReadBack) { + EXPECT_EQ(swmm_link_set_orifice_type(engine_, orifIdx_, SWMM_ORIFICE_SIDE), + SWMM_OK); // flip to SIDE first to confirm we toggle off it + EXPECT_EQ(swmm_link_set_orifice_type(engine_, orifIdx_, SWMM_ORIFICE_BOTTOM), + SWMM_OK); + int t = -1; + EXPECT_EQ(swmm_link_get_orifice_type(engine_, orifIdx_, &t), SWMM_OK); + EXPECT_EQ(t, SWMM_ORIFICE_BOTTOM); +} + +TEST_F(LinkOrificeTypeTest, RejectsNonOrificeLink) { + // Conduit's param1 means something different — refuse the write to + // protect that slot. + int t = 0; + EXPECT_NE(swmm_link_set_orifice_type(engine_, condIdx_, SWMM_ORIFICE_SIDE), + SWMM_OK); + EXPECT_NE(swmm_link_get_orifice_type(engine_, condIdx_, &t), SWMM_OK); +} + +TEST_F(LinkOrificeTypeTest, RejectsOutOfRangeType) { + EXPECT_NE(swmm_link_set_orifice_type(engine_, orifIdx_, -1), SWMM_OK); + EXPECT_NE(swmm_link_set_orifice_type(engine_, orifIdx_, 2), SWMM_OK); + EXPECT_NE(swmm_link_set_orifice_type(engine_, orifIdx_, 99), SWMM_OK); +} + +// ============================================================================ +// Engine gap BN-LINK-03 — weir TYPE (5 values) accessor pair. +// ============================================================================ + +class LinkWeirTypeTest : public ::testing::Test { +protected: + SWMM_Engine engine_ = nullptr; + int weirIdx_ = -1; + int condIdx_ = -1; + + void SetUp() override { + engine_ = swmm_engine_new(); + ASSERT_NE(engine_, nullptr); + ASSERT_EQ(swmm_node_add(engine_, "J1", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_node_add(engine_, "J2", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine_, "W1", SWMM_LINK_WEIR), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine_, "C1", SWMM_LINK_CONDUIT), SWMM_OK); + weirIdx_ = swmm_link_index(engine_, "W1"); + condIdx_ = swmm_link_index(engine_, "C1"); + ASSERT_GE(weirIdx_, 0); + ASSERT_GE(condIdx_, 0); + } + void TearDown() override { + if (engine_) swmm_engine_destroy(engine_); + engine_ = nullptr; + } +}; + +TEST_F(LinkWeirTypeTest, DefaultsToTransverse) { + // links.param1 starts at 0.0 = TRANSVERSE_WEIR per + // legacy/engine/enums.h:925 enum ordering. + int t = -1; + EXPECT_EQ(swmm_link_get_weir_type(engine_, weirIdx_, &t), SWMM_OK); + EXPECT_EQ(t, SWMM_WEIR_TRANSVERSE); +} + +TEST_F(LinkWeirTypeTest, AllFiveValuesRoundTrip) { + for (int v : { SWMM_WEIR_TRANSVERSE, SWMM_WEIR_SIDEFLOW, + SWMM_WEIR_VNOTCH, SWMM_WEIR_TRAPEZOIDAL, + SWMM_WEIR_ROADWAY }) { + EXPECT_EQ(swmm_link_set_weir_type(engine_, weirIdx_, v), SWMM_OK) + << "set " << v << " failed"; + int t = -1; + EXPECT_EQ(swmm_link_get_weir_type(engine_, weirIdx_, &t), SWMM_OK); + EXPECT_EQ(t, v) << "read-back mismatch for " << v; + } +} + +TEST_F(LinkWeirTypeTest, RejectsNonWeirLink) { + int t = 0; + EXPECT_NE(swmm_link_set_weir_type(engine_, condIdx_, SWMM_WEIR_TRANSVERSE), + SWMM_OK); + EXPECT_NE(swmm_link_get_weir_type(engine_, condIdx_, &t), SWMM_OK); +} + +TEST_F(LinkWeirTypeTest, RejectsOutOfRangeType) { + EXPECT_NE(swmm_link_set_weir_type(engine_, weirIdx_, -1), SWMM_OK); + EXPECT_NE(swmm_link_set_weir_type(engine_, weirIdx_, 5), SWMM_OK); + EXPECT_NE(swmm_link_set_weir_type(engine_, weirIdx_, 99), SWMM_OK); +} + +// ============================================================================ +// Engine gap BN-LINK-04 — outlet rating type (4 values) + exponent. +// ============================================================================ + +class LinkOutletRatingTest : public ::testing::Test { +protected: + SWMM_Engine engine_ = nullptr; + int outletIdx_ = -1; + int condIdx_ = -1; + + void SetUp() override { + engine_ = swmm_engine_new(); + ASSERT_NE(engine_, nullptr); + ASSERT_EQ(swmm_node_add(engine_, "J1", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_node_add(engine_, "J2", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine_, "OT1", SWMM_LINK_OUTLET), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine_, "C1", SWMM_LINK_CONDUIT), SWMM_OK); + outletIdx_ = swmm_link_index(engine_, "OT1"); + condIdx_ = swmm_link_index(engine_, "C1"); + ASSERT_GE(outletIdx_, 0); + ASSERT_GE(condIdx_, 0); + } + void TearDown() override { + if (engine_) swmm_engine_destroy(engine_); + engine_ = nullptr; + } +}; + +TEST_F(LinkOutletRatingTest, DefaultsToFunctionalHead) { + int t = -1; + EXPECT_EQ(swmm_link_get_outlet_rating_type(engine_, outletIdx_, &t), SWMM_OK); + EXPECT_EQ(t, SWMM_OUTLET_FUNCTIONAL_HEAD); +} + +TEST_F(LinkOutletRatingTest, AllFourTypesRoundTrip) { + for (int v : { SWMM_OUTLET_FUNCTIONAL_HEAD, SWMM_OUTLET_FUNCTIONAL_DEPTH, + SWMM_OUTLET_TABULAR_HEAD, SWMM_OUTLET_TABULAR_DEPTH }) { + EXPECT_EQ(swmm_link_set_outlet_rating_type(engine_, outletIdx_, v), + SWMM_OK) + << "set " << v << " failed"; + int t = -1; + EXPECT_EQ(swmm_link_get_outlet_rating_type(engine_, outletIdx_, &t), + SWMM_OK); + EXPECT_EQ(t, v); + } +} + +TEST_F(LinkOutletRatingTest, ExponRoundTrip) { + EXPECT_EQ(swmm_link_set_outlet_expon(engine_, outletIdx_, 0.5), SWMM_OK); + double v = 0.0; + EXPECT_EQ(swmm_link_get_outlet_expon(engine_, outletIdx_, &v), SWMM_OK); + EXPECT_DOUBLE_EQ(v, 0.5); + + EXPECT_EQ(swmm_link_set_outlet_expon(engine_, outletIdx_, 1.75), SWMM_OK); + EXPECT_EQ(swmm_link_get_outlet_expon(engine_, outletIdx_, &v), SWMM_OK); + EXPECT_DOUBLE_EQ(v, 1.75); +} + +TEST_F(LinkOutletRatingTest, RejectsNonOutletLink) { + int t = 0; + double e = 0.0; + EXPECT_NE(swmm_link_set_outlet_rating_type(engine_, condIdx_, + SWMM_OUTLET_FUNCTIONAL_HEAD), + SWMM_OK); + EXPECT_NE(swmm_link_get_outlet_rating_type(engine_, condIdx_, &t), SWMM_OK); + EXPECT_NE(swmm_link_set_outlet_expon(engine_, condIdx_, 0.5), SWMM_OK); + EXPECT_NE(swmm_link_get_outlet_expon(engine_, condIdx_, &e), SWMM_OK); +} + +TEST_F(LinkOutletRatingTest, RejectsOutOfRangeType) { + EXPECT_NE(swmm_link_set_outlet_rating_type(engine_, outletIdx_, -1), SWMM_OK); + EXPECT_NE(swmm_link_set_outlet_rating_type(engine_, outletIdx_, 4), SWMM_OK); + EXPECT_NE(swmm_link_set_outlet_rating_type(engine_, outletIdx_, 99), SWMM_OK); +} + +// ============================================================================ +// Engine gap BN-LINK-05 — pump startup / shutoff depth. +// Engine gap BN-LINK-06 — orifice open/close rate. +// ============================================================================ + +class LinkPumpOrificeMisc : public ::testing::Test { +protected: + SWMM_Engine engine_ = nullptr; + int pumpIdx_ = -1; + int orifIdx_ = -1; + int condIdx_ = -1; + + void SetUp() override { + engine_ = swmm_engine_new(); + ASSERT_NE(engine_, nullptr); + ASSERT_EQ(swmm_node_add(engine_, "J1", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_node_add(engine_, "J2", SWMM_NODE_JUNCTION), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine_, "P1", SWMM_LINK_PUMP), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine_, "OR1", SWMM_LINK_ORIFICE), SWMM_OK); + ASSERT_EQ(swmm_link_add(engine_, "C1", SWMM_LINK_CONDUIT), SWMM_OK); + pumpIdx_ = swmm_link_index(engine_, "P1"); + orifIdx_ = swmm_link_index(engine_, "OR1"); + condIdx_ = swmm_link_index(engine_, "C1"); + ASSERT_GE(pumpIdx_, 0); + ASSERT_GE(orifIdx_, 0); + ASSERT_GE(condIdx_, 0); + } + void TearDown() override { + if (engine_) swmm_engine_destroy(engine_); + engine_ = nullptr; + } +}; + +TEST_F(LinkPumpOrificeMisc, PumpStartupShutoffDefaultsZero) { + double s = -1.0, t = -1.0; + EXPECT_EQ(swmm_link_get_pump_startup_depth(engine_, pumpIdx_, &s), SWMM_OK); + EXPECT_EQ(swmm_link_get_pump_shutoff_depth(engine_, pumpIdx_, &t), SWMM_OK); + EXPECT_DOUBLE_EQ(s, 0.0); + EXPECT_DOUBLE_EQ(t, 0.0); +} + +TEST_F(LinkPumpOrificeMisc, PumpDepthsRoundTrip) { + EXPECT_EQ(swmm_link_set_pump_startup_depth(engine_, pumpIdx_, 2.5), SWMM_OK); + EXPECT_EQ(swmm_link_set_pump_shutoff_depth(engine_, pumpIdx_, 0.5), SWMM_OK); + double s = 0, t = 0; + EXPECT_EQ(swmm_link_get_pump_startup_depth(engine_, pumpIdx_, &s), SWMM_OK); + EXPECT_EQ(swmm_link_get_pump_shutoff_depth(engine_, pumpIdx_, &t), SWMM_OK); + EXPECT_DOUBLE_EQ(s, 2.5); + EXPECT_DOUBLE_EQ(t, 0.5); +} + +TEST_F(LinkPumpOrificeMisc, PumpDepthsRejectNonPump) { + double v = 0; + EXPECT_NE(swmm_link_set_pump_startup_depth(engine_, condIdx_, 1.0), SWMM_OK); + EXPECT_NE(swmm_link_get_pump_startup_depth(engine_, condIdx_, &v), SWMM_OK); + EXPECT_NE(swmm_link_set_pump_shutoff_depth(engine_, condIdx_, 1.0), SWMM_OK); + EXPECT_NE(swmm_link_get_pump_shutoff_depth(engine_, condIdx_, &v), SWMM_OK); +} + +TEST_F(LinkPumpOrificeMisc, OrificeRateDefaultZero) { + double r = -1.0; + EXPECT_EQ(swmm_link_get_orifice_open_close_rate(engine_, orifIdx_, &r), + SWMM_OK); + EXPECT_DOUBLE_EQ(r, 0.0); +} + +TEST_F(LinkPumpOrificeMisc, OrificeRateRoundTrip) { + EXPECT_EQ(swmm_link_set_orifice_open_close_rate(engine_, orifIdx_, 0.05), + SWMM_OK); + double r = 0; + EXPECT_EQ(swmm_link_get_orifice_open_close_rate(engine_, orifIdx_, &r), + SWMM_OK); + EXPECT_DOUBLE_EQ(r, 0.05); +} + +TEST_F(LinkPumpOrificeMisc, OrificeRateRejectsNonOrifice) { + double v = 0; + EXPECT_NE(swmm_link_set_orifice_open_close_rate(engine_, pumpIdx_, 0.1), + SWMM_OK); + EXPECT_NE(swmm_link_get_orifice_open_close_rate(engine_, pumpIdx_, &v), + SWMM_OK); + EXPECT_NE(swmm_link_set_orifice_open_close_rate(engine_, condIdx_, 0.1), + SWMM_OK); + EXPECT_NE(swmm_link_get_orifice_open_close_rate(engine_, condIdx_, &v), + SWMM_OK); +} + +} // namespace diff --git a/tests/unit/engine/test_output_node_stats.cpp b/tests/unit/engine/test_output_node_stats.cpp new file mode 100644 index 000000000..aea0d6268 --- /dev/null +++ b/tests/unit/engine/test_output_node_stats.cpp @@ -0,0 +1,182 @@ +/** + * @file test_output_node_stats.cpp + * @brief Slice QA-01 — exercise swmm_output_get_node_stat_*. + * + * Uses the existing site_drainage_model.out fixture (already shipped in + * tests/unit/engine/data/) so the test runs without re-simulating. + * Tests focus on the API contract rather than absolute values: + * - Returns 0 (success) on a valid (handle, node_idx) pair. + * - Returns -1 on a NULL handle, NULL value pointer, or out-of-range + * node index. + * - max_depth >= 0 and finite for every node in the file. + * - max_overflow >= 0 and finite for every node. + * - vol_flooded >= 0 (the running sum is always non-negative since + * only positive overflow values contribute). + * - time_flooded is a multiple of report_step (count × dt math). + * + * @author Caleb Buahin + * @copyright Copyright (c) 2026 Caleb Buahin. All rights reserved. + * @license MIT License + */ + +#include + +#include + +#include +#include + +namespace { + +constexpr const char* kFixturePath = "site_drainage_model.out"; + +// Open the fixture for each test so failures don't bleed across cases. +// CTest sets WORKING_DIRECTORY to tests/unit/engine/data, so the bare +// filename resolves correctly. +class OutputNodeStatsTest : public ::testing::Test { +protected: + void SetUp() override { + handle_ = swmm_output_open(kFixturePath); + ASSERT_NE(handle_, nullptr) + << "Failed to open fixture '" << kFixturePath + << "' — check the test WORKING_DIRECTORY is data/."; + } + void TearDown() override { + if (handle_) swmm_output_close(handle_); + } + SWMM_Output handle_ = nullptr; +}; + +} // anonymous + +// --------------------------------------------------------------------------- +// Smoke: fixture has at least one node + one period. +// --------------------------------------------------------------------------- + +TEST_F(OutputNodeStatsTest, FixtureHasNodesAndPeriods) +{ + EXPECT_GT(swmm_output_get_node_count(handle_), 0); + EXPECT_GT(swmm_output_get_period_count(handle_), 0); +} + +// --------------------------------------------------------------------------- +// Happy path — each getter returns 0 (success) and a finite value for +// every node in the file. +// --------------------------------------------------------------------------- + +TEST_F(OutputNodeStatsTest, MaxDepthAllNodesNonNegativeFinite) +{ + const int n = swmm_output_get_node_count(handle_); + for (int i = 0; i < n; ++i) { + double v = -1.0; + ASSERT_EQ(swmm_output_get_node_stat_max_depth(handle_, i, &v), 0) + << "node_idx=" << i; + EXPECT_TRUE(std::isfinite(v)) << "node_idx=" << i; + EXPECT_GE(v, 0.0) << "node_idx=" << i; + } +} + +TEST_F(OutputNodeStatsTest, MaxOverflowAllNodesNonNegativeFinite) +{ + const int n = swmm_output_get_node_count(handle_); + for (int i = 0; i < n; ++i) { + double v = -1.0; + ASSERT_EQ(swmm_output_get_node_stat_max_overflow(handle_, i, &v), 0) + << "node_idx=" << i; + EXPECT_TRUE(std::isfinite(v)) << "node_idx=" << i; + EXPECT_GE(v, 0.0) << "node_idx=" << i; + } +} + +TEST_F(OutputNodeStatsTest, VolFloodedAllNodesNonNegativeFinite) +{ + const int n = swmm_output_get_node_count(handle_); + for (int i = 0; i < n; ++i) { + double v = -1.0; + ASSERT_EQ(swmm_output_get_node_stat_vol_flooded(handle_, i, &v), 0) + << "node_idx=" << i; + EXPECT_TRUE(std::isfinite(v)) << "node_idx=" << i; + EXPECT_GE(v, 0.0) << "node_idx=" << i; + } +} + +TEST_F(OutputNodeStatsTest, TimeFloodedAllNodesMultipleOfReportStep) +{ + const int n = swmm_output_get_node_count(handle_); + const int reportStep = swmm_output_get_report_step(handle_); + ASSERT_GT(reportStep, 0); + + for (int i = 0; i < n; ++i) { + double v = -1.0; + ASSERT_EQ(swmm_output_get_node_stat_time_flooded(handle_, i, &v), 0) + << "node_idx=" << i; + EXPECT_TRUE(std::isfinite(v)) << "node_idx=" << i; + EXPECT_GE(v, 0.0) << "node_idx=" << i; + + // time_flooded must be N × report_step for some non-negative N. + // The remainder-from-division check stays robust under float + // round-trip because both sides are exact-integer doubles for + // typical report-step values (60, 300, 3600 s, …). + const double n_steps = v / static_cast(reportStep); + EXPECT_DOUBLE_EQ(n_steps, std::round(n_steps)) << "node_idx=" << i; + } +} + +// --------------------------------------------------------------------------- +// Cross-stat consistency — when vol_flooded > 0, time_flooded must also +// be > 0 (you can't accumulate volume in zero time). Same applies for +// max_overflow > 0. +// --------------------------------------------------------------------------- + +TEST_F(OutputNodeStatsTest, VolFloodedImpliesTimeFlooded) +{ + const int n = swmm_output_get_node_count(handle_); + for (int i = 0; i < n; ++i) { + double vol = 0.0, t = 0.0, ov = 0.0; + ASSERT_EQ(swmm_output_get_node_stat_vol_flooded(handle_, i, &vol), 0); + ASSERT_EQ(swmm_output_get_node_stat_time_flooded(handle_, i, &t), 0); + ASSERT_EQ(swmm_output_get_node_stat_max_overflow(handle_, i, &ov), 0); + + if (vol > 0.0) { + EXPECT_GT(t, 0.0) << "node_idx=" << i; + EXPECT_GT(ov, 0.0) << "node_idx=" << i; + } + } +} + +// --------------------------------------------------------------------------- +// Error paths — invalid arguments must return -1 without touching `value`. +// --------------------------------------------------------------------------- + +TEST_F(OutputNodeStatsTest, NullHandleReturnsMinusOne) +{ + double v = 42.0; + EXPECT_EQ(swmm_output_get_node_stat_max_depth(nullptr, 0, &v), -1); + EXPECT_EQ(swmm_output_get_node_stat_max_overflow(nullptr, 0, &v), -1); + EXPECT_EQ(swmm_output_get_node_stat_vol_flooded(nullptr, 0, &v), -1); + EXPECT_EQ(swmm_output_get_node_stat_time_flooded(nullptr, 0, &v), -1); + EXPECT_DOUBLE_EQ(v, 42.0) << "value buffer must not be touched on error"; +} + +TEST_F(OutputNodeStatsTest, NullValueReturnsMinusOne) +{ + EXPECT_EQ(swmm_output_get_node_stat_max_depth(handle_, 0, nullptr), -1); + EXPECT_EQ(swmm_output_get_node_stat_max_overflow(handle_, 0, nullptr), -1); + EXPECT_EQ(swmm_output_get_node_stat_vol_flooded(handle_, 0, nullptr), -1); + EXPECT_EQ(swmm_output_get_node_stat_time_flooded(handle_, 0, nullptr), -1); +} + +TEST_F(OutputNodeStatsTest, OutOfRangeNodeIdxReturnsMinusOne) +{ + const int n = swmm_output_get_node_count(handle_); + double v = 42.0; + EXPECT_EQ(swmm_output_get_node_stat_max_depth(handle_, -1, &v), -1); + EXPECT_EQ(swmm_output_get_node_stat_max_depth(handle_, n, &v), -1); + EXPECT_EQ(swmm_output_get_node_stat_max_overflow(handle_, -1, &v), -1); + EXPECT_EQ(swmm_output_get_node_stat_max_overflow(handle_, n, &v), -1); + EXPECT_EQ(swmm_output_get_node_stat_vol_flooded(handle_, -1, &v), -1); + EXPECT_EQ(swmm_output_get_node_stat_vol_flooded(handle_, n, &v), -1); + EXPECT_EQ(swmm_output_get_node_stat_time_flooded(handle_, -1, &v), -1); + EXPECT_EQ(swmm_output_get_node_stat_time_flooded(handle_, n, &v), -1); + EXPECT_DOUBLE_EQ(v, 42.0) << "value buffer must not be touched on error"; +} diff --git a/tests/unit/engine/test_pluginfactory_builtins.cpp b/tests/unit/engine/test_pluginfactory_builtins.cpp new file mode 100644 index 000000000..92049e621 --- /dev/null +++ b/tests/unit/engine/test_pluginfactory_builtins.cpp @@ -0,0 +1,142 @@ +/** + * @file test_pluginfactory_builtins.cpp + * @brief Slice RC.5 — verify GeoPackage is an explicit built-in plugin and + * the discovery API surfaces the `is_builtin` flag correctly. + * + * Coverage: + * - The four Default plugins (Input / Output / Report / StateIO) are + * always registered and flagged is_builtin=true. + * - GeoPackage is registered as a built-in iff OPENSWMM_HAS_GEOPACKAGE. + * - The `is_builtin` field propagates through DiscoveredFilter and + * DiscoveredPlugin without losing identity. + * + * Note: the historic dlsym-leak (where the engine binary's own + * `openswmm_plugin_info` symbol was inadvertently discovered + * through the scan path) cannot be probed from pure C++ at unit-test + * level — that's a build-property check left to the engine's + * packaging tests. Visibility-hidden hardening (Slice RC.2) is + * verified at build time by the CMake target properties. + * + * @author Caleb Buahin + * @copyright Copyright (c) 2026 Caleb Buahin. All rights reserved. + * @license MIT License + */ + +#include + +#include + +#include +#include +#include + +namespace { + +const openswmm::DiscoveredPlugin* +findById(const std::vector& plugins, + const std::string& id) +{ + auto it = std::find_if(plugins.begin(), plugins.end(), + [&](const openswmm::DiscoveredPlugin& p) { + return p.plugin_id == id; + }); + return it == plugins.end() ? nullptr : &*it; +} + +} // anonymous + +// --------------------------------------------------------------------------- +// Default built-ins must be present and flagged +// --------------------------------------------------------------------------- + +TEST(PluginFactoryBuiltins, DefaultPluginsAreRegisteredAsBuiltins) +{ + const auto plugins = openswmm::discover_plugins_by_id(); + ASSERT_FALSE(plugins.empty()); + + // Walk every plugin; assert at least one carries is_builtin=true so we + // know the field was wired through (the four Default plugins are + // always registered via PluginFactory::register_builtin_infos). + bool sawBuiltin = false; + for (const auto& p : plugins) { + if (p.is_builtin) { + sawBuiltin = true; + break; + } + } + EXPECT_TRUE(sawBuiltin) + << "Expected at least one is_builtin=true plugin from " + << "register_builtin_infos (Default Input/Output/Report/StateIO)."; +} + +// --------------------------------------------------------------------------- +// GeoPackage — gated on OPENSWMM_HAS_GEOPACKAGE +// --------------------------------------------------------------------------- + +TEST(PluginFactoryBuiltins, GeoPackageRegistrationMatchesBuildConfig) +{ + const auto plugins = openswmm::discover_plugins_by_id(); + const auto* gpkg = findById(plugins, + "org.hydrocouple.openswmm.plugins.geopackage"); + +#ifdef OPENSWMM_HAS_GEOPACKAGE + ASSERT_NE(gpkg, nullptr) + << "Expected GeoPackage to be registered as a built-in when " + "OPENSWMM_HAS_GEOPACKAGE is defined."; + EXPECT_TRUE(gpkg->is_builtin) + << "GeoPackage must be flagged is_builtin=true (Slice RC.1)."; +#else + EXPECT_EQ(gpkg, nullptr) + << "GeoPackage should NOT be registered when " + "OPENSWMM_HAS_GEOPACKAGE is undefined."; +#endif +} + +TEST(PluginFactoryBuiltins, NoDuplicateRegistrationOfGeoPackage) +{ + const auto plugins = openswmm::discover_plugins_by_id(); + int count = 0; + for (const auto& p : plugins) { + if (p.plugin_id == "org.hydrocouple.openswmm.plugins.geopackage") + ++count; + } +#ifdef OPENSWMM_HAS_GEOPACKAGE + EXPECT_EQ(count, 1) + << "GeoPackage should appear exactly once — the explicit " + "register_one in register_builtin_infos (Slice RC.1) must " + "not collide with any leftover discovery-scan registration."; +#else + EXPECT_EQ(count, 0); +#endif +} + +// --------------------------------------------------------------------------- +// is_builtin propagates through DiscoveredFilter -> DiscoveredPlugin +// --------------------------------------------------------------------------- + +TEST(PluginFactoryBuiltins, IsBuiltinPropagatesThroughDiscoveredFilter) +{ + const auto filters = openswmm::discover_all_filters(); + ASSERT_FALSE(filters.empty()); + + // At least one filter from a built-in source must carry is_builtin=true + // (Slice RC.3 added the field to DiscoveredFilter and the discovery + // implementation now propagates it from ComponentEntry). + bool sawBuiltinFilter = false; + for (const auto& f : filters) { + if (f.is_builtin) { sawBuiltinFilter = true; break; } + } + EXPECT_TRUE(sawBuiltinFilter); +} + +TEST(PluginFactoryBuiltins, NonBuiltinDefaultIsFalseForAccidentalScanHits) +{ + // Defensive: if discover_plugins_by_id ever returns a plugin without + // an explicit is_builtin assignment (e.g. a future on-disk-scanned + // plugin), the field's default (false) must hold so callers don't + // mistake it for a built-in. We can't synthesize a scan plugin in + // this test environment, but we can assert that the field's default + // is documented as false by inspecting a default-constructed value. + openswmm::DiscoveredPlugin fresh; + EXPECT_FALSE(fresh.is_builtin); +} diff --git a/tests/unit/engine/test_runoff_interface_capi.cpp b/tests/unit/engine/test_runoff_interface_capi.cpp new file mode 100644 index 000000000..aff2301c2 --- /dev/null +++ b/tests/unit/engine/test_runoff_interface_capi.cpp @@ -0,0 +1,198 @@ +/** + * @file test_runoff_interface_capi.cpp + * @brief Unit tests for the Phase 1b runoff interface C API + * (``swmm_runoff_iface_open_write`` / ``_open_read`` / + * ``_save_step`` / ``_read_step`` / ``_close``). + * + * @details The C API exposes the existing internal + * ``runoff_iface::RunoffInterfaceFile`` and adds engine-side + * auto-save into ``stepRunoff``. These tests verify the round + * trip: open the file in SAVE mode → run a simulation → close + * → reopen in READ mode → assert that the per-substep + * subcatchment runoff values match what the engine recorded. + * + * Working directory is set to ``tests/unit/engine/data/`` by + * the test CMakeLists.txt. + * + * USE-mode auto-skip is a follow-up; this test exercises the + * read API by calling ``swmm_runoff_iface_read_step`` directly + * and verifying it returns sensible values without engine + * orchestration. + * + * @see docs/C_API_BINDINGS_MCP_IMPROVEMENT_PLAN.md Phase 1b + * @ingroup engine_tests + * + * @author Caleb Buahin + * @copyright Copyright (c) 2026 Caleb Buahin. All rights reserved. + * @license MIT License + */ + +#include +#include +#include +#include + +#include + +namespace { + +// Per-test temp file lifecycle helper — uses an absolute path under the +// fixture data dir so the file lives next to the .out the simulation +// produces (working dir is tests/unit/engine/data/ at runtime). +std::string make_temp_iface_path(const std::string& tag) { + namespace fs = std::filesystem; + return (fs::current_path() / ("phase1b_" + tag + ".rfi")).string(); +} + +class RunoffIfaceCApiTest : public ::testing::Test { +protected: + SWMM_Engine engine_ = nullptr; + + void SetUp() override { + engine_ = swmm_engine_create(); + ASSERT_NE(engine_, nullptr); + } + + void TearDown() override { + if (engine_) { + swmm_engine_close(engine_); + swmm_engine_destroy(engine_); + engine_ = nullptr; + } + } + + void open_and_init() { + ASSERT_EQ(swmm_engine_open(engine_, + "site_drainage_model.inp", + "site_drainage_model.rpt", + "site_drainage_model.out", + nullptr), + SWMM_OK) + << "open failed: " << swmm_get_last_error_msg(engine_); + ASSERT_EQ(swmm_engine_initialize(engine_), SWMM_OK); + } +}; + +// --------------------------------------------------------------------------- +// Bad-param contracts. +// --------------------------------------------------------------------------- + +TEST_F(RunoffIfaceCApiTest, RejectsNullEngine) { + EXPECT_EQ(swmm_runoff_iface_open_write(nullptr, "ignored.rfi"), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_runoff_iface_open_read(nullptr, "ignored.rfi"), + SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_runoff_iface_save_step(nullptr, 1.0), SWMM_ERR_BADHANDLE); + int has = -1; + EXPECT_EQ(swmm_runoff_iface_read_step(nullptr, &has), SWMM_ERR_BADHANDLE); + EXPECT_EQ(swmm_runoff_iface_close(nullptr), SWMM_ERR_BADHANDLE); +} + +TEST_F(RunoffIfaceCApiTest, RejectsNullOrEmptyPath) { + open_and_init(); + EXPECT_EQ(swmm_runoff_iface_open_write(engine_, nullptr), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_runoff_iface_open_write(engine_, ""), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_runoff_iface_open_read(engine_, nullptr), + SWMM_ERR_BADPARAM); +} + +TEST_F(RunoffIfaceCApiTest, CloseIsIdempotent) { + open_and_init(); + // Safe to call without ever opening a file. + EXPECT_EQ(swmm_runoff_iface_close(engine_), SWMM_OK); + EXPECT_EQ(swmm_runoff_iface_close(engine_), SWMM_OK); +} + +TEST_F(RunoffIfaceCApiTest, RefusesDoubleOpenWithoutClose) { + open_and_init(); + const auto path = make_temp_iface_path("dbl"); + ASSERT_EQ(swmm_runoff_iface_open_write(engine_, path.c_str()), SWMM_OK); + // Second open without intervening close must fail (non-OK). + EXPECT_NE(swmm_runoff_iface_open_write(engine_, path.c_str()), SWMM_OK); + // Cleanup: close and remove the file so subsequent tests / CI runs + // don't see a leftover. + EXPECT_EQ(swmm_runoff_iface_close(engine_), SWMM_OK); + std::error_code ec; + std::filesystem::remove(path, ec); +} + +// --------------------------------------------------------------------------- +// SAVE → READ round trip — the headline behaviour. After running the full +// fixture with a SAVE-mode runoff interface file open, the file must +// re-open cleanly in READ mode and produce non-zero record reads. +// --------------------------------------------------------------------------- + +TEST_F(RunoffIfaceCApiTest, SaveThenReadRoundTrip) { + open_and_init(); + ASSERT_EQ(swmm_engine_start(engine_, 1), SWMM_OK); + + const auto path = make_temp_iface_path("rt"); + ASSERT_EQ(swmm_runoff_iface_open_write(engine_, path.c_str()), SWMM_OK) + << "open_write failed"; + + // Drive the simulation to completion — the engine auto-saves one + // record per runoff substep from inside stepRunoff(). + double elapsed = 0.0; + while (true) { + int rc = swmm_engine_step(engine_, &elapsed); + if (rc != 0) break; + if (elapsed <= 0.0) break; + } + ASSERT_EQ(swmm_engine_end(engine_), SWMM_OK); + ASSERT_EQ(swmm_runoff_iface_close(engine_), SWMM_OK); + + // File should exist and be non-empty. + ASSERT_TRUE(std::filesystem::exists(path)); + EXPECT_GT(std::filesystem::file_size(path), + static_cast(28)) // header is 28 bytes + << "Expected at least one substep record beyond the header"; + + // Reopen in READ mode and verify that swmm_runoff_iface_read_step + // is callable and reads at least one record before EOF. + ASSERT_EQ(swmm_runoff_iface_open_read(engine_, path.c_str()), SWMM_OK) + << "open_read failed — header mismatch?"; + + int records_read = 0; + int has = 0; + for (;;) { + ASSERT_EQ(swmm_runoff_iface_read_step(engine_, &has), SWMM_OK); + if (!has) break; + ++records_read; + // Bail after a few thousand to keep the test fast even if EOF + // detection regresses. + if (records_read > 100000) { + FAIL() << "read_step appears to be looping past EOF"; + break; + } + } + EXPECT_GT(records_read, 0) + << "Expected at least one record from the saved file"; + + EXPECT_EQ(swmm_runoff_iface_close(engine_), SWMM_OK); + std::error_code ec; + std::filesystem::remove(path, ec); +} + +// --------------------------------------------------------------------------- +// Explicit save_step (force a snapshot) — even outside the engine's +// internal hook, the explicit C API entrypoint must succeed (and be a +// no-op when no SAVE file is open, which is exercised by the close-then- +// save sequence below). +// --------------------------------------------------------------------------- + +TEST_F(RunoffIfaceCApiTest, SaveStepIsNoopWhenNoFileOpen) { + open_and_init(); + // No file open — must succeed silently. + EXPECT_EQ(swmm_runoff_iface_save_step(engine_, 1.0), SWMM_OK); +} + +TEST_F(RunoffIfaceCApiTest, ReadStepWhenNoFileOpenReturnsHasZero) { + open_and_init(); + int has = -1; + EXPECT_EQ(swmm_runoff_iface_read_step(engine_, &has), SWMM_OK); + EXPECT_EQ(has, 0) << "Expected has_data=0 with no file open"; +} + +} // namespace diff --git a/tests/unit/engine/test_statistics_bulk_phase3.cpp b/tests/unit/engine/test_statistics_bulk_phase3.cpp index 0f336c043..320d558db 100644 --- a/tests/unit/engine/test_statistics_bulk_phase3.cpp +++ b/tests/unit/engine/test_statistics_bulk_phase3.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include namespace { @@ -125,6 +126,92 @@ TEST_F(StatsBulkPhase3Test, SubcatchMaxRunoffBulkMatchesScalar) { } } +// --------------------------------------------------------------------------- +// Phase 4e link-stat bulks — same equivalence + non-negativity + bad-param +// shape as the Phase 3 batch. +// --------------------------------------------------------------------------- + +TEST_F(StatsBulkPhase3Test, LinkMaxVelocityBulkMatchesScalar) { + const int n_links = swmm_link_count(engine_); + ASSERT_GT(n_links, 0); + std::vector bulk(n_links, -42.0); + ASSERT_EQ(swmm_stat_link_max_velocity_bulk(engine_, bulk.data(), n_links), + SWMM_OK); + for (int i = 0; i < n_links; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_stat_link_max_velocity(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "link " << i; + } +} + +TEST_F(StatsBulkPhase3Test, LinkMaxFillingBulkMatchesScalar) { + const int n_links = swmm_link_count(engine_); + std::vector bulk(n_links, -42.0); + ASSERT_EQ(swmm_stat_link_max_filling_bulk(engine_, bulk.data(), n_links), + SWMM_OK); + for (int i = 0; i < n_links; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_stat_link_max_filling(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "link " << i; + } +} + +TEST_F(StatsBulkPhase3Test, LinkVolFlowBulkMatchesScalar) { + const int n_links = swmm_link_count(engine_); + std::vector bulk(n_links, -42.0); + ASSERT_EQ(swmm_stat_link_vol_flow_bulk(engine_, bulk.data(), n_links), + SWMM_OK); + for (int i = 0; i < n_links; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_stat_link_vol_flow(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "link " << i; + } +} + +TEST_F(StatsBulkPhase3Test, LinkSurchargeTimeBulkMatchesScalar) { + const int n_links = swmm_link_count(engine_); + std::vector bulk(n_links, -42.0); + ASSERT_EQ(swmm_stat_link_surcharge_time_bulk(engine_, bulk.data(), n_links), + SWMM_OK); + for (int i = 0; i < n_links; ++i) { + double scalar = -1.0; + ASSERT_EQ(swmm_stat_link_surcharge_time(engine_, i, &scalar), SWMM_OK); + EXPECT_EQ(bulk[i], scalar) << "link " << i; + } +} + +TEST_F(StatsBulkPhase3Test, LinkStatsBulksRejectBadParams) { + const int n_links = swmm_link_count(engine_); + std::vector v(n_links, 0.0); + EXPECT_EQ(swmm_stat_link_max_velocity_bulk(engine_, nullptr, n_links), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_stat_link_max_filling_bulk(engine_, nullptr, n_links), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_stat_link_vol_flow_bulk(engine_, nullptr, n_links), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_stat_link_surcharge_time_bulk(engine_, nullptr, n_links), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_stat_link_max_velocity_bulk(engine_, v.data(), 0), + SWMM_ERR_BADPARAM); + EXPECT_EQ(swmm_stat_link_max_velocity_bulk(nullptr, v.data(), n_links), + SWMM_ERR_BADHANDLE); +} + +TEST_F(StatsBulkPhase3Test, LinkStatsAreNonNegative) { + const int n_links = swmm_link_count(engine_); + std::vector mv(n_links), mf(n_links), vf(n_links), st(n_links); + ASSERT_EQ(swmm_stat_link_max_velocity_bulk(engine_, mv.data(), n_links), SWMM_OK); + ASSERT_EQ(swmm_stat_link_max_filling_bulk(engine_, mf.data(), n_links), SWMM_OK); + ASSERT_EQ(swmm_stat_link_vol_flow_bulk(engine_, vf.data(), n_links), SWMM_OK); + ASSERT_EQ(swmm_stat_link_surcharge_time_bulk(engine_, st.data(), n_links), SWMM_OK); + for (int i = 0; i < n_links; ++i) { + EXPECT_GE(mv[i], 0.0) << "link " << i << " max_velocity"; + EXPECT_GE(mf[i], 0.0) << "link " << i << " max_filling"; + EXPECT_GE(vf[i], 0.0) << "link " << i << " vol_flow"; + EXPECT_GE(st[i], 0.0) << "link " << i << " surcharge_time"; + } +} + // --------------------------------------------------------------------------- // Non-negativity sanity — these are cumulative non-negative quantities. A // regression that read the wrong SoA column would likely produce negatives. diff --git a/tests/unit/engine/test_transect_inp_parser.cpp b/tests/unit/engine/test_transect_inp_parser.cpp new file mode 100644 index 000000000..985f78488 --- /dev/null +++ b/tests/unit/engine/test_transect_inp_parser.cpp @@ -0,0 +1,217 @@ +/** + * @file test_transect_inp_parser.cpp + * @brief Pins the [TRANSECTS] X1-line column mapping in the .inp parser. + * + * EPA SWMM 5 (legacy transect.c::setParams) layout: + * X1 Name Nsta Xleft Xright 0 0 Lfactor Xfactor Yfactor + * Tokens 3 4 5 6 7 8 9 + * + * Two earlier bugs the parser had: + * 1. length_factor was hardcoded to 1.0 (tok[7] dropped on the floor); + * x_factor read tok[8] OK but y_factor read tok[9] OK — column + * mapping was right by coincidence on the multiplier pair, but + * Lfactor was always lost. + * 2. Zero values for Lfactor / Xfactor were stored verbatim instead of + * being defaulted to 1.0 (legacy SWMM behaviour). Combined with EPA- + * generated .inp files that emit "0" placeholders, this collapsed + * every plotted station to x*0 = 0 — the "vertical line" cross- + * section symptom in the GUI's Transect Editor. + * + * These tests load minimal .inp files on disk through swmm_engine_open + * and read back the modifiers via swmm_transect_get_modifiers. + */ + +#include + +#include +#include +#include +#include + +#include +#include + +namespace { + +// Minimal valid .inp skeleton with a single placeholder conduit/transect. +// Just enough sections for swmm_engine_open() to succeed. +constexpr const char *kInpSkeletonHead = R"( +[TITLE] +Transect parser test + +[OPTIONS] +FLOW_UNITS CFS +INFILTRATION HORTON +FLOW_ROUTING KINWAVE +LINK_OFFSETS DEPTH +MIN_SLOPE 0 +ALLOW_PONDING NO +SKIP_STEADY_STATE NO +START_DATE 01/01/2024 +START_TIME 00:00:00 +REPORT_START_DATE 01/01/2024 +REPORT_START_TIME 00:00:00 +END_DATE 01/01/2024 +END_TIME 00:30:00 +SWEEP_START 01/01 +SWEEP_END 12/31 +DRY_DAYS 0 +REPORT_STEP 00:01:00 +WET_STEP 00:01:00 +DRY_STEP 00:01:00 +ROUTING_STEP 00:00:30 + +[JUNCTIONS] +;;Name Elev MaxDepth InitDepth SurDepth Aponded +J1 100 5 0 0 0 +J2 99 5 0 0 0 + +[OUTFALLS] +;;Name Elev Type StageData Gated RouteTo + +[CONDUITS] +;;Name From To Length Manning InOffset OutOffset InitFlow MaxFlow +C1 J1 J2 100 0.013 0 0 0 0 + +[XSECTIONS] +;;Name Shape Geom1 Geom2 Geom3 Geom4 Barrels +C1 CIRCULAR 1 0 0 0 1 + +[TRANSECTS] +)"; + +constexpr const char *kInpTail = R"( + +[REPORT] +SUBCATCHMENTS NONE +NODES NONE +LINKS NONE +)"; + +// Write `inpContents` (which is "skeleton + transect_block + tail") to a +// temp file and return its path. The caller must delete the path. +std::string writeTempInp(const std::string &transectBlock) { + std::string path; + { + // mkstemp pattern — XXXXXX is replaced in place. + char tmpl[] = "/tmp/swmm_transect_test_XXXXXX.inp"; + // Use a deterministic-ish path; mkstemps would need on + // macOS. tmpnam is deprecated but adequate here for a unit test. + char *tname = std::tmpnam(nullptr); + path = tname; + path += ".inp"; + (void)tmpl; + } + std::ofstream out(path); + out << kInpSkeletonHead << transectBlock << kInpTail; + return path; +} + +class TransectInpParserTest : public ::testing::Test { +protected: + SWMM_Engine eng = nullptr; + std::string inpPath; + std::string rptPath; + std::string outPath; + + void TearDown() override { + if (eng) { + swmm_engine_close(eng); + swmm_engine_destroy(eng); + eng = nullptr; + } + if (!inpPath.empty()) std::remove(inpPath.c_str()); + if (!rptPath.empty()) std::remove(rptPath.c_str()); + if (!outPath.empty()) std::remove(outPath.c_str()); + } + + // Helper — write the transect block, open, return the engine. + bool loadWithTransects(const std::string &transectBlock) { + inpPath = writeTempInp(transectBlock); + rptPath = inpPath + ".rpt"; + outPath = inpPath + ".out"; + eng = swmm_engine_create(); + if (!eng) return false; + return swmm_engine_open(eng, inpPath.c_str(), + rptPath.c_str(), outPath.c_str(), nullptr) + == SWMM_OK; + } +}; + +// --------------------------------------------------------------------------- +// All-zero modifiers (EPA-generated files) must default to Lfactor=1, +// Xfactor=1 — otherwise station*0 collapses the cross-section. +// --------------------------------------------------------------------------- + +TEST_F(TransectInpParserTest, ZeroModifiersDefaultToOne) { + const std::string block = + "NC 0.04 0.04 0.04\n" + "X1 TRZ 4 2.0 8.0 0 0 0 0 0\n" + "GR 105 0 100 2 100 8 105 10\n"; + ASSERT_TRUE(loadWithTransects(block)); + + const int idx = swmm_transect_index(eng, "TRZ"); + ASSERT_GE(idx, 0); + + double xF = -1, yF = -1, lF = -1; + ASSERT_EQ(swmm_transect_get_modifiers(eng, idx, &xF, &yF, &lF), SWMM_OK); + EXPECT_DOUBLE_EQ(lF, 1.0); // Lfactor zero -> default 1.0 + EXPECT_DOUBLE_EQ(xF, 1.0); // Xfactor zero -> default 1.0 + EXPECT_DOUBLE_EQ(yF, 0.0); // Yfactor 0 is a valid offset (no shift). + + // Stations come through raw (un-multiplied) on the engine side. + ASSERT_EQ(swmm_transect_get_station_count(eng, idx), 4); + double s = -1, e = -1; + ASSERT_EQ(swmm_transect_get_station(eng, idx, 0, &s, &e), SWMM_OK); + EXPECT_DOUBLE_EQ(s, 0.0); + EXPECT_DOUBLE_EQ(e, 105.0); + ASSERT_EQ(swmm_transect_get_station(eng, idx, 2, &s, &e), SWMM_OK); + EXPECT_DOUBLE_EQ(s, 8.0); + EXPECT_DOUBLE_EQ(e, 100.0); +} + +// --------------------------------------------------------------------------- +// Explicit non-default modifiers must be read from the correct token columns. +// X1 ... 0 0 Lfactor=1.5 Xfactor=2.0 Yfactor=3.0 +// Before the fix, length_factor was hardcoded to 1.0 (1.5 lost). +// --------------------------------------------------------------------------- + +TEST_F(TransectInpParserTest, ExplicitModifiersReadFromCorrectColumns) { + const std::string block = + "NC 0.04 0.04 0.04\n" + "X1 TRM 3 1.0 9.0 0 0 1.5 2.0 3.0\n" + "GR 100 0 90 5 100 10\n"; + ASSERT_TRUE(loadWithTransects(block)); + + const int idx = swmm_transect_index(eng, "TRM"); + ASSERT_GE(idx, 0); + + double xF = -1, yF = -1, lF = -1; + ASSERT_EQ(swmm_transect_get_modifiers(eng, idx, &xF, &yF, &lF), SWMM_OK); + EXPECT_DOUBLE_EQ(lF, 1.5); + EXPECT_DOUBLE_EQ(xF, 2.0); + EXPECT_DOUBLE_EQ(yF, 3.0); +} + +// --------------------------------------------------------------------------- +// Bank stations also live in tok[3]/tok[4] — pin them so a future re-shuffle +// doesn't silently misread them. +// --------------------------------------------------------------------------- + +TEST_F(TransectInpParserTest, BankStationsReadFromCorrectColumns) { + const std::string block = + "NC 0.04 0.04 0.04\n" + "X1 TBK 3 2.5 7.5 0 0 0 0 0\n" + "GR 100 0 90 5 100 10\n"; + ASSERT_TRUE(loadWithTransects(block)); + + const int idx = swmm_transect_index(eng, "TBK"); + ASSERT_GE(idx, 0); + + double xLb = -1, xRb = -1; + ASSERT_EQ(swmm_transect_get_bank_stations(eng, idx, &xLb, &xRb), SWMM_OK); + EXPECT_DOUBLE_EQ(xLb, 2.5); + EXPECT_DOUBLE_EQ(xRb, 7.5); +} + +} // namespace From 102d37906b8991993857b07053b74be6d8caee3a Mon Sep 17 00:00:00 2001 From: cbuahin Date: Mon, 25 May 2026 23:37:29 -0400 Subject: [PATCH 19/26] Forward declaration fix Signed-off-by: cbuahin --- .github/workflows/codeql.yml | 2 +- python/tests/engine/test_new_api.py | 62 +++++++++++++-------------- src/engine/CMakeLists.txt | 5 ++- src/engine/core/InpWriter.cpp | 22 ++++++---- tests/unit/engine/test_geopackage.cpp | 8 ++++ vcpkg.json | 1 - 6 files changed, 57 insertions(+), 43 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ea4b27185..f5d0b95da 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,7 +5,7 @@ on: branches: [main] # drop develop — covered by PR paths: ['src/**', 'include/**', 'python/**', 'CMakeLists.txt', 'vcpkg.json'] pull_request: - branches: [main, develop] + branches: [main] paths: ['src/**', 'include/**', 'python/**', 'CMakeLists.txt', 'vcpkg.json'] schedule: [{ cron: "0 6 * * 1" }] workflow_dispatch: diff --git a/python/tests/engine/test_new_api.py b/python/tests/engine/test_new_api.py index 3a48c6d56..db2d05c60 100644 --- a/python/tests/engine/test_new_api.py +++ b/python/tests/engine/test_new_api.py @@ -68,7 +68,7 @@ def test_exists(self, stepped_links): assert hasattr(stepped_links, "get_pump_stats_bulk") def test_return_shape_and_dtypes(self, stepped_links): - n = stepped_links.count + n = stepped_links.count() result = stepped_links.get_pump_stats_bulk() assert set(result.keys()) == {"cycles", "on_time", "volume"} assert result["cycles"].shape == (n,) @@ -86,7 +86,7 @@ def test_equivalence_with_scalar_getters(self, stepped_links): emits the documented sentinel. """ result = stepped_links.get_pump_stats_bulk() - n = stepped_links.count + n = stepped_links.count() # LinkType.PUMP == 1 (see LinkData.hpp); use get_type to discover. for i in range(n): if stepped_links.get_type(i) != 1: # not a pump @@ -102,7 +102,7 @@ def test_equivalence_with_scalar_getters(self, stepped_links): def test_sentinel_count_matches_non_pumps(self, stepped_links): """Count of -1 sentinels should equal the count of non-pump links.""" result = stepped_links.get_pump_stats_bulk() - n = stepped_links.count + n = stepped_links.count() n_non_pumps = sum(1 for i in range(n) if stepped_links.get_type(i) != 1) assert int((result["cycles"] == -1).sum()) == n_non_pumps @@ -381,7 +381,7 @@ def test_get_volumes_bulk_exists(self, stepped_nodes): assert hasattr(stepped_nodes, "get_volumes_bulk") def test_get_volumes_bulk_shape_and_dtype(self, stepped_nodes): - n = stepped_nodes.count + n = stepped_nodes.count() arr = stepped_nodes.get_volumes_bulk() assert isinstance(arr, np.ndarray) assert arr.shape == (n,) @@ -390,25 +390,25 @@ def test_get_volumes_bulk_shape_and_dtype(self, stepped_nodes): def test_get_volumes_bulk_equivalence(self, stepped_nodes): arr = stepped_nodes.get_volumes_bulk() - n = stepped_nodes.count + n = stepped_nodes.count() for i in range(n): assert arr[i] == stepped_nodes.get_volume(i), f"node {i}" def test_get_outflows_bulk_equivalence(self, stepped_nodes): arr = stepped_nodes.get_outflows_bulk() - n = stepped_nodes.count + n = stepped_nodes.count() for i in range(n): assert arr[i] == stepped_nodes.get_outflow(i), f"node {i}" def test_get_losses_bulk_equivalence(self, stepped_nodes): arr = stepped_nodes.get_losses_bulk() - n = stepped_nodes.count + n = stepped_nodes.count() for i in range(n): assert arr[i] == stepped_nodes.get_losses(i), f"node {i}" def test_get_lateral_inflows_bulk_equivalence(self, stepped_nodes): arr = stepped_nodes.get_lateral_inflows_bulk() - n = stepped_nodes.count + n = stepped_nodes.count() for i in range(n): assert arr[i] == stepped_nodes.get_lateral_inflow(i), f"node {i}" @@ -422,7 +422,7 @@ def test_get_lateral_inflows_bulk_matches_inflows_bulk(self, stepped_nodes): def test_get_ids_bulk_returns_list_of_strings(self, stepped_nodes): ids = stepped_nodes.get_ids_bulk() - n = stepped_nodes.count + n = stepped_nodes.count() assert isinstance(ids, list) assert len(ids) == n for s in ids: @@ -430,7 +430,7 @@ def test_get_ids_bulk_returns_list_of_strings(self, stepped_nodes): def test_get_ids_bulk_equivalence_with_scalar(self, stepped_nodes): ids = stepped_nodes.get_ids_bulk() - n = stepped_nodes.count + n = stepped_nodes.count() for i in range(n): assert ids[i] == stepped_nodes.get_id(i), f"index {i}" @@ -468,44 +468,44 @@ class TestLinksPhase3Bulk: def test_velocities_bulk_shape(self, stepped_links): arr = stepped_links.get_velocities_bulk() assert isinstance(arr, np.ndarray) - assert arr.shape == (stepped_links.count,) + assert arr.shape == (stepped_links.count(),) assert arr.dtype == np.float64 assert arr.flags["C_CONTIGUOUS"] def test_velocities_bulk_equivalence(self, stepped_links): arr = stepped_links.get_velocities_bulk() - for i in range(stepped_links.count): + for i in range(stepped_links.count()): assert arr[i] == stepped_links.get_velocity(i), f"link {i}" def test_capacities_bulk_equivalence(self, stepped_links): arr = stepped_links.get_capacities_bulk() - for i in range(stepped_links.count): + for i in range(stepped_links.count()): assert arr[i] == stepped_links.get_capacity(i), f"link {i}" def test_volumes_bulk_equivalence(self, stepped_links): arr = stepped_links.get_volumes_bulk() - for i in range(stepped_links.count): + for i in range(stepped_links.count()): assert arr[i] == stepped_links.get_volume(i), f"link {i}" def test_control_settings_bulk_equivalence(self, stepped_links): arr = stepped_links.get_control_settings_bulk() - for i in range(stepped_links.count): + for i in range(stepped_links.count()): assert arr[i] == stepped_links.get_control_setting(i), f"link {i}" def test_target_settings_bulk_equivalence(self, stepped_links): arr = stepped_links.get_target_settings_bulk() - for i in range(stepped_links.count): + for i in range(stepped_links.count()): assert arr[i] == stepped_links.get_target_setting(i), f"link {i}" def test_hyd_powers_bulk_equivalence(self, stepped_links): arr = stepped_links.get_hyd_powers_bulk() - for i in range(stepped_links.count): + for i in range(stepped_links.count()): assert arr[i] == stepped_links.get_hyd_power(i), f"link {i}" def test_ids_bulk_returns_list_of_strings(self, stepped_links): ids = stepped_links.get_ids_bulk() assert isinstance(ids, list) - assert len(ids) == stepped_links.count + assert len(ids) == stepped_links.count() for s in ids: assert isinstance(s, str) @@ -555,23 +555,23 @@ class TestSubcatchmentsPhase3Bulk: def test_rainfall_bulk_shape(self, stepped_subcatchments): arr = stepped_subcatchments.get_rainfall_bulk() assert isinstance(arr, np.ndarray) - assert arr.shape == (stepped_subcatchments.count,) + assert arr.shape == (stepped_subcatchments.count(),) assert arr.dtype == np.float64 assert arr.flags["C_CONTIGUOUS"] def test_rainfall_bulk_equivalence(self, stepped_subcatchments): arr = stepped_subcatchments.get_rainfall_bulk() - for i in range(stepped_subcatchments.count): + for i in range(stepped_subcatchments.count()): assert arr[i] == stepped_subcatchments.get_rainfall(i), f"subcatch {i}" def test_evap_bulk_equivalence(self, stepped_subcatchments): arr = stepped_subcatchments.get_evap_bulk() - for i in range(stepped_subcatchments.count): + for i in range(stepped_subcatchments.count()): assert arr[i] == stepped_subcatchments.get_evap(i), f"subcatch {i}" def test_infil_bulk_equivalence(self, stepped_subcatchments): arr = stepped_subcatchments.get_infil_bulk() - for i in range(stepped_subcatchments.count): + for i in range(stepped_subcatchments.count()): assert arr[i] == stepped_subcatchments.get_infil(i), f"subcatch {i}" def test_snow_depth_bulk_returns_zeros_placeholder(self, stepped_subcatchments): @@ -580,7 +580,7 @@ def test_snow_depth_bulk_returns_zeros_placeholder(self, stepped_subcatchments): integration completes, both this test and the scalar test will need a corresponding update.""" arr = stepped_subcatchments.get_snow_depth_bulk() - for i in range(stepped_subcatchments.count): + for i in range(stepped_subcatchments.count()): scalar = stepped_subcatchments.get_snow_depth(i) assert arr[i] == scalar, f"subcatch {i}" assert arr[i] == 0.0, f"subcatch {i}: expected placeholder zero" @@ -588,7 +588,7 @@ def test_snow_depth_bulk_returns_zeros_placeholder(self, stepped_subcatchments): def test_ids_bulk_returns_list_of_strings(self, stepped_subcatchments): ids = stepped_subcatchments.get_ids_bulk() assert isinstance(ids, list) - assert len(ids) == stepped_subcatchments.count + assert len(ids) == stepped_subcatchments.count() for s in ids: assert isinstance(s, str) @@ -617,7 +617,7 @@ def test_whole_network_water_balance_pattern(self, stepped_subcatchments): infil = stepped_subcatchments.get_infil_bulk() evap = stepped_subcatchments.get_evap_bulk() runoff = stepped_subcatchments.get_runoff_bulk() - n = stepped_subcatchments.count + n = stepped_subcatchments.count() assert rain.shape == (n,) assert infil.shape == (n,) assert evap.shape == (n,) @@ -648,7 +648,7 @@ def test_node_max_overflow_bulk_shape(self, completed_solver): nodes = Nodes(completed_solver) arr = stats.node_max_overflow_bulk() assert isinstance(arr, np.ndarray) - assert arr.shape == (nodes.count,) + assert arr.shape == (nodes.count(),) assert arr.dtype == np.float64 assert arr.flags["C_CONTIGUOUS"] @@ -657,7 +657,7 @@ def test_node_max_overflow_bulk_equivalence(self, completed_solver): stats = Statistics(completed_solver) nodes = Nodes(completed_solver) arr = stats.node_max_overflow_bulk() - for i in range(nodes.count): + for i in range(nodes.count()): assert arr[i] == nodes.get_stat_max_overflow(i), f"node {i}" def test_node_vol_flooded_bulk_equivalence(self, completed_solver): @@ -665,7 +665,7 @@ def test_node_vol_flooded_bulk_equivalence(self, completed_solver): stats = Statistics(completed_solver) nodes = Nodes(completed_solver) arr = stats.node_vol_flooded_bulk() - for i in range(nodes.count): + for i in range(nodes.count()): assert arr[i] == nodes.get_stat_vol_flooded(i), f"node {i}" def test_node_time_flooded_bulk_equivalence(self, completed_solver): @@ -673,7 +673,7 @@ def test_node_time_flooded_bulk_equivalence(self, completed_solver): stats = Statistics(completed_solver) nodes = Nodes(completed_solver) arr = stats.node_time_flooded_bulk() - for i in range(nodes.count): + for i in range(nodes.count()): assert arr[i] == nodes.get_stat_time_flooded(i), f"node {i}" def test_subcatch_max_runoff_bulk_equivalence(self, completed_solver): @@ -681,7 +681,7 @@ def test_subcatch_max_runoff_bulk_equivalence(self, completed_solver): stats = Statistics(completed_solver) subs = Subcatchments(completed_solver) arr = stats.subcatch_max_runoff_bulk() - for i in range(subs.count): + for i in range(subs.count()): assert arr[i] == subs.get_stat_max_runoff(i), f"subcatch {i}" def test_all_stats_non_negative(self, completed_solver): @@ -711,7 +711,7 @@ def test_flooding_summary_idiom(self, completed_solver): vol_flood = stats.node_vol_flooded_bulk() t_flood = stats.node_time_flooded_bulk() # All four results align on the node index. - n = nodes.count + n = nodes.count() assert len(ids) == n assert max_over.shape == (n,) assert vol_flood.shape == (n,) diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index 8f80a3cc8..1dc10b824 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -111,7 +111,10 @@ target_link_libraries(openswmm_engine PUBLIC openswmm_common PRIVATE - $<$>>:m> + # On macOS, libm is part of libSystem and HDF5/SUNDIALS already pull in + # -lm transitively — adding it here produces an "ignoring duplicate + # libraries: '-lm'" ld warning. Restrict to non-APPLE UNIX. + $<$>>,$>>:m> ) # ---- Threads (for IOThread) ---- diff --git a/src/engine/core/InpWriter.cpp b/src/engine/core/InpWriter.cpp index 9defded0e..25a7560e0 100644 --- a/src/engine/core/InpWriter.cpp +++ b/src/engine/core/InpWriter.cpp @@ -41,40 +41,44 @@ static void sec(FILE* f, const char* name) { } // Format an OADate as MM/DD/YYYY -static void fmt_date(char* buf, double oadate) { +template +static void fmt_date(char (&buf)[N], double oadate) { int y, m, d; datetime::decodeDate(oadate, y, m, d); - std::sprintf(buf, "%02d/%02d/%04d", m, d, y); + std::snprintf(buf, N, "%02d/%02d/%04d", m, d, y); } // Format the time-of-day part of an OADate as HH:MM:SS -static void fmt_time(char* buf, double oadate) { +template +static void fmt_time(char (&buf)[N], double oadate) { int h, m, s; datetime::decodeTime(oadate, h, m, s); - std::sprintf(buf, "%02d:%02d:%02d", h, m, s); + std::snprintf(buf, N, "%02d:%02d:%02d", h, m, s); } // Format a timestep (seconds). Whole-second values use H:MM:SS (matching // legacy SWMM GUI GetTimeString); fractional values use %g decimal notation. -static void fmt_step(char* buf, double secs) { +template +static void fmt_step(char (&buf)[N], double secs) { long r = std::lround(secs); if (std::fabs(secs - static_cast(r)) < 0.001) { int ss = static_cast(r % 60); int mm = static_cast((r / 60) % 60); int hh = static_cast(r / 3600); - std::sprintf(buf, "%d:%02d:%02d", hh, mm, ss); + std::snprintf(buf, N, "%d:%02d:%02d", hh, mm, ss); } else { - std::sprintf(buf, "%g", secs); + std::snprintf(buf, N, "%g", secs); } } // Format a day-of-year integer as M/D (no leading zeros, matching legacy GUI) -static void fmt_sweep(char* buf, int doy) { +template +static void fmt_sweep(char (&buf)[N], int doy) { // Anchor to a non-leap year to get a stable month/day double dt = datetime::encodeDate(2001, 1, 1) + static_cast(doy - 1); int y, m, d; datetime::decodeDate(dt, y, m, d); - std::sprintf(buf, "%d/%d", m, d); + std::snprintf(buf, N, "%d/%d", m, d); } // Write per-object comment lines. Multi-line comments are stored with literal diff --git a/tests/unit/engine/test_geopackage.cpp b/tests/unit/engine/test_geopackage.cpp index c3e583bfd..7f918659e 100644 --- a/tests/unit/engine/test_geopackage.cpp +++ b/tests/unit/engine/test_geopackage.cpp @@ -904,6 +904,14 @@ TEST(GeoPackagePluginInfoTest, FactoryCreatesReportPlugin) { delete plugin; } +// Slice RC.2 hid the `openswmm_plugin_info` C export from the engine SHARED +// (visibility=hidden + no public header declaration) to plug the dlsym leak +// that PluginFactory::discover() was inadvertently picking up. The symbol +// is still emitted into the openswmm_geopackage STATIC archive that this +// test links, so a local forward declaration is sufficient to call it from +// the test executable without re-exporting it for third-party consumers. +extern "C" openswmm::IPluginComponentInfo* openswmm_plugin_info(void); + TEST(GeoPackagePluginInfoTest, CExportFunction) { // Verify the C export returns the same singleton auto* exported = openswmm_plugin_info(); diff --git a/vcpkg.json b/vcpkg.json index 85aafec00..3bb069804 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -4,7 +4,6 @@ "license": "MIT", "homepage": "https://hydrocouple.org/projects/openswmm", "version-semver": "6.0.0-alpha.1", - "builtin-baseline": "d5ec528843d29e3a52d745a64b469f810b2cedbf", "dependencies": [ { "name": "vcpkg-cmake", From 043fe903f0bf371c529096ab9d672058d2e04ca7 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Tue, 26 May 2026 00:22:40 -0400 Subject: [PATCH 20/26] Geopackage public import fix Signed-off-by: cbuahin --- src/engine/CMakeLists.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index 1dc10b824..c31385804 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -411,6 +411,15 @@ if(OPENSWMM_WITH_GEOPACKAGE) if(TARGET openswmm_engine_internal) target_link_libraries(openswmm_engine_internal PRIVATE openswmm_geopackage) endif() + # Re-export OPENSWMM_HAS_GEOPACKAGE on the engine target so consumers + # (tests, host apps) can compile-time gate on engine capabilities. + # The PRIVATE link above hides geopackage's own PUBLIC defines from + # engine consumers, but the capability is an engine-level fact once + # geopackage is absorbed into the engine SHARED. + target_compile_definitions(openswmm_engine PUBLIC OPENSWMM_HAS_GEOPACKAGE=1) + if(TARGET openswmm_engine_internal) + target_compile_definitions(openswmm_engine_internal PUBLIC OPENSWMM_HAS_GEOPACKAGE=1) + endif() message(STATUS "OpenSWMM Engine: GeoPackage I/O library enabled (built-in plugin)") else() message(STATUS "OpenSWMM Engine: GeoPackage I/O disabled (set -DOPENSWMM_WITH_GEOPACKAGE=ON to enable)") From f51eb9ac0f1029b9d50ce0879a16b071a8cd9976 Mon Sep 17 00:00:00 2001 From: cbuahin Date: Tue, 26 May 2026 02:59:11 -0400 Subject: [PATCH 21/26] Ensuring unit test pass Signed-off-by: cbuahin --- CMakeLists.txt | 2 +- include/openswmm/engine/openswmm_2d.h | 20 + .../openswmm/engine/openswmm_massbalance.h | 10 +- python/openswmm/engine/_2d.pxd | 1 + python/openswmm/engine/_2d.pyi | 17 + python/openswmm/engine/_2d.pyx | 17 + python/openswmm/engine/_enums.py | 5 + .../data/solver/non_existent_input_file.rpt | 6 +- .../data/solver/site_drainage_example.rpt | 6 +- .../solver/site_drainage_example_link.rpt | 6 +- .../solver/site_drainage_example_node.rpt | 6 +- .../solver/site_drainage_example_outfall.rpt | 8 +- .../solver/site_drainage_example_routing.rpt | 6 +- .../solver/site_drainage_example_runoff.rpt | 6 +- .../solver/site_drainage_example_subcatch.rpt | 8 +- python/tests/engine/test_integration.py | 8 +- python/tests/engine/test_new_api.py.tmp | 724 ++++++++++++++++++ src/engine/2d/api/Api2D.cpp | 12 + src/engine/2d/mesh/MeshBuilder.cpp | 22 + src/engine/2d/mesh/MeshBuilder.hpp | 17 + src/engine/core/openswmm_controls_impl.cpp | 8 +- src/engine/core/openswmm_massbalance_impl.cpp | 1 + .../unit/engine/data/site_drainage_model.out | Bin 331143 -> 331143 bytes .../unit/engine/data/site_drainage_model.rpt | 295 +------ tests/unit/engine/test_2d_surface_routing.cpp | 53 ++ .../legacy/engine/data/hotstart/_api_test.rpt | 4 +- .../legacy/engine/data/hotstart/_err_test.rpt | 4 +- .../data/hotstart/_expanded_api_test.rpt | 4 +- .../engine/data/hotstart/_shap_test.rpt | 4 +- .../engine/data/hotstart/hotstart_end.hsf | Bin 1479 -> 1479 bytes .../site_drainage_model_use_hotstart_v1.rpt | 6 +- 31 files changed, 946 insertions(+), 340 deletions(-) create mode 100644 python/tests/engine/test_new_api.py.tmp diff --git a/CMakeLists.txt b/CMakeLists.txt index 525135811..d07933686 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,7 +85,7 @@ option(OPENSWMM_BUILD_REGRESSION_TESTS "Build regression tests only" OFF) option(OPENSWMM_BUILD_BENCHMARKS "Build Google Benchmark performance tests" OFF) option(OPENSWMM_INSTALL "Install openswmm libraries" ON) option(OPENSWMM_BUILD_PYTHON "Build Python bindings" OFF) -option(OPENSWMM_WITH_GEOPACKAGE "Build GeoPackage I/O library (requires sqlite3)" OFF) +option(OPENSWMM_WITH_GEOPACKAGE "Build GeoPackage I/O library (requires sqlite3)" ON) option(OPENSWMM_BUILD_2D "Build optional 2D surface routing module (requires SUNDIALS)" ON) # OPENSWMM_BUILD_TESTS is a convenience flag that enables both unit and regression diff --git a/include/openswmm/engine/openswmm_2d.h b/include/openswmm/engine/openswmm_2d.h index 7b52c8830..80e48fcba 100644 --- a/include/openswmm/engine/openswmm_2d.h +++ b/include/openswmm/engine/openswmm_2d.h @@ -79,6 +79,26 @@ SWMM_ENGINE_API int swmm_2d_vertex_get_xyz(SWMM_Engine engine, int idx, SWMM_ENGINE_API int swmm_2d_vertex_get_xyz_bulk(SWMM_Engine engine, double* x, double* y, double* z); +/** @brief Set vertex Z (ground elevation). + * + * Updates `vz[idx]` and recomputes the dependent geometry for every triangle + * incident to this vertex: `tri_cz` (centroid Z = mean of vertex Zs) and + * `edge_mz` (per-edge midpoint Z) so the solver's bed-elevation references + * stay consistent on the next step. XY-derived fields (`tri_area`, `tri_cx`, + * `tri_cy`, `edge_length`, `edge_nx`, `edge_ny`, `edge_mx`, `edge_my`) are + * unaffected. + * + * When called while the engine is RUNNING, the solver state (`head`, + * `depth`) is intentionally **not** rewritten — `head` remains the value + * CVODE is integrating; the implied `depth = head - bed` therefore changes + * by the same amount as bed. This is the expected physical semantics + * ("raising the bed under water reduces water depth there"). + * + * @param idx Vertex index (0-based). + * @param z New ground elevation (project vertical units). + * @ingroup engine_2d */ +SWMM_ENGINE_API int swmm_2d_set_vertex_z(SWMM_Engine engine, int idx, double z); + /** @brief Get triangle connectivity (3 vertex indices). * @param idx Triangle index (0-based). * @param v0,v1,v2 Output vertex indices. diff --git a/include/openswmm/engine/openswmm_massbalance.h b/include/openswmm/engine/openswmm_massbalance.h index b6f5e95b5..e519d4d3e 100644 --- a/include/openswmm/engine/openswmm_massbalance.h +++ b/include/openswmm/engine/openswmm_massbalance.h @@ -63,7 +63,15 @@ typedef enum SWMM_RoutingTotal { SWMM_ROUTING_EVAP_LOSS = 7, /**< Cumulative evaporation loss from conveyance. */ SWMM_ROUTING_SEEP_LOSS = 8, /**< Cumulative seepage loss from conveyance. */ SWMM_ROUTING_INIT_STORAGE = 9, /**< Initial in-system storage volume. */ - SWMM_ROUTING_FINAL_STORAGE = 10 /**< Final in-system storage volume. */ + SWMM_ROUTING_FINAL_STORAGE = 10, /**< Final in-system storage volume. */ + SWMM_ROUTING_FORCING_INFLOW = 11 /**< Cumulative runtime-API forced + * lateral inflow volume (i.e. flow + * injected via + * `swmm_node_set_lateral_inflow` or + * transient ForcingData). Distinct + * from `SWMM_ROUTING_EXTERNAL`, which + * only counts INP `[INFLOWS]`-derived + * inflow. */ } SWMM_RoutingTotal; /** diff --git a/python/openswmm/engine/_2d.pxd b/python/openswmm/engine/_2d.pxd index 42df8fab4..650a3dc72 100644 --- a/python/openswmm/engine/_2d.pxd +++ b/python/openswmm/engine/_2d.pxd @@ -14,6 +14,7 @@ cdef extern from "openswmm_2d.h": double* x, double* y, double* z) int swmm_2d_vertex_get_xyz_bulk(void* engine, double* x, double* y, double* z) nogil + int swmm_2d_set_vertex_z(void* engine, int idx, double z) int swmm_2d_triangle_get_vertices(void* engine, int idx, int* v0, int* v1, int* v2) int swmm_2d_triangle_get_area(void* engine, int idx, double* area) diff --git a/python/openswmm/engine/_2d.pyi b/python/openswmm/engine/_2d.pyi index 562131307..962c80cf9 100644 --- a/python/openswmm/engine/_2d.pyi +++ b/python/openswmm/engine/_2d.pyi @@ -102,6 +102,23 @@ class Surface2D: """ ... + def set_vertex_z(self, idx: int, z: float) -> None: + """Set the ground elevation of a mesh vertex. + + Updates derived geometry for every triangle incident to this + vertex (centroid Z, per-edge midpoint Z). XY-derived fields are + unaffected. When called during a running simulation, solver + state (head, depth) is intentionally not rewritten — the implied + depth = head - bed therefore changes by the same amount as bed. + + @param idx: Vertex index (0-based). + @type idx: int + @param z: New ground elevation (project vertical units). + @type z: float + @raise RuntimeError: If the C API call fails. + """ + ... + def get_triangle_vertices(self, idx: int) -> tuple[int, int, int]: """Return the (v0, v1, v2) vertex indices for a triangle. diff --git a/python/openswmm/engine/_2d.pyx b/python/openswmm/engine/_2d.pyx index 6e717301e..6a4ec7b01 100644 --- a/python/openswmm/engine/_2d.pyx +++ b/python/openswmm/engine/_2d.pyx @@ -121,6 +121,23 @@ cdef class Surface2D: _check(err) return x, y, z + def set_vertex_z(self, int idx, double z) -> None: + """Set the ground elevation of a mesh vertex. + + Updates derived geometry for every triangle incident to this + vertex (centroid Z, per-edge midpoint Z). XY-derived fields are + unaffected. When called during a running simulation, solver + state (head, depth) is intentionally not rewritten — the implied + depth = head - bed therefore changes by the same amount as bed. + + @param idx: Vertex index (0-based). + @type idx: int + @param z: New ground elevation (project vertical units). + @type z: float + @raise RuntimeError: If the C API call fails. + """ + _check(swmm_2d_set_vertex_z(self._engine, idx, z)) + def get_triangle_vertices(self, int idx): """Return the (v0, v1, v2) vertex indices for a triangle. diff --git a/python/openswmm/engine/_enums.py b/python/openswmm/engine/_enums.py index 329f28bd4..2f5412e75 100644 --- a/python/openswmm/engine/_enums.py +++ b/python/openswmm/engine/_enums.py @@ -600,6 +600,10 @@ class RoutingTotal(IntEnum): @cvar SEEP_LOSS: Seepage loss. @cvar INIT_STORAGE: Initial network storage. @cvar FINAL_STORAGE: Final network storage. + @cvar FORCING_INFLOW: Runtime-API forced lateral inflow (e.g. + flow injected via Nodes.set_lateral_inflow / transient + ForcingData). Distinct from EXTERNAL which only counts INP + [INFLOWS]-derived inflow. """ DRY_WEATHER = 0 @@ -613,3 +617,4 @@ class RoutingTotal(IntEnum): SEEP_LOSS = 8 INIT_STORAGE = 9 FINAL_STORAGE = 10 + FORCING_INFLOW = 11 diff --git a/python/tests/data/solver/non_existent_input_file.rpt b/python/tests/data/solver/non_existent_input_file.rpt index 403c79007..3a5ad669e 100644 --- a/python/tests/data/solver/non_existent_input_file.rpt +++ b/python/tests/data/solver/non_existent_input_file.rpt @@ -1,5 +1,5 @@ - EPA STORM WATER MANAGEMENT MODEL - VERSION 5.3.0 (Build 5.3.0) + OPENSWMM ENGINE - VERSION 5.3.0 (Build 5.3.0) ------------------------------------------------------------ A site surface drainage model. @@ -2382,6 +2382,6 @@ 01/01/1998 01:00:00 56.517 9.549 1.748 0.334 0.000 - Analysis begun on: Sun May 10 19:35:22 2026 - Analysis ended on: Sun May 10 19:35:22 2026 + Analysis begun on: Tue May 26 01:59:50 2026 + Analysis ended on: Tue May 26 01:59:50 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/python/tests/data/solver/site_drainage_example.rpt b/python/tests/data/solver/site_drainage_example.rpt index 762f013ce..9eeba11d0 100644 --- a/python/tests/data/solver/site_drainage_example.rpt +++ b/python/tests/data/solver/site_drainage_example.rpt @@ -1,5 +1,5 @@ - EPA STORM WATER MANAGEMENT MODEL - VERSION 5.3.0 (Build 5.3.0) + OPENSWMM ENGINE - VERSION 5.3.0 (Build 5.3.0) ------------------------------------------------------------ A site surface drainage model. @@ -2381,6 +2381,6 @@ 01/01/1998 01:00:00 131.487 11.835 2.852 0.627 0.000 - Analysis begun on: Sun May 10 19:35:22 2026 - Analysis ended on: Sun May 10 19:35:22 2026 + Analysis begun on: Tue May 26 01:59:50 2026 + Analysis ended on: Tue May 26 01:59:50 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/python/tests/data/solver/site_drainage_example_link.rpt b/python/tests/data/solver/site_drainage_example_link.rpt index cddccca58..745cdf479 100644 --- a/python/tests/data/solver/site_drainage_example_link.rpt +++ b/python/tests/data/solver/site_drainage_example_link.rpt @@ -1,5 +1,5 @@ - EPA STORM WATER MANAGEMENT MODEL - VERSION 5.3.0 (Build 5.3.0) + OPENSWMM ENGINE - VERSION 5.3.0 (Build 5.3.0) ------------------------------------------------------------ A site surface drainage model. @@ -11371,6 +11371,6 @@ 01/01/1998 06:00:00 0.006 1.038 0.011 0.000 0.000 - Analysis begun on: Sun May 10 19:35:20 2026 - Analysis ended on: Sun May 10 19:35:20 2026 + Analysis begun on: Tue May 26 01:59:48 2026 + Analysis ended on: Tue May 26 01:59:48 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/python/tests/data/solver/site_drainage_example_node.rpt b/python/tests/data/solver/site_drainage_example_node.rpt index 0d4eca105..745cdf479 100644 --- a/python/tests/data/solver/site_drainage_example_node.rpt +++ b/python/tests/data/solver/site_drainage_example_node.rpt @@ -1,5 +1,5 @@ - EPA STORM WATER MANAGEMENT MODEL - VERSION 5.3.0 (Build 5.3.0) + OPENSWMM ENGINE - VERSION 5.3.0 (Build 5.3.0) ------------------------------------------------------------ A site surface drainage model. @@ -11371,6 +11371,6 @@ 01/01/1998 06:00:00 0.006 1.038 0.011 0.000 0.000 - Analysis begun on: Sun May 10 19:35:19 2026 - Analysis ended on: Sun May 10 19:35:19 2026 + Analysis begun on: Tue May 26 01:59:48 2026 + Analysis ended on: Tue May 26 01:59:48 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/python/tests/data/solver/site_drainage_example_outfall.rpt b/python/tests/data/solver/site_drainage_example_outfall.rpt index cddccca58..8a98094a6 100644 --- a/python/tests/data/solver/site_drainage_example_outfall.rpt +++ b/python/tests/data/solver/site_drainage_example_outfall.rpt @@ -1,5 +1,5 @@ - EPA STORM WATER MANAGEMENT MODEL - VERSION 5.3.0 (Build 5.3.0) + OPENSWMM ENGINE - VERSION 5.3.0 (Build 5.3.0) ------------------------------------------------------------ A site surface drainage model. @@ -11371,6 +11371,6 @@ 01/01/1998 06:00:00 0.006 1.038 0.011 0.000 0.000 - Analysis begun on: Sun May 10 19:35:20 2026 - Analysis ended on: Sun May 10 19:35:20 2026 - Total elapsed time: < 1 sec \ No newline at end of file + Analysis begun on: Tue May 26 01:59:48 2026 + Analysis ended on: Tue May 26 01:59:49 2026 + Total elapsed time: 00:00:01 \ No newline at end of file diff --git a/python/tests/data/solver/site_drainage_example_routing.rpt b/python/tests/data/solver/site_drainage_example_routing.rpt index cddccca58..ff74ed549 100644 --- a/python/tests/data/solver/site_drainage_example_routing.rpt +++ b/python/tests/data/solver/site_drainage_example_routing.rpt @@ -1,5 +1,5 @@ - EPA STORM WATER MANAGEMENT MODEL - VERSION 5.3.0 (Build 5.3.0) + OPENSWMM ENGINE - VERSION 5.3.0 (Build 5.3.0) ------------------------------------------------------------ A site surface drainage model. @@ -11371,6 +11371,6 @@ 01/01/1998 06:00:00 0.006 1.038 0.011 0.000 0.000 - Analysis begun on: Sun May 10 19:35:20 2026 - Analysis ended on: Sun May 10 19:35:20 2026 + Analysis begun on: Tue May 26 01:59:49 2026 + Analysis ended on: Tue May 26 01:59:49 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/python/tests/data/solver/site_drainage_example_runoff.rpt b/python/tests/data/solver/site_drainage_example_runoff.rpt index 38b28cfbd..ff74ed549 100644 --- a/python/tests/data/solver/site_drainage_example_runoff.rpt +++ b/python/tests/data/solver/site_drainage_example_runoff.rpt @@ -1,5 +1,5 @@ - EPA STORM WATER MANAGEMENT MODEL - VERSION 5.3.0 (Build 5.3.0) + OPENSWMM ENGINE - VERSION 5.3.0 (Build 5.3.0) ------------------------------------------------------------ A site surface drainage model. @@ -11371,6 +11371,6 @@ 01/01/1998 06:00:00 0.006 1.038 0.011 0.000 0.000 - Analysis begun on: Sun May 10 19:35:21 2026 - Analysis ended on: Sun May 10 19:35:21 2026 + Analysis begun on: Tue May 26 01:59:49 2026 + Analysis ended on: Tue May 26 01:59:49 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/python/tests/data/solver/site_drainage_example_subcatch.rpt b/python/tests/data/solver/site_drainage_example_subcatch.rpt index 0d4eca105..fa343b535 100644 --- a/python/tests/data/solver/site_drainage_example_subcatch.rpt +++ b/python/tests/data/solver/site_drainage_example_subcatch.rpt @@ -1,5 +1,5 @@ - EPA STORM WATER MANAGEMENT MODEL - VERSION 5.3.0 (Build 5.3.0) + OPENSWMM ENGINE - VERSION 5.3.0 (Build 5.3.0) ------------------------------------------------------------ A site surface drainage model. @@ -11371,6 +11371,6 @@ 01/01/1998 06:00:00 0.006 1.038 0.011 0.000 0.000 - Analysis begun on: Sun May 10 19:35:19 2026 - Analysis ended on: Sun May 10 19:35:19 2026 - Total elapsed time: < 1 sec \ No newline at end of file + Analysis begun on: Tue May 26 01:59:47 2026 + Analysis ended on: Tue May 26 01:59:48 2026 + Total elapsed time: 00:00:01 \ No newline at end of file diff --git a/python/tests/engine/test_integration.py b/python/tests/engine/test_integration.py index 5f322aee7..5811db9b7 100644 --- a/python/tests/engine/test_integration.py +++ b/python/tests/engine/test_integration.py @@ -4,6 +4,7 @@ import pytest from openswmm.engine import Solver, Nodes, Links, Subcatchments, Gages, MassBalance, EngineState +from openswmm.engine import RoutingTotal class TestFullSimulationWithExpandedAPI: @@ -131,10 +132,11 @@ def test_lateral_inflow_injection(self, solver_files): s.end() - # Check routing total for external inflow + # Runtime-API-injected lateral inflow accumulates in the FORCING_INFLOW + # bucket — distinct from EXTERNAL, which only counts INP [INFLOWS]. mb = MassBalance(s) - ext_inflow = mb.get_routing_total(4) # EXTERNAL - assert ext_inflow > 0.0, "External inflow should be positive after injection" + forced_inflow = mb.get_routing_total(RoutingTotal.FORCING_INFLOW) + assert forced_inflow > 0.0, "Forced inflow should be positive after injection" s.report() s.close() diff --git a/python/tests/engine/test_new_api.py.tmp b/python/tests/engine/test_new_api.py.tmp new file mode 100644 index 000000000..3a48c6d56 --- /dev/null +++ b/python/tests/engine/test_new_api.py.tmp @@ -0,0 +1,724 @@ +"""Tests for new C API bindings from the refactored engine. + +Covers Phase A-B functionality: +- Pump statistics (Links) +- Hydraulic power (Links) +- Outfall route-to (Nodes) +- Depth from volume (Nodes) +- Event/steady-state status (Solver) +- Routing stats, Courant, quality losses (MassBalance) +- Ponded quality (Subcatchments) +""" + +import pytest +import numpy as np + + +# ============================================================================ +# Links: Pump Statistics +# ============================================================================ + +class TestPumpStatistics: + """Pump utilization statistics from Links binding.""" + + def test_get_pump_cycles_exists(self, stepped_links): + """Method should exist on Links class.""" + assert hasattr(stepped_links, "get_stat_pump_cycles") + + def test_get_pump_cycles_returns_int(self, stepped_links): + """Pump cycles should be an integer >= 0.""" + # Link 0 may not be a pump, but should not crash + v = stepped_links.get_stat_pump_cycles(0) + assert isinstance(v, int) + assert v >= 0 + + def test_get_pump_on_time_exists(self, stepped_links): + assert hasattr(stepped_links, "get_stat_pump_on_time") + + def test_get_pump_on_time_returns_float(self, stepped_links): + v = stepped_links.get_stat_pump_on_time(0) + assert isinstance(v, float) + assert v >= 0.0 + + def test_get_pump_volume_exists(self, stepped_links): + assert hasattr(stepped_links, "get_stat_pump_volume") + + def test_get_pump_volume_returns_float(self, stepped_links): + v = stepped_links.get_stat_pump_volume(0) + assert isinstance(v, float) + assert v >= 0.0 + + +class TestPumpStatsBulk: + """Bulk pump-stats accessor — single C call returning numpy arrays. + + The contract under test: + + * Returns a dict with keys ``cycles`` (int32), ``on_time`` (float64), + and ``volume`` (float64). Each array has length ``n_links``. + * For non-pump links, ``cycles[i] == -1`` and ``on_time[i] == 0.0`` + and ``volume[i] == 0.0`` (the cycles sentinel is the discriminator). + * For pump links, the values match the per-link scalar getters + exactly (numerical equivalence). + * The number of non-sentinel entries equals the number of pump links + when discovered independently via ``get_type``. + """ + + def test_exists(self, stepped_links): + assert hasattr(stepped_links, "get_pump_stats_bulk") + + def test_return_shape_and_dtypes(self, stepped_links): + n = stepped_links.count + result = stepped_links.get_pump_stats_bulk() + assert set(result.keys()) == {"cycles", "on_time", "volume"} + assert result["cycles"].shape == (n,) + assert result["on_time"].shape == (n,) + assert result["volume"].shape == (n,) + assert result["cycles"].dtype == np.intc + assert result["on_time"].dtype == np.float64 + assert result["volume"].dtype == np.float64 + + def test_equivalence_with_scalar_getters(self, stepped_links): + """Bulk values must match scalar values for every pump link. + + Non-pump links are skipped because the scalar getters read raw + statistics vectors and may return zeros while the bulk getter + emits the documented sentinel. + """ + result = stepped_links.get_pump_stats_bulk() + n = stepped_links.count + # LinkType.PUMP == 1 (see LinkData.hpp); use get_type to discover. + for i in range(n): + if stepped_links.get_type(i) != 1: # not a pump + assert result["cycles"][i] == -1 + assert result["on_time"][i] == 0.0 + assert result["volume"][i] == 0.0 + continue + # Pump link: bulk == scalar exactly. + assert result["cycles"][i] == stepped_links.get_stat_pump_cycles(i) + assert result["on_time"][i] == stepped_links.get_stat_pump_on_time(i) + assert result["volume"][i] == stepped_links.get_stat_pump_volume(i) + + def test_sentinel_count_matches_non_pumps(self, stepped_links): + """Count of -1 sentinels should equal the count of non-pump links.""" + result = stepped_links.get_pump_stats_bulk() + n = stepped_links.count + n_non_pumps = sum(1 for i in range(n) if stepped_links.get_type(i) != 1) + assert int((result["cycles"] == -1).sum()) == n_non_pumps + + def test_returns_contiguous_arrays(self, stepped_links): + """C call passes raw pointers; arrays must be C-contiguous.""" + result = stepped_links.get_pump_stats_bulk() + for arr in result.values(): + assert arr.flags["C_CONTIGUOUS"] + + +# ============================================================================ +# Links: Hydraulic Power +# ============================================================================ + +class TestHydraulicPower: + """Hydraulic power computation from Links binding.""" + + def test_get_hyd_power_exists(self, stepped_links): + assert hasattr(stepped_links, "get_hyd_power") + + def test_get_hyd_power_returns_float(self, stepped_links): + v = stepped_links.get_hyd_power(0) + assert isinstance(v, float) + assert v >= 0.0 + + def test_get_hyd_power_by_name(self, stepped_links): + """Should accept string link ID.""" + link_id = stepped_links.get_id(0) + v = stepped_links.get_hyd_power(link_id) + assert isinstance(v, float) + + +# ============================================================================ +# Nodes: Outfall Route-To +# ============================================================================ + +class TestOutfallRouteTo: + """Outfall-to-subcatchment routing from Nodes binding.""" + + def test_get_outfall_route_to_exists(self, stepped_nodes): + assert hasattr(stepped_nodes, "get_outfall_route_to") + + def test_get_outfall_route_to_default(self, stepped_nodes): + """Default should be -1 (no routing).""" + v = stepped_nodes.get_outfall_route_to(0) + assert isinstance(v, int) + # Most nodes won't have route-to set + assert v == -1 or v >= 0 + + def test_set_outfall_route_to_exists(self, stepped_nodes): + assert hasattr(stepped_nodes, "set_outfall_route_to") + + +# ============================================================================ +# Nodes: Depth from Volume +# ============================================================================ + +class TestDepthFromVolume: + """Inverse volume→depth from Nodes binding.""" + + def test_get_depth_from_volume_exists(self, stepped_nodes): + assert hasattr(stepped_nodes, "get_depth_from_volume") + + def test_get_depth_from_volume_zero(self, stepped_nodes): + """Zero volume should return zero depth.""" + v = stepped_nodes.get_depth_from_volume(0, 0.0) + assert isinstance(v, float) + assert v == pytest.approx(0.0, abs=1e-10) + + def test_get_depth_from_volume_positive(self, stepped_nodes): + """Positive volume should return positive depth.""" + v = stepped_nodes.get_depth_from_volume(0, 100.0) + assert isinstance(v, float) + assert v > 0.0 + + +# ============================================================================ +# Solver: Event and Steady-State Status +# ============================================================================ + +class TestEventStatus: + """Routing event status from Solver binding.""" + + def test_is_between_events_exists(self, running_solver): + assert hasattr(running_solver, "is_between_events") + + def test_is_between_events_returns_bool(self, running_solver): + v = running_solver.is_between_events() + assert isinstance(v, bool) + + def test_get_event_count_exists(self, running_solver): + assert hasattr(running_solver, "get_event_count") + + def test_get_event_count_returns_int(self, running_solver): + v = running_solver.get_event_count() + assert isinstance(v, int) + assert v >= 0 + + +class TestEventsEditor: + """C{[EVENTS]} section editor (Slice CW — 2026-05-21). + + OADate values are decimal days since 1899-12-30; arithmetic is + calendar-agnostic here so any disjoint pair works. + """ + + def test_events_count_alias(self, running_solver): + # events_count must agree with the legacy get_event_count. + assert running_solver.events_count() == running_solver.get_event_count() + + def test_events_add_then_get_round_trips(self, running_solver): + before = running_solver.events_count() + idx = running_solver.events_add(46036.0, 46036.5) + assert idx == before + s, e = running_solver.events_get(idx) + assert s == 46036.0 + assert e == 46036.5 + running_solver.events_remove(idx) + assert running_solver.events_count() == before + + def test_events_add_rejects_start_ge_end(self, running_solver): + with pytest.raises(RuntimeError): + running_solver.events_add(10.0, 10.0) + with pytest.raises(RuntimeError): + running_solver.events_add(10.0, 9.0) + + def test_events_set_rejects_start_ge_end(self, running_solver): + before = running_solver.events_count() + idx = running_solver.events_add(20.0, 21.0) + try: + with pytest.raises(RuntimeError): + running_solver.events_set(idx, 21.0, 21.0) + # Underlying row unchanged after rejected set. + s, e = running_solver.events_get(idx) + assert s == 20.0 + assert e == 21.0 + finally: + running_solver.events_remove(idx) + assert running_solver.events_count() == before + + def test_events_clear(self, running_solver): + # Stash whatever the .inp had, restore on teardown so we don't + # leak edits across tests sharing a running solver. + original = [running_solver.events_get(i) + for i in range(running_solver.events_count())] + try: + running_solver.events_add(30.0, 31.0) + running_solver.events_add(32.0, 33.0) + running_solver.events_clear() + assert running_solver.events_count() == 0 + finally: + for s, e in original: + running_solver.events_add(s, e) + + +class TestSteadyStateSkip: + """Steady-state skip control from Solver binding.""" + + def test_get_steady_state_skip_exists(self, running_solver): + assert hasattr(running_solver, "get_steady_state_skip") + + def test_get_steady_state_skip_returns_bool(self, running_solver): + v = running_solver.get_steady_state_skip() + assert isinstance(v, bool) + + def test_set_steady_state_skip_exists(self, running_solver): + assert hasattr(running_solver, "set_steady_state_skip") + + def test_set_and_get_roundtrip(self, running_solver): + """Set True, get should return True.""" + running_solver.set_steady_state_skip(True) + assert running_solver.get_steady_state_skip() is True + running_solver.set_steady_state_skip(False) + assert running_solver.get_steady_state_skip() is False + + +# ============================================================================ +# MassBalance: Routing Stats and Quality Losses +# ============================================================================ + +class TestRoutingStats: + """Routing diagnostics from MassBalance binding.""" + + def test_get_routing_stats_exists(self, mass_balance): + assert hasattr(mass_balance, "get_routing_stats") + + def test_get_routing_stats_returns_dict(self, mass_balance): + v = mass_balance.get_routing_stats() + assert isinstance(v, dict) + assert "avg_step" in v + assert "min_step" in v + assert "max_step" in v + assert "n_steps" in v + assert "pct_non_converged" in v + assert "avg_iterations" in v + assert "max_courant" in v + + def test_routing_stats_positive_steps(self, mass_balance): + v = mass_balance.get_routing_stats() + assert v["n_steps"] > 0 + assert v["avg_step"] > 0.0 + + def test_get_max_courant_exists(self, mass_balance): + assert hasattr(mass_balance, "get_max_courant") + + def test_get_max_courant_returns_float(self, mass_balance): + v = mass_balance.get_max_courant() + assert isinstance(v, float) + assert v >= 0.0 + + +class TestQualityLosses: + """Quality seepage/evaporation losses from MassBalance binding.""" + + def test_get_quality_seep_loss_exists(self, mass_balance): + assert hasattr(mass_balance, "get_quality_seep_loss") + + def test_get_quality_seep_loss_returns_float(self, mass_balance): + # May be 0 if no pollutants or no seepage + v = mass_balance.get_quality_seep_loss(0) + assert isinstance(v, float) + assert v >= 0.0 + + def test_get_quality_evap_loss_exists(self, mass_balance): + assert hasattr(mass_balance, "get_quality_evap_loss") + + def test_get_quality_evap_loss_returns_float(self, mass_balance): + v = mass_balance.get_quality_evap_loss(0) + assert isinstance(v, float) + assert v >= 0.0 + + +# ============================================================================ +# Subcatchments: Ponded Quality +# ============================================================================ + +class TestPondedQuality: + """Ponded quality mass from Subcatchments binding.""" + + def test_get_ponded_quality_exists(self, stepped_subcatchments): + assert hasattr(stepped_subcatchments, "get_ponded_quality") + + def test_set_ponded_quality_exists(self, stepped_subcatchments): + assert hasattr(stepped_subcatchments, "set_ponded_quality") + + def test_get_ponded_quality_returns_float(self, stepped_subcatchments): + # May be 0 if no pollutants defined + try: + v = stepped_subcatchments.get_ponded_quality(0, 0) + assert isinstance(v, float) + assert v >= 0.0 + except Exception: + # Might fail if no pollutants — that's OK + pass + + +# ============================================================================ +# Phase 3: Nodes bulk getters (volumes / outflows / losses / +# lateral_inflows / ids) +# ============================================================================ + +class TestNodesPhase3Bulk: + """Equivalence and contract tests for the Phase 3 nodes-bulk family. + + The contract under test for each get_*_bulk: + + * Returns a NumPy ``float64`` (or ``list[str]`` for ids) of length + ``n_nodes``. + * Each entry matches the scalar accessor on the same engine state + (numerical equivalence). + * The C array is contiguous (the C call writes raw doubles into + ``arr.data``). + """ + + def test_get_volumes_bulk_exists(self, stepped_nodes): + assert hasattr(stepped_nodes, "get_volumes_bulk") + + def test_get_volumes_bulk_shape_and_dtype(self, stepped_nodes): + n = stepped_nodes.count + arr = stepped_nodes.get_volumes_bulk() + assert isinstance(arr, np.ndarray) + assert arr.shape == (n,) + assert arr.dtype == np.float64 + assert arr.flags["C_CONTIGUOUS"] + + def test_get_volumes_bulk_equivalence(self, stepped_nodes): + arr = stepped_nodes.get_volumes_bulk() + n = stepped_nodes.count + for i in range(n): + assert arr[i] == stepped_nodes.get_volume(i), f"node {i}" + + def test_get_outflows_bulk_equivalence(self, stepped_nodes): + arr = stepped_nodes.get_outflows_bulk() + n = stepped_nodes.count + for i in range(n): + assert arr[i] == stepped_nodes.get_outflow(i), f"node {i}" + + def test_get_losses_bulk_equivalence(self, stepped_nodes): + arr = stepped_nodes.get_losses_bulk() + n = stepped_nodes.count + for i in range(n): + assert arr[i] == stepped_nodes.get_losses(i), f"node {i}" + + def test_get_lateral_inflows_bulk_equivalence(self, stepped_nodes): + arr = stepped_nodes.get_lateral_inflows_bulk() + n = stepped_nodes.count + for i in range(n): + assert arr[i] == stepped_nodes.get_lateral_inflow(i), f"node {i}" + + def test_get_lateral_inflows_bulk_matches_inflows_bulk(self, stepped_nodes): + """Backward-compat: ``get_lateral_inflows_bulk`` reads the same + SoA column as the older ``get_inflows_bulk`` (both expose + ``lat_flow``).""" + new = stepped_nodes.get_lateral_inflows_bulk() + old = stepped_nodes.get_inflows_bulk() + np.testing.assert_array_equal(new, old) + + def test_get_ids_bulk_returns_list_of_strings(self, stepped_nodes): + ids = stepped_nodes.get_ids_bulk() + n = stepped_nodes.count + assert isinstance(ids, list) + assert len(ids) == n + for s in ids: + assert isinstance(s, str) + + def test_get_ids_bulk_equivalence_with_scalar(self, stepped_nodes): + ids = stepped_nodes.get_ids_bulk() + n = stepped_nodes.count + for i in range(n): + assert ids[i] == stepped_nodes.get_id(i), f"index {i}" + + def test_get_ids_bulk_handles_short_stride_truncation(self, stepped_nodes): + """A deliberately small stride should truncate IDs without + crashing. Each returned string is at most ``stride - 1`` chars.""" + stride = 4 + ids = stepped_nodes.get_ids_bulk(stride=stride) + for s in ids: + # UTF-8 length (bytes), not codepoints — but SWMM IDs are + # ASCII so they coincide. Allow exactly stride-1 bytes max. + assert len(s.encode("utf-8")) <= stride - 1 + + def test_get_ids_bulk_default_stride_no_truncation(self, stepped_nodes): + """At the default stride of 64, the site_drainage fixture's IDs + must round-trip without loss.""" + ids = stepped_nodes.get_ids_bulk() + for i, s in enumerate(ids): + assert s == stepped_nodes.get_id(i) + + +# ============================================================================ +# Phase 3: Links bulk getters (velocities / capacities / volumes / +# control_settings / target_settings / hyd_powers / ids) +# ============================================================================ + +class TestLinksPhase3Bulk: + """Equivalence and contract tests for the Phase 3 links-bulk family. + + Velocities, capacities, and hyd_powers are derived per-link in C; the + rest are SoA memcpys. Both flavours must agree bit-for-bit with the + matching scalar accessors. + """ + + def test_velocities_bulk_shape(self, stepped_links): + arr = stepped_links.get_velocities_bulk() + assert isinstance(arr, np.ndarray) + assert arr.shape == (stepped_links.count,) + assert arr.dtype == np.float64 + assert arr.flags["C_CONTIGUOUS"] + + def test_velocities_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_velocities_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_velocity(i), f"link {i}" + + def test_capacities_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_capacities_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_capacity(i), f"link {i}" + + def test_volumes_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_volumes_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_volume(i), f"link {i}" + + def test_control_settings_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_control_settings_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_control_setting(i), f"link {i}" + + def test_target_settings_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_target_settings_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_target_setting(i), f"link {i}" + + def test_hyd_powers_bulk_equivalence(self, stepped_links): + arr = stepped_links.get_hyd_powers_bulk() + for i in range(stepped_links.count): + assert arr[i] == stepped_links.get_hyd_power(i), f"link {i}" + + def test_ids_bulk_returns_list_of_strings(self, stepped_links): + ids = stepped_links.get_ids_bulk() + assert isinstance(ids, list) + assert len(ids) == stepped_links.count + for s in ids: + assert isinstance(s, str) + + def test_ids_bulk_equivalence_with_scalar(self, stepped_links): + ids = stepped_links.get_ids_bulk() + for i, s in enumerate(ids): + assert s == stepped_links.get_id(i), f"index {i}" + + def test_ids_bulk_handles_short_stride_truncation(self, stepped_links): + stride = 4 + ids = stepped_links.get_ids_bulk(stride=stride) + for s in ids: + assert len(s.encode("utf-8")) <= stride - 1 + + def test_ids_bulk_default_stride_no_truncation(self, stepped_links): + ids = stepped_links.get_ids_bulk() + for i, s in enumerate(ids): + assert s == stepped_links.get_id(i) + + def test_pump_filtered_hyd_power_summary(self, stepped_links): + """End-to-end pattern: pair ``get_pump_stats_bulk`` with + ``get_hyd_powers_bulk`` to get pump power totals — the canonical + idiom for the MCP server's pump-energy summary.""" + stats = stepped_links.get_pump_stats_bulk() + powers = stepped_links.get_hyd_powers_bulk() + mask = stats["cycles"] >= 0 + # Non-pumps still have a defined hyd_power, but the sum below + # is restricted to pumps only — this is the documented pattern. + pump_power = float(powers[mask].sum()) + assert pump_power >= 0.0 + + +# ============================================================================ +# Phase 3: Subcatchments bulk getters (rainfall / evap / infil / +# snow_depth / ids) +# ============================================================================ + +class TestSubcatchmentsPhase3Bulk: + """Equivalence and contract tests for the Phase 3 subcatchments-bulk + family. + + Snow depth currently returns zeros from both the scalar and bulk + variants (placeholder until snow-state integration); the equivalence + test exercises that documented behaviour. + """ + + def test_rainfall_bulk_shape(self, stepped_subcatchments): + arr = stepped_subcatchments.get_rainfall_bulk() + assert isinstance(arr, np.ndarray) + assert arr.shape == (stepped_subcatchments.count,) + assert arr.dtype == np.float64 + assert arr.flags["C_CONTIGUOUS"] + + def test_rainfall_bulk_equivalence(self, stepped_subcatchments): + arr = stepped_subcatchments.get_rainfall_bulk() + for i in range(stepped_subcatchments.count): + assert arr[i] == stepped_subcatchments.get_rainfall(i), f"subcatch {i}" + + def test_evap_bulk_equivalence(self, stepped_subcatchments): + arr = stepped_subcatchments.get_evap_bulk() + for i in range(stepped_subcatchments.count): + assert arr[i] == stepped_subcatchments.get_evap(i), f"subcatch {i}" + + def test_infil_bulk_equivalence(self, stepped_subcatchments): + arr = stepped_subcatchments.get_infil_bulk() + for i in range(stepped_subcatchments.count): + assert arr[i] == stepped_subcatchments.get_infil(i), f"subcatch {i}" + + def test_snow_depth_bulk_returns_zeros_placeholder(self, stepped_subcatchments): + """Mirrors the scalar accessor's placeholder behavior — every + entry is 0.0 until snow-state integration lands. When that + integration completes, both this test and the scalar test will + need a corresponding update.""" + arr = stepped_subcatchments.get_snow_depth_bulk() + for i in range(stepped_subcatchments.count): + scalar = stepped_subcatchments.get_snow_depth(i) + assert arr[i] == scalar, f"subcatch {i}" + assert arr[i] == 0.0, f"subcatch {i}: expected placeholder zero" + + def test_ids_bulk_returns_list_of_strings(self, stepped_subcatchments): + ids = stepped_subcatchments.get_ids_bulk() + assert isinstance(ids, list) + assert len(ids) == stepped_subcatchments.count + for s in ids: + assert isinstance(s, str) + + def test_ids_bulk_equivalence_with_scalar(self, stepped_subcatchments): + ids = stepped_subcatchments.get_ids_bulk() + for i, s in enumerate(ids): + assert s == stepped_subcatchments.get_id(i), f"index {i}" + + def test_ids_bulk_handles_short_stride_truncation(self, stepped_subcatchments): + stride = 4 + ids = stepped_subcatchments.get_ids_bulk(stride=stride) + for s in ids: + assert len(s.encode("utf-8")) <= stride - 1 + + def test_ids_bulk_default_stride_no_truncation(self, stepped_subcatchments): + ids = stepped_subcatchments.get_ids_bulk() + for i, s in enumerate(ids): + assert s == stepped_subcatchments.get_id(i) + + def test_whole_network_water_balance_pattern(self, stepped_subcatchments): + """End-to-end pattern: rainfall - infil - evap - runoff per + subcatchment as a quick water-balance check. This is the canonical + MCP / GUI idiom; verifies the four hydrology bulk getters return + equally-sized arrays in matching subcatch order.""" + rain = stepped_subcatchments.get_rainfall_bulk() + infil = stepped_subcatchments.get_infil_bulk() + evap = stepped_subcatchments.get_evap_bulk() + runoff = stepped_subcatchments.get_runoff_bulk() + n = stepped_subcatchments.count + assert rain.shape == (n,) + assert infil.shape == (n,) + assert evap.shape == (n,) + assert runoff.shape == (n,) + # Each subcatch has a non-negative residual when storage is steady. + # We don't assert sign here (snow / storage make the instantaneous + # balance noisy), just that we can compute it without error. + residual = rain - infil - evap - runoff + assert residual.shape == (n,) + + +# ============================================================================ +# Phase 3: Statistics bulk getters (node max_overflow / vol_flooded / +# time_flooded; subcatch max_runoff) +# ============================================================================ + +class TestStatisticsPhase3Bulk: + """Equivalence + non-negativity tests for the Phase 3 statistics-bulk + family. These are cumulative quantities populated over the simulation, + so each test runs against ``completed_solver`` (a fixture that drives + the site_drainage_model through to ENDED) and asserts equivalence with + the post-run scalar accessor. + """ + + def test_node_max_overflow_bulk_shape(self, completed_solver): + from openswmm.engine import Nodes, Statistics + stats = Statistics(completed_solver) + nodes = Nodes(completed_solver) + arr = stats.node_max_overflow_bulk() + assert isinstance(arr, np.ndarray) + assert arr.shape == (nodes.count,) + assert arr.dtype == np.float64 + assert arr.flags["C_CONTIGUOUS"] + + def test_node_max_overflow_bulk_equivalence(self, completed_solver): + from openswmm.engine import Nodes, Statistics + stats = Statistics(completed_solver) + nodes = Nodes(completed_solver) + arr = stats.node_max_overflow_bulk() + for i in range(nodes.count): + assert arr[i] == nodes.get_stat_max_overflow(i), f"node {i}" + + def test_node_vol_flooded_bulk_equivalence(self, completed_solver): + from openswmm.engine import Nodes, Statistics + stats = Statistics(completed_solver) + nodes = Nodes(completed_solver) + arr = stats.node_vol_flooded_bulk() + for i in range(nodes.count): + assert arr[i] == nodes.get_stat_vol_flooded(i), f"node {i}" + + def test_node_time_flooded_bulk_equivalence(self, completed_solver): + from openswmm.engine import Nodes, Statistics + stats = Statistics(completed_solver) + nodes = Nodes(completed_solver) + arr = stats.node_time_flooded_bulk() + for i in range(nodes.count): + assert arr[i] == nodes.get_stat_time_flooded(i), f"node {i}" + + def test_subcatch_max_runoff_bulk_equivalence(self, completed_solver): + from openswmm.engine import Subcatchments, Statistics + stats = Statistics(completed_solver) + subs = Subcatchments(completed_solver) + arr = stats.subcatch_max_runoff_bulk() + for i in range(subs.count): + assert arr[i] == subs.get_stat_max_runoff(i), f"subcatch {i}" + + def test_all_stats_non_negative(self, completed_solver): + """Cumulative non-negative quantities — a regression that reads the + wrong SoA column would likely produce negatives here.""" + from openswmm.engine import Statistics + stats = Statistics(completed_solver) + for arr_name, arr in [ + ("node_max_overflow_bulk", stats.node_max_overflow_bulk()), + ("node_vol_flooded_bulk", stats.node_vol_flooded_bulk()), + ("node_time_flooded_bulk", stats.node_time_flooded_bulk()), + ("subcatch_max_runoff_bulk", stats.subcatch_max_runoff_bulk()), + ]: + assert (arr >= 0).all(), f"{arr_name} has a negative entry" + + def test_flooding_summary_idiom(self, completed_solver): + """End-to-end pattern: pair the three flooding bulk getters with + ``Nodes.get_ids_bulk()`` to assemble a network-wide flooding + summary in 4 C calls instead of 4*N. This is the canonical + replacement for the MCP server's ``get_flooding_summary`` Python + loop.""" + from openswmm.engine import Nodes, Statistics + stats = Statistics(completed_solver) + nodes = Nodes(completed_solver) + ids = nodes.get_ids_bulk() + max_over = stats.node_max_overflow_bulk() + vol_flood = stats.node_vol_flooded_bulk() + t_flood = stats.node_time_flooded_bulk() + # All four results align on the node index. + n = nodes.count + assert len(ids) == n + assert max_over.shape == (n,) + assert vol_flood.shape == (n,) + assert t_flood.shape == (n,) + # The set of "flooded nodes" is consistent across the three stats: + # a node with no flooded time should also have zero flooded volume. + for i in range(n): + if t_flood[i] == 0.0: + assert vol_flood[i] == 0.0, ( + f"{ids[i]}: t_flooded==0 but vol_flooded={vol_flood[i]}") diff --git a/src/engine/2d/api/Api2D.cpp b/src/engine/2d/api/Api2D.cpp index 907a9a347..bacaf5bb1 100644 --- a/src/engine/2d/api/Api2D.cpp +++ b/src/engine/2d/api/Api2D.cpp @@ -12,6 +12,7 @@ #include #include #include "../../core/SWMMEngine.hpp" +#include "../mesh/MeshBuilder.hpp" #include #include @@ -93,6 +94,17 @@ int swmm_2d_vertex_get_xyz_bulk(SWMM_Engine engine, return SWMM_OK; } +int swmm_2d_set_vertex_z(SWMM_Engine engine, int idx, double z) { + GET_ENGINE(engine); + CHECK_2D_ACTIVE(eng); + CHECK_VERT_IDX(idx, router2d); + + auto& m = router2d.mesh(); + m.vz[idx] = z; + openswmm::twoD::recomputeVertexZDependents(m, idx); + return SWMM_OK; +} + int swmm_2d_triangle_get_vertices(SWMM_Engine engine, int idx, int* v0, int* v1, int* v2) { GET_ENGINE(engine); diff --git a/src/engine/2d/mesh/MeshBuilder.cpp b/src/engine/2d/mesh/MeshBuilder.cpp index a3cc41737..2444e7ade 100644 --- a/src/engine/2d/mesh/MeshBuilder.cpp +++ b/src/engine/2d/mesh/MeshBuilder.cpp @@ -211,4 +211,26 @@ std::string validateMesh(const MeshData& mesh) { return {}; } +void recomputeVertexZDependents(MeshData& mesh, int vidx) { + const int nt = mesh.n_triangles(); + for (int t = 0; t < nt; ++t) { + const int v0 = mesh.tri_v0[t]; + const int v1 = mesh.tri_v1[t]; + const int v2 = mesh.tri_v2[t]; + if (v0 != vidx && v1 != vidx && v2 != vidx) continue; + + const double z0 = mesh.vz[v0]; + const double z1 = mesh.vz[v1]; + const double z2 = mesh.vz[v2]; + + mesh.tri_cz[t] = (z0 + z1 + z2) / 3.0; + + // Edge e (0..2) is opposite vertex e; its endpoints are the other two + // vertices of the triangle (same convention as MeshBuilder::edgeVertices). + mesh.edge_mz[t * 3 + 0] = 0.5 * (z1 + z2); + mesh.edge_mz[t * 3 + 1] = 0.5 * (z2 + z0); + mesh.edge_mz[t * 3 + 2] = 0.5 * (z0 + z1); + } +} + } // namespace openswmm::twoD diff --git a/src/engine/2d/mesh/MeshBuilder.hpp b/src/engine/2d/mesh/MeshBuilder.hpp index dee8ad6e6..a98acdbb5 100644 --- a/src/engine/2d/mesh/MeshBuilder.hpp +++ b/src/engine/2d/mesh/MeshBuilder.hpp @@ -43,6 +43,23 @@ void buildMeshTopology(MeshData& mesh); */ std::string validateMesh(const MeshData& mesh); +/** + * @brief Recompute Z-derived per-triangle / per-edge geometry for triangles + * incident to a vertex whose Z just changed. + * + * Updates `tri_cz` (centroid Z = mean of vertex Zs) and `edge_mz` (per-edge + * midpoint Z) for every triangle that references vertex `vidx`. XY-derived + * fields (`tri_area`, `tri_cx`, `tri_cy`, `edge_length`, `edge_nx`, + * `edge_ny`, `edge_mx`, `edge_my`) are not affected. + * + * Used by `swmm_2d_set_vertex_z` and exposed here so tests can verify the + * recompute logic without spinning up a full engine. + * + * @param mesh The mesh; `mesh.vz[vidx]` is assumed to already hold the new Z. + * @param vidx Index of the vertex whose Z just changed. + */ +void recomputeVertexZDependents(MeshData& mesh, int vidx); + } // namespace openswmm::twoD #endif // OPENSWMM_ENGINE_2D_MESH_BUILDER_HPP diff --git a/src/engine/core/openswmm_controls_impl.cpp b/src/engine/core/openswmm_controls_impl.cpp index cf5711a13..f260300bd 100644 --- a/src/engine/core/openswmm_controls_impl.cpp +++ b/src/engine/core/openswmm_controls_impl.cpp @@ -126,11 +126,11 @@ SWMM_ENGINE_API int swmm_control_validate_rule(SWMM_Engine engine, if (line_out) *line_out = -1; // Line-precise reporting not yet plumbed. - if (rc < 0) { + // rc < 0 → parse error; rc == 0 → no rule found (e.g. empty/whitespace-only + // input). Both fail validation: the validator's contract is "this string + // is a valid control rule", and zero rules is not a valid rule. + if (rc <= 0) { if (errbuf && buflen > 0) { - // Generic message — parseRuleText returns -1 without context. - // Line-precise + token-specific messages land in a follow-up - // when parseRuleText itself grows error-out parameters. static constexpr char kMsg[] = "Control-rule parser rejected the rule text"; const int n = std::min(static_cast(sizeof(kMsg) - 1), buflen - 1); std::memcpy(errbuf, kMsg, static_cast(n)); diff --git a/src/engine/core/openswmm_massbalance_impl.cpp b/src/engine/core/openswmm_massbalance_impl.cpp index 218a33755..d7a0c6883 100644 --- a/src/engine/core/openswmm_massbalance_impl.cpp +++ b/src/engine/core/openswmm_massbalance_impl.cpp @@ -108,6 +108,7 @@ SWMM_ENGINE_API int swmm_get_routing_total(SWMM_Engine engine, int component, do case SWMM_ROUTING_SEEP_LOSS: *volume = mb.routing_seep_loss; break; case SWMM_ROUTING_INIT_STORAGE: *volume = mb.routing_init_storage; break; case SWMM_ROUTING_FINAL_STORAGE: *volume = mb.routing_final_storage; break; + case SWMM_ROUTING_FORCING_INFLOW: *volume = mb.routing_forcing_inflow; break; default: return SWMM_ERR_BADPARAM; } return SWMM_OK; diff --git a/tests/unit/engine/data/site_drainage_model.out b/tests/unit/engine/data/site_drainage_model.out index ac871a2b466340a2a9066fe549f051181c46017b..1ca2e33aec0d2920f89bdc6223628319d55dcef5 100644 GIT binary patch literal 331143 zcmeFad0Y+AAOBxbNhK=DlBJC-k+jb_Gj}Q!m93(LBKsC1dzMm3vb9(uLUy4fWGRZ! zVwaul`;wjCd7t^*x${w<>-YFRe*gWx_wlH?z0Z5z=e%EYp7)%YIdco>SjpS-WBe~kxK)?b0`;*@^ z4M;^#r_%9&&0|MT-9wH;!pxjrrap1pk%1jg_z!agcX&ag>e6@Q*XVWbDk>aD|Lh6x zBU$>M+8YY?feN3I-ir4s)PI=ZGgxBp2JZ#?NK1u%WJ|?+6=Qh6iGwz)G=%pmD(diy zyfnxU93p6AJ@WJ4FKDZitHKsZ{w8gXtmCGz!8>UCE&ji4u&=sgUnm8ePc1f`zih*Z zUw$p|EdTO@c$;dmHTuhzLi`T!S}MXz-VN)JKOt|>Laqa70D8ddfBAtgylN}g6N+jr zlhy%ksZV|Y3&2B{{4^jx@QR%Efa^$twmg6k_e%Uum8(s z?H%d=a#`c^=Km`$eaQ>Bj7Zi-a`7MAhcWB-)^(BpE#FFg=BIcw{Ga+teO^AzWKNmu zDfM}OcPYcPouJg`P++Xf_SgnWeNGL$=bYs>-dWyO`BvIy>3exOY}dE1@2pC=4AHph z0(nbwxBGhDMVD~Nj^2nMUun*sox3nbWWPT{>SM@LnrmXj7AI9QUY3VM(sqyDvrlYW z>c*oEFkY4ita5_-J!Z9`V+*|3n^Eit<7M{b4b(roO{Q3;b|}tSjf|IecL!0;YUuWV zFkYq|&2Sn;#>?1Y3#i6Yoz=ZoJE@X+@Ur)#T|iskGgHJI_HC%|D4!QYyR|K{#dhlD zymq)8oI!IdOIVGwa3$@(?gi}}HV^QodYF2t;y%4CsU5Vb6Oc{q*|CwhMTZk~UyZr# z9j0CJr|a>#)Q+QxNfYc*e3Zn{r`5njIkO->%Dn!2iCll zNb5}vo#D`O@p@`+bRY;fKUqfYkUN*xliH`DaHgJht~g%G`<%Z|YlmpBJB(%ju!tzj$(Czo8tV#rsT^P2brOeeBmuyZ_y zJf*p+wV3Ksk8ru}4W^%W7jK>r+oo$?;W&zLX;6PF?L)&pOceWj-Gtpvs-(USQA?=5 zj@xUo>}}_lE_#H^OWC;yG?VH!^WsxS6~d*DZVvTl4xASIq)X*zdzi;QF*-$UH(L0L z{k!AWtIlHym)Ep28PHg&^O7!gTSJLxAG(_ zI5%q~_0O0Y#8__BqyC`7_4Bu>ChU&`=(e4Ks7r~#a9VHDo-DTM;$-TxPj`XSwT^G7 zJ@Q>9J6^vPjS0CgZk$N%##;N)pVsRC(LSD+_c`D)?PCXV?34FDxea2JzwaWG+sQOG zp*Aj`4eP9w72vY#G*+n(a5*tfU8xUn>7Es>)Cag+_$W}R4{&)oB~R=(^8Dnwz~9pM z@^aYD?54};6TTcS+(D4HGFz7<2B(p=N`8#4xk z%Nf_+(qqxvuddjBkBipMqY0PadTLRd8j~!x+wIOv=enf6x9A8h%P9XXmZcw9#Izw? z%IR zZiFDvSgP}@#%Eo02$$X~*CEh0_3SUPEyeZPIjc(Mu?=Ic(|!r{c}%c$iEWcYea9?` zR3B0(R*||NR{cxifJE7nJjg?owp3A}w zTWFkZL4I6UTuJ>qnaUgu($c9j#7^h=+%9T&E}6>B zSyDyqkb8qBfz&?mS`g8Aq~TO6Fb+c}jDQvYx=SBwUtucg9fPft^jn_V3x!(q$asvc&ou zwdtmEVp;W%To)ZuUzeUM=rJ5r8Yh-jb9xfUJkS^E`=&C@0jCET3Hf4(=&(D zdZbP5ppE_PolM`lS!~A6Fm`R`ZEDZ4&2VWyzCMl9%P@x<*~*#P_Z5{nJ+N3z>vOV+ z=EgqTPxEq)U&4$ZKaz=BHvvzLZY|q?b{+KxErvcDNd3P|?1R;!v_&1#%5|vEpV^P` z_evL0mw~NgsK532Yup@J4)t%|e2U91(~eAebOO?vSk2veHHz*Fxqn)`msjtYdDcvt%={>RR;O=6kE8+6$+6@>q zlj`Q*u#bx-;d1t9Ga9q~eBpB)G8}SXh~p?SUM_xOP5aTI(u4o$H!3P!uon|axa?=s z27|^@owuhQVXO(4)kP~XXv=)RAhzZ0i=HkT5-xX!sfcBNCiZ25H}_!d-3H*TEgfVH zbE0XzW|Jf95yJaHd z`(zi&ulAAo#T8IH;H0>QXiPd?waMS)CcCVr>jbp`oKK)flSdqYike8 zWA)m&Dvcv;zdQzPKQ`UOv?F}&oi!Un-qPGNrj0~X2`6)ZcVi)6Y0i_qelRTA?_SUu z40%d(T_;<~SP(8p9Sx;?&6?*ZwrzJ#H)by3GXAwLEz@}QODtO#MA|~?`}lY^EgRoO zUp$siy>>Gl2$yC3mSE6Ks$2g{T1+Ft<;3tC)IZ=35@RN{S>^;>#_tWHeeH?9aP9}W ze}5Cr#1JlnKlI0-aa~`9&W2O;kq6;&spb<5+CE6u5!=!-@tKRBgiDX#tHk)kwMYs~LbJ7~icpJj$KS?t8e@vNz~ z25sm2aUP7}hVjhW$_}_prG*TsZ=(M5b+)BBminXf+G7jk!6ypMUAoWt5pzQ#5j=tBKr zyp;DjIKR9*?3Jh=Xb&&B4Ptzsa7W}4$`;fzUdEQM7j*_7c*)s^_b^_{9vx8X1LLKC zJfqYH#><%NmzDa!c-j2+Yo$IgUM^?*D)o`JA99etwwkSA%n4sE--lz!TbjH1%V}s9 z;lw|HV84CtHaOi-Ec@OpiLoSHb}Sl)K{Kgt4h@$uri9BB%K>!1A8!?( ztDi3?I*%b-Hk>=0>N8IJDD6*>zlLlxvz&0*+M$}OKhYK1)xJ=t@z=F0X zW;%#%3BK5eX(r)vSYlnVY{J>&%>CX84C@k&&z)T;OVB(;`>E-hNaoKhPqg$&9X70U zw9L=4HEjoIvuE@PW@ghocE!#mY`aN+sDI*zVCHS(D5j@=2OP7jvux#nL)2b3sUtJ7 zaTfD?XaQQMd05uj;XSn<{!+ncjm}|wvq#}UE#}K|H-4pd&?5KYUS^ZcM>bVAlXd*H znA$;yaV8CEzCYO4+{^1SYR5CqJ9hN$z>JB1>wIZYj4Y*OB(<*zjbe(IX`qtTg=qKL zGqTCOooM?Z_dx4+%k5EN zIoIG1_eoNs){4hR9z*H_`B80k!FX9c`MuIOFkXh| z*1QfaZL{>fydKzI(InSX2wz{zyfEY~&0XDpE}Bm`S@fkl3;9ZOMva8=GR67~hCHRY zW;Ptnm=G=vczeo8VGkRz{kq4pm{o+!wMk29SzwJk8V?4B}9kh6z`ka|K`4zi-=6!be;$t)p=+OPz7{<@V9*?*!lNFkzQTtC_W9QkM z`;#%Y5z~K9rY!Dx0=2)mw~dJ@Z;E=GUqh8WAIWq}2GIDB`>lq0XwRJQZ2Ea?&ev}n z_5XXk3_A2t)JWd{ieK*zQQtBgpFOLD+7T?L(Mz~C!(E~%?(%jpg zUVs)7PR5k?Vj*8?&P`lCGKlO~SMvmhJf*qztT&8lLAccY*qL(j@@qS>{TfB*m{h{$ ziik8?Hf!!zv8*W>A3aHZY2nXlS^KSa;;~F`Pv*viOP}OW44O%Gn`EMaTnU$WM+Mz4 z;f|WP-^Z>mUBU^MopN)jZT4y5S}){Y;Zee*5-ta3Zp5ImQ9D&}zu%+Apm4%vu8#u? z+77-QB9`IJ=}bEbmo~S0i)G&n*E4z{8O*Z?7T?&IEbF)G9_^>=$a(z3fqv+1&-!ds zSduI^!j|@H&_;a|`K;=Flr<=s&35pACiLqt2WDl}dPei(3uK_$M^M4r(8zsAey9&bdAKzn$}Z4l#52N#h`SXTgC zQYXnTuTpaU{Eyf4el@xLdzY8Ijr9{nlN?_J_ya zVm1&iKW^Sd%R<^H&M%Lfo1=cDzWOgz=$b5@LU-|4_Pa&yr4lYT1dqd@nN+tmkD8#~ zgv;Tp-_V$mUdG~nBN`8277#9XCtA|7CC@d*vQgjeFlmHK&UY^cjWdcGiDe$wXQH`; zOASXi7PNIQ^*uJ9uw886*3=
+XEVB z^n+YRC%Ybcq^5x_zBS-Y2QxIz{%sY^oknk&1i#MM_I9T1v!*$* zn{bnE3D=cChc?eIFs-8(V-x#bGHVST>i=#;fy-;v45oj#NeI`i!>ctAK37hcJY;^4 z>4ttLRio`T8vJRC+0-9$uY6*KP%jm(>1rQt>4=LWm(u?B<@sw-Be`8}gBV92_Yt{- zbp^GJm+L;)WKKHXz5>V7x5b>!s8O#!I6cPnG(>c)4S(R*jsbb-{Q! zsdAdqI8uLkdtp1XkuZ-nx$lJ`Z)xtM+Al#%2q#B;2C$H?G-upX8+9MO&Rx6c7KS{f zxvowgLFch$OKd3%^A7hC+cwPODYJ=inYcBZmNomR7^BY5ut9@JeZPlkQ!WD|2Z(w9 zTDF#HOStS&I0b`dQr!$^G)LZq%aL6_(U_~RD(1f%Cy;YU!sP(d`Lry&TPHDQ-=?3K zEriSX>=PI?u0H7^mZ?^g&m_X7&CNb6XnVn6saVz`_AJv?!sV2Wv&FLYR$G~Aow6Aw z(;k;5Y?tkLUQPRHlMUOM`_F<+xfql_B%Ub z$IwHvJ7^IE6ckt zaw+d~aDM6IUZ3hO?R#8$QpZ%_^0s>qvFw-Wdu9va@>}q6fyc0sP`@8GzRC?VfH#iJ~W>VcW?inCY!ez1BGaBh59O{fD7H4h`8$lefyQr?^oLK$|r!PcvTrXV}oBSoXH2 z8nySn#4z8IvX}sqt7v8K@v@MY!u6f!3vMxAo;O2Q9kp@fc~jmZ$dCHF1XMD8b<~l= zA~QVs&N123b`I1IT9hQVL%D}kxQE}{aO3U^=cAy*{o^Vq{b(xgH2S)%o~{k`k2IIsJLG=(u`3E1uEwSLPU0S} z`CKcP7EkNd;8Jdb80lFNBA2kPpf)a_-_8{Kk-V=-c>*r;cGR4IN_~LK$`>{7T}XX^ z%b_ptDy<8+tk|xYd)3w+7%#iyDN5r=>y^iV?cECt>2;sLX`;(ql2AtK$A;)}F7-R>Gy1cL6O+9jM@P z*Uuhk1gURLLlerS)AXTYf4>})Lb-gOJPCtlQr*gi2Y+3aJgT`yu&K1k{u1-S9giHVE@hoV&ATv`e`{nh4K@u)M z$EAv8URyJm-p7wK8d;t2$`ku!j^kBn|Ghgmi<#&Vf`*4QVoS$o%JS5EQafnV;A1{x zJG_AH=D&n(vi&#h*XNrsOe3dkCb_y4d3lG)u6z^5-BbJ%=CqY5vbdv-Jx{df8y5|u zarW=H!6de>k8T7v#~0=n$_55wY6mUyI<-Y}w6(bXTU&F>x*JkE=wLZg12xOvfOFIz z$XXc+*H;s(UNRm(bUAz8h zc%M34&aX)Bim4{G|9w5SXst#KF6B0eG5YOdkxN)tP#c$lFEW(&ap1BbB44QwaOrdA z-}gx@2H#K`2e>R-t}Y&T=xgwjat2&FKAxyFj?`a>ApEx2CU6|m+D*^_ z!sTo3H7(Pa)Ke_GX=RRb375KWR9MjX_G!hqm%DWnN+(=8`pscM+jl`n#Qo|oC7%Nl zF1g5^V%eFX4Cds#lT2=9dmJ(1fNaLVI<%j*Xr9eHyfGYA_0?xPU)?DibY7T$f;JVs zjxvSUPqG6}E@rpP5bke`!3?vsQ7&`l`UN!FIb0U@?mOkRiR>-2*QXu&RHlvVF0tXO zH;5WvTh&)a?ax2!p{%tgDATMPUVCCbzt|~&+TFtkql~;M$aigL{H&P+zkRf@b^~&E zdN~|DXi=A|=dzR=(^eRd|K>70sZI?pypG?~#40%d(U0Wo~V`KIR^Vq_niu*-btSZ`0xSUs4 zV5sja1(#hO4@BcheLnuevv?cbM~eNuW0JsSNwbL7?cC=rmYMJAf=&=FubryTg2u^JE5)+yUOUlF!sUXKQ7mZNG@w{4 z8=*?B8%wzC5_?oEi>SahW$vPVg+=CWUd2P9%6)mVg=rE$5 z0n+#!hx1gv$np()QG4ZeQ*?Hr75esy!3SQ(@-{=pQTyJXGm%NZ1!!LvXY6>xmA}yE zFttPO1HGrAKL_h_$I%)troS1r|IOuwhkrzkgBV__t3)oLd|@ruV|(1GdH+%# zOTNuHb+85p@-q20?Wom1eGDUOUO$rWFW(wm`t#2?YRf|Z=_8LP-HypwMCY+KkGwJD zEzSMJyLhyWaB?*`h=qKmIgdIiaM|6n2t%IIT%UayNw52C54NK$r0Oa@pXwi0Lz#q2 z{eBl|*~%@7`3HcnZ>)GMFKP+rmoXueF=!^$E#`_akG)R|1#NG% zzbfvx9JNM$BwRjMD-p{E6l`OhR~9l|c3a_fo;k91Hp2RygO~D{%dR7l##sY4F=?;t zy@?O)zo3on+FWK;lYDmR^@VKMyN`5SOxKh#pQj#U<~O*ASUg)69j!wBvkrb|hR1e6 zUB_r+F2kAs6fHbMa<%6z22E~^=J_iT)%%uKH&%~l66LP&_D39|m zdGq_@PfQ&@F1JC9(1|G`m$0q?xJ+Ew zR@^5xFOeC0w&qwU_38Rz26KMN)fyZ~eV*<+&#c-%SgFr|8`~NC7hjb6gk8ySd3e@9 zY@a+QXMxH0+nf-xR%SGLwkmS~hP4=F1@`Vd}XOe4BWW0R%_BxFj>aUpp_WaTnog`d#O3)El zcON3gJlD|;6%#Hk^_#Jvv5}!-K5Ffihw=!Q_4=-4LEHKkPsIK1MRh=a5-yGU-4x4C zj@Zi#+*QN`r&;4vha<9Cf%RxVE!~~Z^n5)EUEgEKs@Y}93|sc2cF<Pbx)L$iP4LWC&fF=*|z*)No@+qgzQ#<6YTQ3GJ?x)Vp z?YfOK94_3q0WRfzE^gOy#-*hpJvP$5C$~}kA~%R!!ny+BQaZFb+Uz67*^?`Y;nTC&2ADGA1XJ0Dyfq872m7aLqYpV<9vA$LFmBx{_O&$Zb6OTnx zE`M(Bk0Ebq?i(`_&~n1boCd>K$XA*(CeMQn%4XTVLfh%z8z)gw5@_mhvggSeUi92Gcaf-)osju(iXy{ zUQQ*AX~vEg^~&k&gbE0kS0^{3F;Bcw+-qF&)e~JHTt<#=#e&90EB1){aRDdMNy24% zqhuDe?YCcXU%c*vZYV&)Wdn;UaldfAJxt-bVrEsZj(Cn&p6s-laKEr%=Se0%e>95k z)RkDt+m6KH=tf$6iFYuyCw%RNObvN7D$)xVRiyD<7Yp}8zQm*<{^d$! zXX}kyju^(@e_u}hA@^aS%TR~u>RjN--Q4#H-KqWWd90g8gBo1QZ4e{1Q@Y3{tShK( zy!276x%O4gKJ;Z6FJE@7$)(f>#!EE2CYMql7%z_m)#Os@1LNg(mzrEkePFzF)rzda zrPN3I9&(VsJnRH6voH0-khe5;EyMxK~QNra!Lt)N&tU^QV(@zmuV?#a%c3L}7hWw@}xQv{+o^si*I2?m! zQr)f|)kQrBmsd93rN>@p+ITVM@_rbdC0x#)*_g(RYonMOPaD+-l@czW>^5UT56Z4=62>#oW}WvL#wY6xd-lds_Gh7RKH6)RD^r?ulDTDE zg2qNf%J?w?m+OYtL1#-HP5!uKQ@Tb zOFu*864n*eHeQyF+%5JadH$Jvg@ zWRWq#efh`T3K{>dW6{$3M(nwwL$XGkFrI-nV}G4wQjJctlh4JlYa6Q4{?5(n&Q!H0 z_on7wKwsk*$c8QwckS032w& zo!_ukc;;mH{M~5f;8b+R+Ygt&9m|g>sigjpdmG0!Xv-}PF23^-uFFJUYX5t@OxdMX zW4x5xAjYZ8Z6cSjuAsK@Qq_H%*pK9WSUTRoc)4lmNu@q8Ub7c)7S&&27_c39Hi};N32WUCL*CNdx0DN9dOZzhAzx|E z{pXW=6J#D6Ho62up3+>$rjTc?2d;BJ8SX%jMUlRfsMU^U^(dDu&RwNt*N!R1%jg{w z&oSQbe?coU75y$wg(ec#D3L_+aB0?B za;>rHF6PRw^US^}?XdmiV=~{p0++Vm3Yi0uV^Jfg(wu#6)x_MjMdSrWJ*@*Ca{IU}>zHsIr@3_z<27;|GA%G-{rBa_ zeoPa-Ljbg?H}WJ?cJm}_F(if^vs}3D`Z2H<)6~3>X+N|St*BZkb9^kEclzjQq9(Up zkWH};c4T_+cYX+5wy1u>bTw^-?&|5`HEkcu9*rAJxdAO++i>V~u|BtNpCjkqq${<9 z4ma+0MC$o*xN~QHzRXd$-_kvA3_9=84>epg5U)JGo6jGQXd#EuS=c_t}PCXm+u!S)?wG?1LNffs~DwqN!uom z0o#7*af}(^>*mQp81k0pKHqi)T0uBzV=#h+e5E;edPvs3k^PQ&Jj0NuG}rc@CQ~kL z5u*L|&VwOh+xqibl*_18;o9kz1r0>@{^U%iTw3WO%H@)xNU`i;*DZ_{xgOi3Vx`W z{S1a&LYD}a;|w;lpzVpZnj(9BGy9=25-!J&RTs-XFU)3CS6yNXqB~=|@)NSaF1oaz zc6ok|IUPG5B@S-FGK+F$JrjiS479n|s*E{RevVzSXce1i*MRzGsQ57c^Up9-)|R6e z<%?w3t_k;B+H|dlGJY~hzp*Y(o7zj5RP!?kOJ>W(nPtxcZFwhS0b?V!c! zP#N-2GvMZ4cj7*+w54{?VVb`+szc_nrgIGV=s{Dcea_bjsL_i7h)o`Zm#gmMUHYw| z_U!$6Xy&gpBx^SmPqUiFuMc}n?U1|4&2^|#hkD%2$U-hTB81xi9xv9@MY1`y6VB3r(&rcD)A`XXQ$XlAb>#LP$72$Gv(MT5ZmFE0@7I|)% z?5BwhSjbbFt55Je`dsw3V&S>yek&vSRWf^_>{&B)tw{8TsN*ax=6TuyRt1<>qgPqO|&!sQq>#ktd- zu4fsIjLS^>YGozFq5dSaF58$LmwHTAzzf`fHa5-3v#f^q*ewqa zvfJO-QGdQ(2;!a|`7)565;z#>D`79Jg{eL&nMS7zw zkSYvz^-y%4f+Q%53L<>5nqfN39{Oars-t5X#!A^32ydVu}rq|_LE}ZW+`lYt>M9-|@PU`KUjvu@tp+Of0WP!qD8KIr zTy|NgSYsm8^xt&>m*I&~O6vkHADTZC`+jY4r0tiN!}iOMqfBSQ*WfLyFyt-G{XokN zXanK$LdkR%@|EU1XXzX?kLI)%D&dmd z_JSU##3aRBHRALlw3yUqTJ234I=8M^%$xE3!eGMXS@tvr&7`_@PBEs>c{SYsmc|@v zzD(S&*;j9LiEueQ-jv2%zj3ixc1$Y_-6LF@dm|P!{xwT+4yk>(65S?TYAoN)g0`2| z3D1dvX077F&`b%J*NuhmBZ4yDHcb#4{E_LXHUoR-K9t?nZ%g~B`Jiu%P5wN@ShZo> zBwUkK&lT=(fHw0kv`5`s$bI>Fx}3|JIO;$B#aYI6{2Ru;o+S=Q-zaOfN?1peT*m~R zn(B>K_cX-Ee+BYkD-)^zPjXMmG(|@4k50!{W3>50R>J)a&|+YNK4?yE6K=-q9^5l^ z;XVfFpjB*(+T|tUa93mgvhyq&N2g>u`lK=pCB5~Uav*^wKlz1fW`sM=&F(n}hT zH}+n{zufVe+9CIPcqe+?uReF|`)zL5mHE{EHFOT*l-p z=1-tCyrlgaxV%4Rxp;g9kN@@oF26oLy5khe7Vz=?a&Uc$-P-!UxYE6v&AODu{b`(i#qGkdQ#uu2H}*U!)cIsMhv8-vrDzsX{<%%Z4c{J!*;W`ZYoiIe! z-&f-?Q?z;47-7BL!N7K?L*+6QH?1>!IXW(OKyW0#!GLBT*A5n;Ic)8op`Ja&#!0hd|NI4Ekq#C^-FxBJ))V*7q%<) zsajRZ=yHnZ8fuH9aW$K1zPU_koU+6NE>Y93DfM~Wv5`v)Ll>n!()P>S0^6&N$#=97 zzLuQ2iXm@l?%mzVy>-G#JJnhn?*Pxec`7RW-L(p-~9k?;N?Txw?t$HL8? z5!<%ha1gpoxa>VwSOdHI+zYXc>70vlNqr-?CsKy4o#-MS%lopnXaM2TXmA}CG?VIf zZDD^jmTnohP8vP(#jy!u|b0_b(uuXHlqEgH`y}Q8#{)Lq3fIS_FJe zL^Eb}BTblbr-7-{0I9WDw3k&&5bB^D#3?-2L_RSi? zLY~rGQ%8_B2ZYNtA8*nA+I*todk3^S&P7#(%ad2TQ-&62D$dRHu~6U0_${lc5O`7rO1L1PV$3R*Z-l>gPrg3dM zYS4<@>nU5rg2t^|1&d{ClNw+X!sU#w_gK)@{FfB=(U8)wl+y(+1P+glzz$s zUB0^+CzP^$yEe;cKfSzQB?=ALhu&WA!GfKrT270zAC=hvxx>jqIE3wH_ShU@YwBWzi}WQboVfyHsd|D*PZ+VE%91|2HshN z+5SO1zve2ngBCG36PYHJMB> z`68WyY&AV90SDLfUsT7n-_M7!?5({}sb2ZT*>u(5`p^NU&c59|9*1q+6xD