diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 52d0c3aa6..f5d0b95da 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,15 +2,14 @@ name: CodeQL on: push: - branches: [main, develop, swmm6_rel] + branches: [main] # drop develop — covered by PR + paths: ['src/**', 'include/**', 'python/**', 'CMakeLists.txt', 'vcpkg.json'] pull_request: - branches: [main, develop, swmm6_rel] - 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" + branches: [main] + 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 d7383fa38..bd51d6642 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -2,7 +2,10 @@ name: Deployment on: push: + branches: [main] tags: ["v*.*.*"] + pull_request: + branches: [main, develop] workflow_dispatch: env: @@ -124,173 +127,123 @@ 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" + # 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 - # 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..5e6149349 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -2,10 +2,12 @@ name: Documentation on: push: - branches: [main, develop, swmm6_rel] + branches: [main, develop] tags: ["v*.*.*"] + paths: ['docs/**', 'python/docs/**', 'python/openswmm/**', 'include/**', '**/*.md'] pull_request: - branches: [main, develop, swmm6_rel] + 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 ec8199292..0e2980a4c 100644 --- a/.github/workflows/regression_testing.yml +++ b/.github/workflows/regression_testing.yml @@ -10,7 +10,7 @@ on: env: VCPKG_ROOT: ${{ github.workspace }}/vcpkg - VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" + VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" OMP_NUM_THREADS: 1 jobs: @@ -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/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..af97e94bc 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,111 @@ 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 || ''); - - - name: Cache vcpkg downloads (source tarballs) - uses: actions/cache@v4 + 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" + # 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() + 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..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 @@ -99,6 +99,151 @@ 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-.*" + # 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. + # 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/.*" + # 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. +# +# `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(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() + +# 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}]==]") + + # 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. + # 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}") +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 +324,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 +375,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 a69b97551..10cc99172 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", - "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 /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 -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 -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 -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 -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", @@ -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/cmake/BundleRuntimeDeps.cmake.in b/cmake/BundleRuntimeDeps.cmake.in new file mode 100644 index 000000000..3d174ca6b --- /dev/null +++ b/cmake/BundleRuntimeDeps.cmake.in @@ -0,0 +1,141 @@ +# +# 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@") + +# 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@) +# 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 + UNRESOLVED_DEPENDENCIES_VAR _unresolved + EXECUTABLES "${_exe}" + 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} +) + +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) diff --git a/include/openswmm/engine/openswmm_2d.h b/include/openswmm/engine/openswmm_2d.h index 7b52c8830..ab3021c53 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. @@ -321,10 +341,20 @@ SWMM_ENGINE_API int swmm_2d_set_abs_tolerance(SWMM_Engine engine, double atol); * 2D Boundary Conditions * ========================================================================= */ -/** Boundary condition type constants. */ +/** Boundary condition type constants. + * + * WALL / NORMAL_FLOW / SPECIFIED_STAGE were the original three; the + * SPECIFIED_FLOW (3) and RATING_CURVE (4) values were added per GUI plan + * §V V-E4 / V-E5. Storage + this C API only at this revision — the + * FV-SWE flux integration for non-Wall BCs is deferred to a separate + * slice (V-E-FLUX). The solver still treats every boundary edge as + * Wall regardless of type today (see SurfaceFluxCalculator.cpp:131). + */ #define SWMM_2D_BC_WALL 0 #define SWMM_2D_BC_NORMAL_FLOW 1 #define SWMM_2D_BC_SPECIFIED_STAGE 2 +#define SWMM_2D_BC_SPECIFIED_FLOW 3 /**< V-E4. */ +#define SWMM_2D_BC_RATING_CURVE 4 /**< V-E5. */ /** @brief Get the number of boundary edges (edges with no neighbour). * @ingroup engine_2d */ @@ -369,6 +399,47 @@ SWMM_ENGINE_API int swmm_2d_set_edge_bc_slope(SWMM_Engine engine, int tri_idx, int edge, double slope); +/** @brief Set the timeseries NAME to drive a SPECIFIED_STAGE edge. + * + * V-E2. Stores the name verbatim; the engine resolves it into a table + * index from `SimulationContext::tables` on the next forcing-step + * lookup (until then `edge_bc_tseries[idx]` is -2). Empty name clears + * the slot (back to constant `edge_bc_head`). + * @ingroup engine_2d */ +SWMM_ENGINE_API int swmm_2d_set_edge_bc_tseries_name(SWMM_Engine engine, + int tri_idx, + int edge, + const char* name); + +/** @brief Get prescribed flow per metre of edge (m³/s/m) for a + * SPECIFIED_FLOW edge. V-E4. + * @ingroup engine_2d */ +SWMM_ENGINE_API int swmm_2d_get_edge_bc_flow(SWMM_Engine engine, + int tri_idx, int edge, + double* flow); + +/** @brief Set prescribed flow per metre of edge (m³/s/m). V-E4. */ +SWMM_ENGINE_API int swmm_2d_set_edge_bc_flow(SWMM_Engine engine, + int tri_idx, int edge, + double flow); + +/** @brief Set the timeseries NAME to drive a SPECIFIED_FLOW edge. + * V-E4 — same resolution contract as `swmm_2d_set_edge_bc_tseries_name`. */ +SWMM_ENGINE_API int swmm_2d_set_edge_bc_flow_tseries_name(SWMM_Engine engine, + int tri_idx, + int edge, + const char* name); + +/** @brief Set the curve NAME to drive a RATING_CURVE edge. + * + * V-E5. Stage → flow lookup is resolved against the existing + * `swmm_curve_*` registry on the next forcing-step lookup. Empty name + * clears the slot. */ +SWMM_ENGINE_API int swmm_2d_set_edge_bc_rating_curve_name(SWMM_Engine engine, + int tri_idx, + int edge, + const char* name); + /** @brief Get cumulative boundary flux at an edge (m³, + = outflow). * @ingroup engine_2d */ SWMM_ENGINE_API int swmm_2d_get_edge_bc_cum_flux(SWMM_Engine engine, 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_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_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_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 330e5f715..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) * ========================================================================= */ @@ -736,6 +960,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 +1064,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 * ========================================================================= */ @@ -761,6 +1138,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_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/include/openswmm/engine/openswmm_nodes.h b/include/openswmm/engine/openswmm_nodes.h index c68f5e826..00d3a1383 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. * @@ -730,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 * ========================================================================= */ @@ -753,6 +885,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_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 9ea6b1657..821ae2045 100644 --- a/include/openswmm/engine/openswmm_statistics.h +++ b/include/openswmm/engine/openswmm_statistics.h @@ -83,6 +83,81 @@ 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); + +/* ------------------------------------------------------------------------- + * 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/engine/openswmm_subcatchments.h b/include/openswmm/engine/openswmm_subcatchments.h index 0f3e1b616..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) * ========================================================================= */ @@ -614,6 +674,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..011b007ed 100644 --- a/include/openswmm/engine/openswmm_tables.h +++ b/include/openswmm/engine/openswmm_tables.h @@ -210,6 +210,73 @@ 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); + +/** + * @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/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/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/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/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 adb65954e..e50675ed2 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,46 @@ 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)*. + * - :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): + +.. 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 +255,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 +271,27 @@ 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)* + * - :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 +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..650a3dc72 100644 --- a/python/openswmm/engine/_2d.pxd +++ b/python/openswmm/engine/_2d.pxd @@ -13,7 +13,8 @@ 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_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) @@ -23,7 +24,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 +38,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..962c80cf9 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}. @@ -101,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. @@ -205,10 +223,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 +238,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 +248,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 +259,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 +269,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 +342,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..6a4ec7b01 100644 --- a/python/openswmm/engine/_2d.pyx +++ b/python/openswmm/engine/_2d.pyx @@ -111,9 +111,33 @@ 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 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. @@ -249,11 +273,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 +291,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 +310,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 +332,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 +357,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 +449,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..5b1e97b19 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) @@ -50,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) @@ -179,13 +191,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) @@ -218,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) @@ -280,13 +321,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 +410,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 +445,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 +466,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 +675,19 @@ 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 + # 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 @@ -663,31 +735,42 @@ 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 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/_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/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..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 # ==================================================================== @@ -839,12 +941,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 +981,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 +990,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 +999,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 +1009,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..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 # ==================================================================== @@ -1082,12 +1281,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 +1357,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 +1389,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 +1408,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..35cb47298 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}. @@ -470,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 1e92d2825..08f4f964a 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,111 @@ 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 + + # ==================================================================== + # 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 4e1880672..61d3da946 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 @@ -480,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 c59a2942e..9c0dc3742 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 @@ -646,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 161deea51..b903e62a4 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,114 @@ 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 + """ + ... + + # ==================================================================== + # 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 807f4cefe..610a11795 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,194 @@ 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 + + # ------------------------------------------------------------------ + # 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/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 8c6c47bcc..4e13ca36a 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.0a1" +version = "6.0.0.dev2" 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,66 @@ 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). +# +# 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) + +# 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 ;; + *) 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 "$VCPKG_TRIPLET" \ + --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 +267,22 @@ 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" } +# 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}" -environment = { VCPKG_ROOT = "$VCPKG_ROOT", CMAKE_ARGS = "$CMAKE_ARGS" } +# 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 python/scripts/cibw_repair_windows.py {wheel} {dest_dir}" +environment = { VCPKG_ROOT = "$VCPKG_ROOT" } # ============================================================================ # pytest @@ -232,3 +290,6 @@ environment = { VCPKG_ROOT = "$VCPKG_ROOT", CMAKE_ARGS = "$CMAKE_ARGS" } [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/scripts/cibw_repair_windows.py b/python/scripts/cibw_repair_windows.py new file mode 100644 index 000000000..06c51c61f --- /dev/null +++ b/python/scripts/cibw_repair_windows.py @@ -0,0 +1,98 @@ +"""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 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 + +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)) 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/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_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_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 b/python/tests/engine/test_new_api.py index 0db1879ca..db2d05c60 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/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/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/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/cli/CMakeLists.txt b/src/cli/CMakeLists.txt index 7e3ad8ed5..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 @@ -42,10 +43,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/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/2d/api/Api2D.cpp b/src/engine/2d/api/Api2D.cpp index 907a9a347..ff20d4cf2 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); @@ -574,7 +586,9 @@ int swmm_2d_set_edge_bc_type(SWMM_Engine engine, int tri_idx, int edge, if (edge < 0 || edge > 2) return SWMM_ERR_BADPARAM; if (bc_type != SWMM_2D_BC_WALL && bc_type != SWMM_2D_BC_NORMAL_FLOW && - bc_type != SWMM_2D_BC_SPECIFIED_STAGE) { + bc_type != SWMM_2D_BC_SPECIFIED_STAGE && + bc_type != SWMM_2D_BC_SPECIFIED_FLOW && // V-E4 + bc_type != SWMM_2D_BC_RATING_CURVE) { // V-E5 return SWMM_ERR_BADPARAM; } @@ -639,4 +653,89 @@ int swmm_2d_get_edge_bc_cum_flux(SWMM_Engine engine, int tri_idx, int edge, return SWMM_OK; } +// ============================================================================= +// V-E2 / V-E4 / V-E5 — TS-name + SPECIFIED_FLOW + RATING_CURVE storage APIs. +// Solver flux integration deferred to V-E-FLUX; today these are storage-only +// (the solver still walls every boundary edge regardless of type). +// ============================================================================= + +int swmm_2d_set_edge_bc_tseries_name(SWMM_Engine engine, int tri_idx, int edge, + const char* name) { + GET_ENGINE(engine); + CHECK_2D_ACTIVE(eng); + CHECK_TRI_IDX(tri_idx, router2d); + if (edge < 0 || edge > 2) return SWMM_ERR_BADPARAM; + + const int idx = tri_idx * 3 + edge; + auto& b = router2d.boundary(); + if (!name || name[0] == '\0') { + b.edge_bc_tseries_name[idx].clear(); + b.edge_bc_tseries[idx] = -1; + } else { + b.edge_bc_tseries_name[idx] = name; + b.edge_bc_tseries[idx] = -2; // deferred resolution + } + return SWMM_OK; +} + +int swmm_2d_get_edge_bc_flow(SWMM_Engine engine, int tri_idx, int edge, + double* flow) { + GET_ENGINE(engine); + CHECK_2D_ACTIVE(eng); + CHECK_TRI_IDX(tri_idx, router2d); + if (edge < 0 || edge > 2 || !flow) return SWMM_ERR_BADPARAM; + + *flow = router2d.boundary().edge_bc_flow[tri_idx * 3 + edge]; + return SWMM_OK; +} + +int swmm_2d_set_edge_bc_flow(SWMM_Engine engine, int tri_idx, int edge, + double flow) { + GET_ENGINE(engine); + CHECK_2D_ACTIVE(eng); + CHECK_TRI_IDX(tri_idx, router2d); + if (edge < 0 || edge > 2) return SWMM_ERR_BADPARAM; + + router2d.boundary().edge_bc_flow[tri_idx * 3 + edge] = flow; + return SWMM_OK; +} + +int swmm_2d_set_edge_bc_flow_tseries_name(SWMM_Engine engine, int tri_idx, int edge, + const char* name) { + GET_ENGINE(engine); + CHECK_2D_ACTIVE(eng); + CHECK_TRI_IDX(tri_idx, router2d); + if (edge < 0 || edge > 2) return SWMM_ERR_BADPARAM; + + const int idx = tri_idx * 3 + edge; + auto& b = router2d.boundary(); + if (!name || name[0] == '\0') { + b.edge_bc_flow_tseries_name[idx].clear(); + b.edge_bc_flow_tseries[idx] = -1; + } else { + b.edge_bc_flow_tseries_name[idx] = name; + b.edge_bc_flow_tseries[idx] = -2; + } + return SWMM_OK; +} + +int swmm_2d_set_edge_bc_rating_curve_name(SWMM_Engine engine, int tri_idx, int edge, + const char* name) { + GET_ENGINE(engine); + CHECK_2D_ACTIVE(eng); + CHECK_TRI_IDX(tri_idx, router2d); + if (edge < 0 || edge > 2) return SWMM_ERR_BADPARAM; + + const int idx = tri_idx * 3 + edge; + auto& b = router2d.boundary(); + if (!name || name[0] == '\0') { + b.edge_bc_rating_curve_name[idx].clear(); + b.edge_bc_rating_curve[idx] = -1; + } else { + b.edge_bc_rating_curve_name[idx] = name; + b.edge_bc_rating_curve[idx] = -2; + } + return SWMM_OK; +} + } // extern "C" diff --git a/src/engine/2d/data/BoundaryData.hpp b/src/engine/2d/data/BoundaryData.hpp index 9b3941325..593f6a14c 100644 --- a/src/engine/2d/data/BoundaryData.hpp +++ b/src/engine/2d/data/BoundaryData.hpp @@ -30,11 +30,19 @@ namespace openswmm::twoD { /** * @brief Boundary condition types for 2D mesh edges. + * + * SPECIFIED_FLOW (3) and RATING_CURVE (4) added per GUI plan §V V-E4 / V-E5. + * Storage + C API only at this revision — the FV-SWE flux integration for + * non-Wall BCs is deferred to a follow-up slice (V-E-FLUX). Today the + * solver treats every boundary edge as Wall regardless of type + * (see SurfaceFluxCalculator::computeEdgeFluxes line 131). */ enum class BoundaryType : int8_t { WALL = 0, ///< Zero-flux wall (default) NORMAL_FLOW = 1, ///< Manning outflow using bed slope - SPECIFIED_STAGE = 2 ///< Prescribed water surface elevation + SPECIFIED_STAGE = 2, ///< Prescribed water surface elevation (constant or TS) + SPECIFIED_FLOW = 3, ///< Prescribed discharge per metre of edge (constant or TS) + RATING_CURVE = 4 ///< Stage → flow lookup (curve registry index) }; /** @@ -69,6 +77,34 @@ struct BoundaryData { /// Tracked for mass balance reporting. std::vector edge_bc_cum_flux; + // ----------------------------------------------------------------------- + // V-E4 — SPECIFIED_FLOW slots (per-metre-of-edge discharge). + // Sign convention: outward-positive (matches edge_bc_cum_flux). + // ----------------------------------------------------------------------- + + /// Prescribed discharge per metre of edge (m³/s/m) for SPECIFIED_FLOW edges. + /// For time-varying: updated each step from timeseries. + std::vector edge_bc_flow; + + /// Timeseries index for time-varying SPECIFIED_FLOW. + /// -1 = constant (use edge_bc_flow as-is), -2 = unresolved name, + /// >= 0 = resolved table index into SimulationContext::tables. + std::vector edge_bc_flow_tseries; + + /// Timeseries name for deferred resolution (cleared after resolve). + std::vector edge_bc_flow_tseries_name; + + // ----------------------------------------------------------------------- + // V-E5 — RATING_CURVE slot (stage → flow lookup). + // ----------------------------------------------------------------------- + + /// Curve registry index for RATING_CURVE edges. + /// -1 = unset, -2 = unresolved name, >= 0 = resolved curve index. + std::vector edge_bc_rating_curve; + + /// Curve name for deferred resolution (cleared after resolve). + std::vector edge_bc_rating_curve_name; + // ----------------------------------------------------------------------- // Lifecycle // ----------------------------------------------------------------------- @@ -85,6 +121,11 @@ struct BoundaryData { edge_bc_tseries.assign(n, -1); edge_bc_tseries_name.resize(n); edge_bc_cum_flux.assign(n, 0.0); + edge_bc_flow.assign(n, 0.0); + edge_bc_flow_tseries.assign(n, -1); + edge_bc_flow_tseries_name.resize(n); + edge_bc_rating_curve.assign(n, -1); + edge_bc_rating_curve_name.resize(n); } int size() const noexcept { return static_cast(edge_bc_type.size()); } 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/CMakeLists.txt b/src/engine/CMakeLists.txt index 04a081368..b549190ef 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) ---- @@ -222,9 +225,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 +254,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 @@ -371,7 +398,70 @@ 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() + + # Linkers on every platform we target drop .obj/.o files from a static + # archive when no source in the consumer references their symbols. The + # swmm_gpkg_* C API exists solely for external consumers (Cython + # _geopackage, host apps) — nothing inside the engine itself calls it — + # so the openswmm_geopackage_impl translation unit is pruned and the + # C API never reaches the engine shared library's export table. + # + # On Windows the failure is loud: 27 LNK2019 errors at the Cython + # _geopackage link step (openswmm.engine.lib has no swmm_gpkg_* in it). + # On Linux/macOS the failure is silent: the wheel builds fine, but + # `from openswmm.engine._geopackage import GeoPackage` raises + # ImportError at runtime (undefined symbol), and test_geopackage.py + # wraps that import in try/except and skips its tests. + # + # Force whole-archive linking so every translation unit in the + # geopackage static archive is retained in the engine shared library: + # - MSVC: /WHOLEARCHIVE: + # - Apple: -Wl,-force_load, + # - GNU ld: -Wl,--whole-archive -Wl,--no-whole-archive + # The visibility attributes on swmm_gpkg_* (SWMM_ENGINE_API → either + # __declspec(dllexport) on MSVC or __attribute__((visibility("default"))) + # elsewhere — see input/geopackage/CMakeLists.txt) then ensure the + # retained symbols are also exported. + if(MSVC) + target_link_options(openswmm_engine PRIVATE + "/WHOLEARCHIVE:$" + ) + elseif(APPLE) + target_link_options(openswmm_engine PRIVATE + "LINKER:-force_load,$" + ) + else() + # GNU ld / lld accept bracketed --whole-archive; the archive path + # must appear between the two flags. + target_link_options(openswmm_engine PRIVATE + "LINKER:--whole-archive" + "$" + "LINKER:--no-whole-archive" + ) + 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)") endif() diff --git a/src/engine/core/InpWriter.cpp b/src/engine/core/InpWriter.cpp index fee08db1f..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 @@ -784,9 +788,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]); @@ -1162,6 +1174,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/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/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_controls_impl.cpp b/src/engine/core/openswmm_controls_impl.cpp index 10024d17b..f260300bd 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. + + // 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) { + 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_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_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_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 1993aca4e..3c36a4660 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 @@ -182,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 // ============================================================================ @@ -474,6 +721,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 // ============================================================================ @@ -758,6 +1117,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 // ============================================================================ @@ -785,4 +1190,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_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/src/engine/core/openswmm_nodes_impl.cpp b/src/engine/core/openswmm_nodes_impl.cpp index 49c231df2..ba42c950c 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; @@ -377,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); @@ -539,6 +619,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 +830,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_statistics_impl.cpp b/src/engine/core/openswmm_statistics_impl.cpp index 2e9bb8f03..30113524b 100644 --- a/src/engine/core/openswmm_statistics_impl.cpp +++ b/src/engine/core/openswmm_statistics_impl.cpp @@ -157,4 +157,102 @@ 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; +} + +// ---------------------------------------------------------------------------- +// 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/core/openswmm_subcatchments_impl.cpp b/src/engine/core/openswmm_subcatchments_impl.cpp index f1bacd448..e8b9e1d9f 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" { // ============================================================================ @@ -535,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 // ============================================================================ @@ -576,6 +651,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..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" { // ============================================================================ @@ -191,4 +209,79 @@ 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; +} + +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/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/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/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/CMakeLists.txt b/src/engine/input/geopackage/CMakeLists.txt index f0222fdcc..c5b8f68b1 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,12 +64,39 @@ 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 + # openswmm_geopackage is a STATIC library whose archive is absorbed into + # the engine SHARED (engine PRIVATE-links geopackage). Its source files + # define the swmm_gpkg_* C API decorated with SWMM_ENGINE_API (from + # openswmm_engine.h). On MSVC, CMake auto-defines openswmm_engine_EXPORTS + # only for the engine target itself, not for sources in static archives + # that get linked into it — so without intervention, SWMM_ENGINE_API + # expands to __declspec(dllimport) when these TUs compile, and the + # compiler rejects defining a function as dllimport (C2491). + # + # We mirror the engine's own _EXPORTS flag on this target so that + # SWMM_ENGINE_API expands to __declspec(dllexport) here. Result: the + # swmm_gpkg_* symbols land in the engine DLL's export table (and thus + # openswmm.engine.lib), so the Cython _geopackage extension — and any + # other external consumer — can dllimport them at link time. + # + # The previous workaround (defining OPENSWMM_ENGINE_STATIC instead) + # made SWMM_ENGINE_API empty, which silenced the compiler but left the + # symbols out of the export table. That caused 27 LNK2019 errors when + # building the Windows Python wheel — see Cython _geopackage link step. + # + # On non-MSVC, openswmm_engine_EXPORTS is harmless: SWMM_ENGINE_API + # expands to __attribute__((visibility("default"))) on all platforms + # under that branch, matching what the engine's own TUs already get. + PRIVATE openswmm_engine_EXPORTS ) # Install 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/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/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/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/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/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/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/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 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..e86349e6c 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 $ @@ -80,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) @@ -104,12 +110,28 @@ 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_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) 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_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 @@ -163,6 +185,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 +214,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/engine/data/site_drainage_model.out b/tests/unit/engine/data/site_drainage_model.out index ac871a2b4..1ca2e33ae 100644 Binary files a/tests/unit/engine/data/site_drainage_model.out and b/tests/unit/engine/data/site_drainage_model.out differ diff --git a/tests/unit/engine/data/site_drainage_model.rpt b/tests/unit/engine/data/site_drainage_model.rpt index 5b52d0a8d..d1436cba0 100644 --- a/tests/unit/engine/data/site_drainage_model.rpt +++ b/tests/unit/engine/data/site_drainage_model.rpt @@ -46,298 +46,5 @@ Head Tolerance ........... 0.005000 ft - ************************** Volume Depth - Runoff Quantity Continuity acre-feet inches - ************************** --------- ------- - Total Precipitation ...... 6.829 2.834 - Evaporation Loss ......... 0.000 0.000 - Infiltration Loss ........ 2.148 0.891 - Surface Runoff ........... 4.625 1.919 - Final Storage ............ 0.062 0.026 - Continuity Error (%) ..... -0.094 - - - ************************** TSS - Runoff Quality Continuity lbs - ************************** ---------- - Initial Buildup .......... 913.850 - Surface Buildup .......... 473.326 - Wet Deposition ........... 0.000 - Sweeping Removal ......... 0.000 - Infiltration Loss ........ 0.000 - BMP Removal .............. 0.000 - Surface Runoff ........... 1286.433 - Remaining Buildup ........ 100.743 - Continuity Error (%) ..... -0.000 - - - ************************** Volume Volume - Flow Routing Continuity acre-feet 10^6 gal - ************************** --------- --------- - Dry Weather Inflow ....... 0.000 0.000 - Wet Weather Inflow ....... 4.625 1.507 - Groundwater Inflow ....... 0.000 0.000 - RDII Inflow .............. 0.000 0.000 - External Inflow .......... 0.000 0.000 - External Outflow ......... 4.621 1.506 - Flooding Loss ............ 0.000 0.000 - Evaporation Loss ......... 0.000 0.000 - Exfiltration Loss ........ 0.000 0.000 - Initial Stored Volume .... 0.000 0.000 - Final Stored Volume ...... 0.000 0.000 - Continuity Error (%) ..... 0.067 - - - ************************** TSS - Quality Routing Continuity lbs - ************************** ---------- - Dry Weather Inflow ....... 0.000 - Wet Weather Inflow ....... 5.917 - Groundwater Inflow ....... 0.000 - RDII Inflow .............. 0.000 - External Inflow .......... 0.000 - External Outflow ......... 9.360 - Flooding Loss ............ 0.000 - Exfiltration Loss ........ 0.000 - Mass Reacted ............. 0.000 - Initial Stored Mass ...... 0.000 - Final Stored Mass ........ 0.000 - Continuity Error (%) ..... -58.196 - - - ************************* - Highest Continuity Errors - ************************* - No errors. - - - *************************** - Time-Step Critical Elements - *************************** - Link C7 (0.26%) - Link C6 (0.01%) - - - ******************************** - Highest Flow Instability Indexes - ******************************** - Link C3 (1) - Link C6 (1) - Link C8 (1) - Link C1 (0) - Link C4 (0) - - - ********************************* - Most Frequent Nonconverging Nodes - ********************************* - Convergence obtained at all time steps. - - - ************************* - Routing Time Step Summary - ************************* - Minimum Time Step : 0.17 sec - Average Time Step : 5.00 sec - Maximum Time Step : 5.00 sec - % of Time in Steady State : 0.00 - Average Iterations per Step : 2.00 - % of Steps Not Converging : 0.00 - Time Step Frequencies : - 5.000 - 2.540 sec : 99.98 % - 2.540 - 1.290 sec : 0.00 % - 1.290 - 0.655 sec : 0.00 % - 0.655 - 0.333 sec : 0.00 % - 0.333 - 0.169 sec : 0.01 % - - - *************************** - Subcatchment Runoff Summary - *************************** - - ------------------------------------------------------------------------------------------------------------------------------ - Total Total Total Total Imperv Perv Total Total Peak Runoff - Precip Runon Evap Infil Runoff Runoff Runoff Runoff Runoff Coeff - Subcatchment in in in in in in in 10^6 gal CFS - ------------------------------------------------------------------------------------------------------------------------------ - S1 2.83 0.00 0.00 0.90 1.59 0.33 1.91 0.24 15.90 0.675 - S2 2.83 0.00 0.00 0.77 1.76 0.28 2.04 0.26 17.70 0.720 - S3 2.83 0.00 0.00 1.26 1.10 0.46 1.56 0.16 11.33 0.551 - S4 2.83 0.00 0.00 1.04 1.39 0.38 1.77 0.33 22.78 0.626 - S5 2.83 0.00 0.00 0.25 2.45 0.10 2.54 0.33 21.51 0.898 - S6 2.83 0.00 0.00 0.10 2.65 0.04 2.69 0.14 9.05 0.950 - S7 2.83 0.00 0.00 2.10 0.00 0.74 0.74 0.05 4.54 0.260 - - - - **************************** - Subcatchment Washoff Summary - **************************** - - ---------------------------------- - TSS - Subcatchment lbs - ---------------------------------- - S1 0.001 - S2 0.001 - S3 0.000 - S4 0.000 - S5 0.001 - S6 0.000 - S7 0.000 - ---------------------------------- - System 0.003 - - - - - ****************** - Node Depth Summary - ****************** - - --------------------------------------------------------------------------------- - Average Maximum Maximum Time of Max Reported - Depth Depth HGL Occurrence Max Depth - Node Type Feet Feet Feet days hr:min Feet - --------------------------------------------------------------------------------- - J1 JUNCTION 0.05 0.65 4973.65 0 12:04 0.65 - J2 JUNCTION 0.12 0.67 4969.67 0 12:04 0.67 - J3 JUNCTION 0.09 0.93 4973.93 0 12:04 0.93 - J4 JUNCTION 0.05 0.65 4971.65 0 12:05 0.65 - J5 JUNCTION 0.10 1.12 4970.92 0 12:05 1.12 - J6 JUNCTION 0.14 1.38 4970.38 0 12:05 1.38 - J7 JUNCTION 0.06 0.77 4972.27 0 12:04 0.77 - J8 JUNCTION 0.11 1.17 4967.67 0 12:05 1.17 - J9 JUNCTION 0.15 1.47 4966.27 0 12:11 1.47 - J10 JUNCTION 0.17 1.56 4965.36 0 12:10 1.56 - J11 JUNCTION 0.23 2.05 4965.05 0 12:10 2.05 - O1 OUTFALL 0.24 2.05 4964.05 0 12:11 2.05 - - - ******************* - Node Inflow Summary - ******************* - - ------------------------------------------------------------------------------------------------- - Maximum Maximum Lateral Total Flow - Lateral Total Time of Max Inflow Inflow Balance - Inflow Inflow Occurrence Volume Volume Error - Node Type CFS CFS days hr:min 10^6 gal 10^6 gal Percent - ------------------------------------------------------------------------------------------------- - J1 JUNCTION 15.90 15.90 0 12:04 0.236 0.236 0.000 - J2 JUNCTION 17.70 17.70 0 12:04 0.263 0.263 0.000 - J3 JUNCTION 11.33 11.33 0 12:04 0.159 0.159 0.000 - J4 JUNCTION 0.00 11.51 0 12:04 0.000 0.159 0.000 - J5 JUNCTION 0.00 27.03 0 12:04 0.000 0.395 0.000 - J6 JUNCTION 0.00 45.93 0 12:05 0.000 0.722 0.000 - J7 JUNCTION 22.78 22.78 0 12:04 0.327 0.327 0.000 - J8 JUNCTION 0.00 42.56 0 12:05 0.000 0.722 0.000 - J9 JUNCTION 0.00 41.98 0 12:05 0.000 0.722 0.000 - J10 JUNCTION 24.60 58.04 0 12:10 0.377 1.099 0.000 - J11 JUNCTION 9.05 76.36 0 12:10 0.145 1.506 0.000 - O1 OUTFALL 0.00 75.62 0 12:11 0.000 1.506 0.000 - - - ********************** - Node Surcharge Summary - ********************** - - No nodes were surcharged. - - - ********************* - Node Flooding Summary - ********************* - - No nodes were flooded. - - - *********************** - Outfall Loading Summary - *********************** - - ------------------------------------------------------------------------- - Flow Avg Max Total Total - Freq Flow Flow Volume TSS - Outfall Node Pcnt CFS CFS 10^6 gal lbs - ------------------------------------------------------------------------- - O1 98.75 1.90 75.62 1.506 149929.589 - ------------------------------------------------------------------------- - System 98.75 1.90 75.62 1.506 149929.589 - - - ******************** - Link Flow Summary - ******************** - - ----------------------------------------------------------------------------- - Maximum Time of Max Maximum Max/ Max/ - |Flow| Occurrence |Veloc| Full Full - Link Type CFS days hr:min ft/sec Flow Depth - ----------------------------------------------------------------------------- - C1 CONDUIT 15.83 0 12:04 2.00 0.05 0.29 - C2 CONDUIT 14.51 0 12:05 2.87 0.32 0.64 - C3 CONDUIT 11.51 0 12:04 9.41 0.34 0.35 - C4 CONDUIT 11.22 0 12:05 1.37 0.05 0.29 - C5 CONDUIT 23.59 0 12:05 1.69 0.15 0.42 - C6 CONDUIT 22.76 0 12:04 2.24 0.07 0.36 - C7 CONDUIT 42.56 0 12:05 13.55 0.32 0.36 - C8 CONDUIT 41.98 0 12:05 2.89 0.16 0.44 - C9 CONDUIT 39.26 0 12:12 2.12 0.28 0.50 - C10 CONDUIT 56.51 0 12:10 2.27 0.30 0.60 - C11 CONDUIT 75.62 0 12:11 10.33 0.39 0.43 - - - *************************** - Flow Classification Summary - *************************** - - ------------------------------------------------------------------------------------- - Adjusted ---------- Fraction of Time in Flow Class ---------- - /Actual Up Down Sub Sup Up Down Norm Inlet - Conduit Length Dry Dry Dry Crit Crit Crit Crit Ltd Ctrl - ------------------------------------------------------------------------------------- - C1 1.00 0.01 0.00 0.00 0.99 0.00 0.00 0.00 0.98 0.00 - C2 1.00 0.01 0.00 0.00 0.00 0.00 0.00 0.99 0.00 0.00 - C3 1.00 0.01 0.00 0.00 0.20 0.80 0.00 0.00 0.17 0.00 - C4 1.00 0.01 0.00 0.00 0.99 0.00 0.00 0.00 0.98 0.00 - C5 1.00 0.01 0.00 0.00 0.99 0.00 0.00 0.00 0.89 0.00 - C6 1.00 0.01 0.00 0.00 0.99 0.00 0.00 0.00 0.98 0.00 - C7 1.28 0.01 0.00 0.00 0.17 0.82 0.00 0.00 0.12 0.00 - C8 1.00 0.01 0.00 0.00 0.99 0.00 0.00 0.00 0.96 0.00 - C9 1.00 0.01 0.00 0.00 0.99 0.00 0.00 0.00 0.99 0.00 - C10 1.00 0.01 0.00 0.00 0.99 0.00 0.00 0.00 0.99 0.00 - C11 1.32 0.01 0.00 0.00 0.17 0.82 0.00 0.00 0.69 0.00 - - ************************* - Conduit Surcharge Summary - ************************* - - No conduits were surcharged. - - - *************************** - Link Pollutant Load Summary - *************************** - - ---------------------------------- - TSS - Link lbs - ---------------------------------- - C1 0.789 - C2 1.277 - C3 0.078 - C4 0.510 - C5 2.487 - C6 0.501 - C7 3.880 - C8 3.881 - C9 3.877 - C10 6.969 - C11 9.360 - - - Analysis begun on: Thu May 14 19:00:26 2026 - Analysis ended on: Thu May 14 19:00:26 2026 - Total elapsed time: < 1 sec + [Report interrupted — simulation did not complete normally] diff --git a/tests/unit/engine/test_2d_surface_routing.cpp b/tests/unit/engine/test_2d_surface_routing.cpp index c03afd0aa..2ba24fe00 100644 --- a/tests/unit/engine/test_2d_surface_routing.cpp +++ b/tests/unit/engine/test_2d_surface_routing.cpp @@ -29,6 +29,7 @@ #include "2d/data/MeshData.hpp" #include "2d/data/SurfaceStateData.hpp" #include "2d/data/SolverOptions2D.hpp" +#include "2d/data/BoundaryData.hpp" #include "2d/mesh/MeshBuilder.hpp" #include "2d/mesh/VertexReconstruction.hpp" #include "2d/solver/SurfaceFluxCalculator.hpp" @@ -171,6 +172,84 @@ TEST(MeshBuilder, EdgeNormalsUnitLength) { } } +TEST(MeshBuilder, RecomputeVertexZDependentsUpdatesIncidentTriangles) { + auto mesh = makeUnitSquareMesh(); + // Both triangles reference v3 = (1,1,0). Bump v3's Z and confirm both + // tri_cz values shift by exactly the per-triangle share (1/3) and the + // three edge midpoints incident to v3 shift by exactly half each. + const double new_z = 3.0; + mesh.vz[3] = new_z; + recomputeVertexZDependents(mesh, 3); + + // T0 vertices: v0=(0,0,0), v1=(1,0,0), v3=(1,1,3) → centroid Z = 1.0 + EXPECT_NEAR(mesh.tri_cz[0], (0.0 + 0.0 + new_z) / 3.0, 1e-12); + // T1 vertices: v0=(0,0,0), v3=(1,1,3), v2=(0,1,0) → centroid Z = 1.0 + EXPECT_NEAR(mesh.tri_cz[1], (0.0 + new_z + 0.0) / 3.0, 1e-12); + + // Edges incident to v3 see midpoint Z = 0.5 * (0 + 3) = 1.5; + // edges not incident to v3 stay at 0. + for (int t = 0; t < mesh.n_triangles(); ++t) { + const int v0 = mesh.tri_v0[t]; + const int v1 = mesh.tri_v1[t]; + const int v2 = mesh.tri_v2[t]; + const int endpoints[3][2] = {{v1, v2}, {v2, v0}, {v0, v1}}; + for (int e = 0; e < 3; ++e) { + const int va = endpoints[e][0]; + const int vb = endpoints[e][1]; + const double expected = 0.5 * (mesh.vz[va] + mesh.vz[vb]); + EXPECT_NEAR(mesh.edge_mz[t * 3 + e], expected, 1e-12) + << "t=" << t << " e=" << e; + } + } +} + +TEST(BoundaryData, ResizeInitialisesAllSlotsIncludingV_E4andV_E5) { + // Verifies V-E4 (SPECIFIED_FLOW) + V-E5 (RATING_CURVE) slots resize + // correctly alongside the legacy stage/slope slots, and that the + // default values are the "unset" / "constant zero" sentinels the + // API documents. + BoundaryData b; + b.resize(12); // 4 triangles * 3 edges + EXPECT_EQ(b.size(), 12); + for (int i = 0; i < 12; ++i) { + EXPECT_EQ(b.edge_bc_type[i], static_cast(BoundaryType::WALL)); + EXPECT_DOUBLE_EQ(b.edge_bed_slope[i], 0.0); + EXPECT_DOUBLE_EQ(b.edge_bc_head[i], 0.0); + EXPECT_EQ(b.edge_bc_tseries[i], -1); + EXPECT_TRUE(b.edge_bc_tseries_name[i].empty()); + EXPECT_DOUBLE_EQ(b.edge_bc_cum_flux[i], 0.0); + + EXPECT_DOUBLE_EQ(b.edge_bc_flow[i], 0.0); + EXPECT_EQ(b.edge_bc_flow_tseries[i], -1); + EXPECT_TRUE(b.edge_bc_flow_tseries_name[i].empty()); + + EXPECT_EQ(b.edge_bc_rating_curve[i], -1); + EXPECT_TRUE(b.edge_bc_rating_curve_name[i].empty()); + } +} + +TEST(MeshBuilder, RecomputeVertexZDependentsLeavesNonIncidentTrianglesAlone) { + // Two disjoint triangles — modifying a vertex of one must not touch + // the centroid Z of the other. + MeshData mesh; + mesh.resize_vertices(6); + mesh.vx = {0, 1, 0, 10, 11, 10}; + mesh.vy = {0, 0, 1, 10, 10, 11}; + mesh.vz = {0, 0, 0, 0, 0, 0}; + + mesh.resize_triangles(2); + mesh.tri_v0[0] = 0; mesh.tri_v1[0] = 1; mesh.tri_v2[0] = 2; + mesh.tri_v0[1] = 3; mesh.tri_v1[1] = 4; mesh.tri_v2[1] = 5; + mesh.mannings_n[0] = 0.035; + mesh.mannings_n[1] = 0.035; + buildMeshTopology(mesh); + + mesh.vz[1] = 9.0; + recomputeVertexZDependents(mesh, 1); + EXPECT_NEAR(mesh.tri_cz[0], 3.0, 1e-12); // (0 + 9 + 0) / 3 + EXPECT_NEAR(mesh.tri_cz[1], 0.0, 1e-12); // untouched +} + TEST(MeshBuilder, ValidationRejectsNegativeArea) { MeshData mesh; mesh.resize_vertices(3); 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_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_geopackage.cpp b/tests/unit/engine/test_geopackage.cpp index 63fa3dc03..7f918659e 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"); + } } // ============================================================================ @@ -879,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/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_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_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_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_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_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_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. 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 new file mode 100644 index 000000000..320d558db --- /dev/null +++ b/tests/unit/engine/test_statistics_bulk_phase3.cpp @@ -0,0 +1,287 @@ +/** + * @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 +#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; + } +} + +// --------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------- + +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_tags.cpp b/tests/unit/engine/test_tags.cpp new file mode 100644 index 000000000..affab6ebb --- /dev/null +++ b/tests/unit/engine/test_tags.cpp @@ -0,0 +1,162 @@ +/** + * @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); + + // 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); + 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); + + // 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); +} 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 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). +} 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..27f33044b 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: Tue May 26 01:56:51 2026 + Analysis ended on: Tue May 26 01:56:51 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..e9576a5b3 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: Tue May 26 01:56:51 2026 + Analysis ended on: Tue May 26 01:56:51 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..843c228af 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: Tue May 26 01:56:59 2026 + Analysis ended on: Tue May 26 01:56:59 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..9f05937d1 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: Tue May 26 01:56:52 2026 + Analysis ended on: Tue May 26 01:56:52 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 9bf07e95c..df7609797 100644 Binary files a/tests/unit/legacy/engine/data/hotstart/hotstart_end.hsf and b/tests/unit/legacy/engine/data/hotstart/hotstart_end.hsf differ diff --git a/tests/unit/legacy/engine/data/hotstart/site_drainage_model_use_hotstart_v1.rpt b/tests/unit/legacy/engine/data/hotstart/site_drainage_model_use_hotstart_v1.rpt index 98682e84d..9617fb5f0 100644 --- a/tests/unit/legacy/engine/data/hotstart/site_drainage_model_use_hotstart_v1.rpt +++ b/tests/unit/legacy/engine/data/hotstart/site_drainage_model_use_hotstart_v1.rpt @@ -311,6 +311,6 @@ C11 0.000 - Analysis begun on: Sat May 23 11:17:41 2026 - Analysis ended on: Sat May 23 11:17:41 2026 + Analysis begun on: Tue May 26 01:56:52 2026 + Analysis ended on: Tue May 26 01:56:52 2026 Total elapsed time: < 1 sec \ No newline at end of file diff --git a/tests/unit/legacy/output/CMakeLists.txt b/tests/unit/legacy/output/CMakeLists.txt index d7aa81fef..74d7365d4 100644 --- a/tests/unit/legacy/output/CMakeLists.txt +++ b/tests/unit/legacy/output/CMakeLists.txt @@ -49,3 +49,5 @@ set_target_properties( RUNTIME_OUTPUT_DIRECTORY ${TEST_BIN_DIRECTORY} INSTALL_RPATH "${LIB_ROOT}" ) + +openswmm_stage_test_runtime_deps(test_output) diff --git a/vcpkg.json b/vcpkg.json index 85aafec00..5a7d9aeff 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", @@ -16,6 +15,10 @@ }, { "name": "gtest" + }, + { + "name": "sqlite3", + "features": ["rtree"] } ], "default-features": [