diff --git a/.github/workflows/cff-validator.yml b/.github/workflows/cff-validator.yml index 598eaed..c80e6ae 100644 --- a/.github/workflows/cff-validator.yml +++ b/.github/workflows/cff-validator.yml @@ -1,27 +1,38 @@ name: Validate CITATION.cff on: - push: - branches: ["main", "dev"] - paths: - - 'CITATION.cff' - - '.github/workflows/cff-validator.yml' - pull_request: - branches: ["main", "dev"] - paths: - - 'CITATION.cff' - - '.github/workflows/cff-validator.yml' + ## Auto-triggers are disabled by default. Uncomment the + ## push/pull_request blocks below to enable validation on branches/PRs. Until + ## then, the workflow runs only via manual dispatch (Actions tab → Run workflow). + #push: + # branches: ["main"] + # paths: + # - 'CITATION.cff' + # - '.github/workflows/cff-validator.yml' + #pull_request: + # branches: ["main"] + # paths: + # - 'CITATION.cff' + # - '.github/workflows/cff-validator.yml' workflow_dispatch: +# Cancel in-progress runs for the same ref when a new run is triggered +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: Validate-CITATION-cff: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # current stable Ubuntu (Aug 2026) name: Validate CITATION.cff env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Validate CITATION.cff - uses: dieghernan/cff-validator@v4 + uses: dieghernan/cff-validator@v5 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 2d1bd1d..66f3a1f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,93 +1,168 @@ name: Test with pytest on: + ## Auto-triggers are disabled by default. Uncomment the + ## push/pull_request blocks below to enable CI on branches/PRs. Until then, + ## the workflow runs only via manual dispatch (Actions tab → Run workflow). + #push: + # branches: + # - main + # paths: + # - 'src/**' + # - 'tests/**' + # - 'pyproject.toml' + # - '.github/workflows/pytest.yml' + #pull_request: + # branches: + # - main + # paths: + # - 'src/**' + # - 'tests/**' + # - 'pyproject.toml' + # - '.github/workflows/pytest.yml' workflow_dispatch: - push: - branches: - - main - pull_request: - branches: - - main - - dev concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true +# Least-privilege default token permissions +permissions: + contents: read + pull-requests: read + +# LIBRARY_BASE_REPO: The base repo with the C++ implementation, e.g. "NTIA/itm". +# LIBRARY_RELEASE_TAG: The Git tag identifying the binary release to test. Can be a pre-release. +# LIBRARY_DESTINATION_DIRECTORY: Path in this repo where shared library files should be placed. env: - LIBRARY_BASE_REPO: NTIA/LFMF - LIBRARY_RELEASE_TAG: v1.1 - LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/Propagation/LFMF' + LIBRARY_BASE_REPO: NTIA/LFMF + LIBRARY_RELEASE_TAG: v1.1 + LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/Propagation/LFMF' jobs: + should-run: + runs-on: ubuntu-24.04 # current stable Ubuntu (Aug 2026) + outputs: + should_run: ${{ steps.decide.outputs.should_run }} + steps: + - name: Skip duplicate runs for merged PRs + id: decide + uses: actions/github-script@v9 + with: + script: | + if (context.eventName !== 'push') { + core.setOutput('should_run', 'true'); + return; + } + + const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha + }); + + const hasMainPullRequest = pullRequests.some((pullRequest) => pullRequest.base.ref === 'main'); + core.setOutput('should_run', hasMainPullRequest ? 'false' : 'true'); + run-all-tests: + if: ${{ needs.should-run.outputs.should_run == 'true' }} + needs: should-run name: ${{ matrix.platform.os-name }} / Py${{ matrix.py }} runs-on: ${{ matrix.platform.os-runner }} strategy: fail-fast: false matrix: + # As of Aug 2026, current stable runner images are windows-2025, ubuntu-24.04, + # and macos-15 (arm64) / macos-15-intel. platform: - os-name: 'Windows (64-bit)' - os-runner: 'windows-latest' + os-runner: 'windows-2025' # current stable, Aug 2026 arch-id: 'x64' release-file-pattern: '*-x64.dll' - os-name: 'Windows (32-bit)' - os-runner: 'windows-latest' + os-runner: 'windows-2025' # current stable, Aug 2026 arch-id: 'x86' release-file-pattern: '*-x86.dll' - os-name: 'macOS (intel/x64)' - os-runner: 'macos-13' + os-runner: 'macos-15-intel' # Explicit Intel runner required for x64 Python setup arch-id: 'x64' release-file-pattern: '*.dylib' - os-name: 'macOS (apple/arm64)' - os-runner: 'macos-latest' + os-runner: 'macos-15' # Apple silicon (arm64), current stable (Aug 2026) arch-id: 'arm64' release-file-pattern: '*.dylib' - os-name: 'Linux (Ubuntu)' - os-runner: 'ubuntu-latest' + os-runner: 'ubuntu-24.04' # current stable, Aug 2026 arch-id: 'x64' release-file-pattern: '*.so' - py: # Python versions to test on all platforms + # Non-Linux runners test only the supported bounds (oldest + newest Python) + # to save runner minutes; Linux covers the full supported range. The bounds + # match pyproject `requires-python = ">=3.9"` (newest = 3.14). + py: - "3.9" - - "3.10" - - "3.11" - - "3.12" + - "3.14" + include: + # Full Python-version coverage on Linux only. + - platform: + os-name: 'Linux (Ubuntu)' + os-runner: 'ubuntu-24.04' + arch-id: 'x64' + release-file-pattern: '*.so' + py: "3.10" + - platform: + os-name: 'Linux (Ubuntu)' + os-runner: 'ubuntu-24.04' + arch-id: 'x64' + release-file-pattern: '*.so' + py: "3.11" + - platform: + os-name: 'Linux (Ubuntu)' + os-runner: 'ubuntu-24.04' + arch-id: 'x64' + release-file-pattern: '*.so' + py: "3.12" + - platform: + os-name: 'Linux (Ubuntu)' + os-runner: 'ubuntu-24.04' + arch-id: 'x64' + release-file-pattern: '*.so' + py: "3.13" steps: - - name: Check out repository - uses: actions/checkout@v4 - with: - submodules: true + - name: Check out repository + uses: actions/checkout@v7 + with: + submodules: true - # Cache key is unique to the combination of runner OS + architecture (matrix.arch-id) + release tag - - name: Restore ${{ env.LIBRARY_RELEASE_TAG }} binaries from cache if available - id: cache-restore - uses: actions/cache@v4 - with: - key: ${{ runner.os }}-${{ matrix.platform.arch-id }}-${{ env.LIBRARY_RELEASE_TAG }} - path: ${{ env.LIBRARY_DESTINATION_DIRECTORY}}/${{ matrix.platform.release-file-pattern }} + # Cache key is unique to the combination of runner OS + architecture (matrix.arch-id) + release tag + - name: Restore ${{ env.LIBRARY_RELEASE_TAG }} binaries from cache if available + id: cache-restore + uses: actions/cache@v6 + with: + key: ${{ runner.os }}-${{ matrix.platform.arch-id }}-${{ env.LIBRARY_RELEASE_TAG }} + path: ${{ env.LIBRARY_DESTINATION_DIRECTORY}}/${{ matrix.platform.release-file-pattern }} - # Only the binaries required for the current platform are downloaded. Note that the distributed - # wheel for proplib python packages includes all binaries, so that the wheel is inherently cross-platform. - - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} binaries - if: ${{ steps.cache-restore.outputs.cache-hit != 'true' }} - uses: robinraju/release-downloader@v1 - with: - repository: ${{ env.LIBRARY_BASE_REPO }} - tag: ${{ env.LIBRARY_RELEASE_TAG }} - fileName: ${{ matrix.platform.release-file-pattern }} - tarBall: false - zipBall: false - out-file-path: ${{ env.LIBRARY_DESTINATION_DIRECTORY }} + # Only the binaries required for the current platform are downloaded. Note that the distributed + # wheel for proplib python packages includes all binaries, so that the wheel is inherently cross-platform. + - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} binaries + if: ${{ steps.cache-restore.outputs.cache-hit != 'true' }} + uses: robinraju/release-downloader@v1.13 + with: + repository: ${{ env.LIBRARY_BASE_REPO }} + tag: ${{ env.LIBRARY_RELEASE_TAG }} + fileName: ${{ matrix.platform.release-file-pattern }} + tarBall: false + zipBall: false + out-file-path: ${{ env.LIBRARY_DESTINATION_DIRECTORY }} - - name: Set up Python ${{ matrix.py }} - uses: actions/setup-python@v5 - with: - architecture: ${{ matrix.platform.arch-id }} - python-version: ${{ matrix.py }} - cache: 'pip' + - name: Set up Python ${{ matrix.py }} + uses: actions/setup-python@v6 + with: + architecture: ${{ matrix.platform.arch-id }} + python-version: ${{ matrix.py }} + cache: 'pip' - - name: Install dependencies for testing - run: python -m pip install -e .[tests] + - name: Install dependencies for testing + run: python -m pip install -e .[tests] - - name: Run pytest - run: pytest --cov-report=term-missing --no-cov-on-fail --cov + - name: Run pytest + run: pytest --cov-report=term-missing --no-cov-on-fail --cov diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08fc2f9..7280523 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,42 +1,49 @@ # Action builds a universal (Win32/Win64/macOS-universal/Linux-x64) Python wheel # from the source code, using Hatchling, and uploads it as an artifact. An sdist (.tar.gz) is # also uploaded, which includes all platform shared library files. These artifacts should be -# used when creating new releases on PyPI and GitHub. The action is triggered by pushes into `main` -# or pull_requests into `main` or `dev` (for testing). To aid in releases, the workflow is -# also triggered when new SemVer tags are created. +# used when creating new releases on PyPI and GitHub. The action is triggered when a new SemVer +# tag (v*) is pushed, and can also be run manually via workflow_dispatch. name: Build Release Artifacts on: - workflow_dispatch: + ## This release build is triggered when a SemVer tag (v*) is pushed, and can + ## also be run manually via workflow_dispatch. Branch/PR auto-triggers are + ## intentionally not used here. push: - branches: - - main tags: - 'v[0-9]+.*' - pull_request: - branches: - - main - - dev + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +# Least-privilege default token permissions +permissions: + contents: read +# LIBRARY_BASE_REPO: The base repo with the C++ implementation, e.g. "NTIA/itm". +# LIBRARY_RELEASE_TAG: The Git tag identifying the binary release to test. Can be a pre-release. +# LIBRARY_DESTINATION_DIRECTORY: Path in this repo where shared library files should be placed. env: - LIBRARY_BASE_REPO: NTIA/LFMF - LIBRARY_RELEASE_TAG: v1.1 - LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/Propagation/LFMF/' + LIBRARY_BASE_REPO: NTIA/LFMF + LIBRARY_RELEASE_TAG: v1.1 + LIBRARY_DESTINATION_DIRECTORY: 'src/ITS/Propagation/LFMF' jobs: build_wheel: name: Build a universal, cross-platform wheel - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # current stable Ubuntu (Aug 2026) steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: submodules: true # Only the binaries required for the current platform are downloaded. Note that the distributed # wheel for proplib python packages includes all binaries, so that the wheel is inherently cross-platform. - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} Windows binaries - uses: robinraju/release-downloader@v1 + uses: robinraju/release-downloader@v1.13 with: repository: ${{ env.LIBRARY_BASE_REPO }} tag: ${{ env.LIBRARY_RELEASE_TAG }} @@ -46,7 +53,7 @@ jobs: out-file-path: ${{ env.LIBRARY_DESTINATION_DIRECTORY }} - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} Linux binaries - uses: robinraju/release-downloader@v1 + uses: robinraju/release-downloader@v1.13 with: repository: ${{ env.LIBRARY_BASE_REPO }} tag: ${{ env.LIBRARY_RELEASE_TAG }} @@ -56,7 +63,7 @@ jobs: out-file-path: ${{ env.LIBRARY_DESTINATION_DIRECTORY }} - name: Download required ${{ env.LIBRARY_RELEASE_TAG }} macOS binaries - uses: robinraju/release-downloader@v1 + uses: robinraju/release-downloader@v1.13 with: repository: ${{ env.LIBRARY_BASE_REPO }} tag: ${{ env.LIBRARY_RELEASE_TAG }} @@ -66,7 +73,7 @@ jobs: out-file-path: ${{ env.LIBRARY_DESTINATION_DIRECTORY }} - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.13' @@ -76,7 +83,7 @@ jobs: - name: Build wheels run: hatchling build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: Release Artifacts (sdist and wheel) path: dist/* diff --git a/.zenodo.json b/.zenodo.json index e4a5acc..833748a 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -14,7 +14,7 @@ "orcid": "0000-0001-8437-6504" } ], - "description": "This code repository contains a Python wrapper for the NTIA/ITS implementation of the Low Frequency / Medium Frequency (LF/MF) Propagation Model.", + "description": "This code repository contains a Python wrapper for the NTIA/ITS implementation of the Low Frequency / Medium Frequency (LF/MF) Propagation Model. This Python package wraps the NTIA/ITS C++ implementation.", "keywords": [ "propagation", "communications", diff --git a/CITATION.cff b/CITATION.cff index a4ce4c3..9aed93e 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,8 +7,8 @@ authors: - family-names: Heroy given-names: Chen affiliation: >- - U.S. Department of Commerce, National - Telecommunications and Information Administration, + U.S. Department of Commerce, + National Telecommunications and Information Administration, Institute for Telecommunication Sciences orcid: 'https://orcid.org/0009-0006-8728-4502' email: cheroy.ctr@ntia.gov @@ -21,8 +21,8 @@ authors: orcid: 'https://orcid.org/0000-0001-8437-6504' email: aromaniello@ntia.gov - name: >- - U.S. Department of Commerce, National - Telecommunications and Information Administration, + U.S. Department of Commerce, + National Telecommunications and Information Administration, Institute for Telecommunication Sciences address: 325 Broadway city: Boulder diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4c5d11..a1d9b41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,18 +51,16 @@ When complete, features branches should merge into `dev`. ### Git Submodules -Software in the ITS Propagation Library is implemented primarily in C++. Each piece -of software has a primary repository which contains the base C++ implementation, -test data and resources, and common files used by the multi-language wrappers. -Interfaces for additional programming languages are provided in separate repositories, -which are linked to the primary repository as [Git submodules](https://gist.github.com/gitaarik/8735255). -When cloning the primary repository, the submodules are not additionally cloned -by default. This can be done with the `git submodule init` command. Initializing -the submodule as part of the parent repository will let you use the build -configuration from the primary repository to compile the C++ source and place it -appropriately for use by the wrapper code. If you choose to independently clone -the wrapper repository, you will likely need to separately download the compiled -library (for example, a DLL from a GitHub release). +PropLib C++ repositories make use of Git submodules to reference certain development +dependencies, e.g. GoogleTest. Depending on the CMake preset or options used, submodules +may be required to successfully build and/or test the software. When cloning a repository, +submodules are not additionally cloned by default. Use the following commands to initialize +and clone any submodules in a repository: + +```cmd +git submodule init +git submodule update +``` ### Contributing on GitHub @@ -130,35 +128,27 @@ repository. For details about wrapper repositories, refer to their own README fi ```bash app/ # The command-line driver which can run the library - data/ # Example input and output files for use with the driver include/ # Headers used by the command-line driver src/ # Source code for the command-line driver tests/ # Header and source files for testing the command-line driver CMakeLists.txt # Configuration for the command-line driver and its tests - README.md # Usage information for the command-line driver docs/ CMakeLists.txt # Doxygen configuration ... # Static files (images, HTML, CS, Markdown) used by Doxygen extern/ - ... # External Git submodules/dependencies + test-data/ # Git submodule containing test data files shared with wrappers + ... # Other external Git submodules/dependencies include/ - / # Include namespace folder, e.g. "ITS.Propagation.ITM" - .h # Library header files go here, e.g. "ITM.h" and "ErrorCodes.h" + .h # Library interface header file goes here, e.g. "ITM.h" src/ .cpp # Source files go here, e.g. "LongleyRice.cpp" and "FreeSpaceLoss.cpp" CMakeLists.txt # Configures cross-platform build tests/ - data/ - .csv # Testing data goes here. Does not have to be CSV. .cpp # Unit tests, usually one test file per source file. .h # Any headers used by tests go here as well. CMakeLists.txt # CTest+GTest config. Files containing tests must be included here. -wrap/ - dotnet/ # C#/.NET wrapper submodule. Should contain CMakeLists.txt - matlab/ # MATLAB wrapper submodule. Should contain CMakeLists.txt - python/ # Python wrapper submodule. Should contain CMakeLists.txt CMakeLists.txt # Top-level CMakeLists.txt: project metadata and options -CMakePresets.json # Presets for CMake, e.g. "release", "debug", etc. +CMakePresets.json # Presets for CMake, e.g. "release64", "debug32", etc. ... ``` @@ -179,7 +169,6 @@ The following CMake options are used for top-level project configuration: | `RUN_DRIVER_TESTS` | `ON` | Test the command-line driver executable | | `DOCS_ONLY` | `OFF` | Skip all steps _except_ generating the documentation site | | `RUN_TESTS` | `ON` | Run unit tests for the main library | -| `COPY_TO_WRAPPERS` | `ON` | Copy the compiled shared library into wrapper submodules | [CMake Presets](https://cmake.org/cmake/help/latest/manual/cmake-presets.7.html) are provided to support common build configurations. These are specified in the @@ -202,21 +191,21 @@ generating the Doxygen documentation site. Below are some examples of how CMake can be called to compile this software. ```bash -# Configure and compile in release configuration -cmake --preset release -cmake --build --preset release +# Configure and compile in 64-bit release configuration +cmake --preset release64 +cmake --build --preset release64 -# Use the release configuration but don't build Doxygen docs -cmake --preset release -DBUILD_DOCS=OFF -cmake --build --preset release +# Use the 64-bit release configuration but don't build Doxygen docs +cmake --preset release64 -DBUILD_DOCS=OFF +cmake --build --preset release64 -# Configure and compile in debug configuration -cmake --preset debug -cmake --build --preset debug +# Configure and compile in 32-bit debug configuration +cmake --preset debug32 +cmake --build --preset debug32 -# Use the release configuration but don't run driver tests -cmake --preset release -DRUN_DRIVER_TESTS=OFF -cmake --build --preset release +# Use the 64-bit release configuration but don't run driver tests +cmake --preset release64 -DRUN_DRIVER_TESTS=OFF +cmake --build --preset release64 ``` ### Supported Platforms and Build Options @@ -243,7 +232,7 @@ example showing the expected documentation formats. Except for inline documentat use the JavaDoc banner style [described by Doxygen](https://www.doxygen.nl/manual/docblocks.html) ```cpp -constexpr double = PI 3.1415; /**< Inline format, e.g. for constants */ +constexpr double = PI 3.1415; ///< Inline format, e.g. for constants /******************************************************************************* * This is a brief description of the function. @@ -273,8 +262,36 @@ the Doxygen site to GitHub Pages. ### MATLAB Wrappers -Most code in the MATLAB wrapper is actually written in C. In these files, the same -documentation style as noted above for C++ should be used. +MATLAB® wrappers are implemented as toolboxes which interface with the shared library +compiled from C++ source code. The project structure is informed by the best practices +provided by MathWorks® in their [`toolboxdesign` repository](https://github.com/mathworks/toolboxdesign). +Here is an example of how a function may be documented in a MATLAB wrapper. Note the +documentation with code, where input and output arguments are provided for autocompletion. + +```matlab +function y = DoubleTheInput(x) +% DoubleTheInput - produces an output which is twice its input. +% +% Syntax: +% y = DoubleTheInput(x) +% +% Input Arguments: +% x (double) - A number which needs doubling +% +% Output Arguments: +% y (double) - The result, 2*x +% +% Description: +% Functions more complex than this one may warrant an additional, +% longer description. +arguments (Input) + x double +end +arguments (Output) + y double +end +... +``` ### Python Wrappers @@ -302,9 +319,9 @@ def double_the_input(x: float) -> float: return 2 * x ``` -### C#/.NET Wrappers +### .NET Wrappers -In C#/.NET, documentation comments are written in +PropLib .NET wrappers are written in C# and documentation comments are written in [XML format](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/documentation-comments) and are used to generate documentation through tools like Visual Studio. Use `` tags to provide brief descriptions of classes, constants, functions, etc. Functions should diff --git a/GitHubRepoPublicReleaseApproval.md b/GitHubRepoPublicReleaseApproval.md index f2af819..a5d6176 100644 --- a/GitHubRepoPublicReleaseApproval.md +++ b/GitHubRepoPublicReleaseApproval.md @@ -1,8 +1,8 @@ # GitHub Repository Public Release Approval -**Project Name:** NTIA/OSM Research and Development +**Project Name:** NTIA/OSM Research and Development - Propagation Library -**Software Name:** Low Frequency / Medium Frequency (LF/MF) Propagation Model, Python Wrapper +**Software Name:** Low Frequency / Medium Frequency (LF/MF) Propagation Model, Python® Wrapper The project identified above, which is contained within the repository this document is stored in, has met the following criteria for public release: @@ -18,14 +18,14 @@ mark next to each attests that the criterion has been met. * [x] The repository includes the appropriate `LICENSE.md` file 2. [x] Any test data necessary for the code and its unit tests to function is included in this GitHub repository, either directly or as a linked Git submodule. -3. [x] The README.md file has passed editorial review from the ITS Publications Office. +3. [x] The README.md file has passed editorial review by the ITS Publications Office. 4. [x] The project complies with the ITS Code Style Guide or an appropriate style guide as agreed to by the sponsor, project lead, or Supervising Division Chief. 5. [x] Approved disclaimer and licensing language has been included. In order to complete this approval, please create a new branch, upload and commit -your version of this Markdown document to that branch, then create a pull request -for that branch. The following must login to GitHub and approve that pull request +your version of this Markdown document to that branch, and then create a pull request +for that branch. The following must log in to GitHub and approve that pull request before the pull request can be merged and this repo made public: * Project Lead: William Kozma, Jr. diff --git a/README.md b/README.md index 3e1e8f3..151f1a2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Low Frequency / Medium Frequency (LF/MF) Propagation Model, Python® Wrapper # +# Low Frequency / Medium Frequency (LF/MF) Propagation Model, Python® Wrapper # [![NTIA/ITS PropLib][proplib-badge]][proplib-link] [![PyPI Release][pypi-release-badge]][pypi-release-link] @@ -78,6 +78,8 @@ library from C++ source code; see relevant build instructions [GitHub Release](https://github.com/NTIA/LFMF/releases). Then place the downloaded file in `src/ITS/Propagation/LFMF/` (alongside `__init__.py`). +1. Make sure pip, hatchling and pytest are installed in your current environment. + 1. Install the local package and development dependencies into your current environment: ```cmd diff --git a/pyproject.toml b/pyproject.toml index 89d5040..35ab7f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] [project.optional-dependencies] @@ -46,7 +47,8 @@ tests = [ dev = [ "hatchling>=1.25.0,<2.0", "pre-commit>=4.0.1,<5.0", - "proplib-lfmf[tests]", + "pytest>=8.2.0,<9.0", + "pytest-cov>=6.0.0,<7.0", ] [project.urls] @@ -68,5 +70,9 @@ ignore-vcs = true ignore-vcs = true [tool.cibuildwheel] -test-command = "pytest ." +test-command = "pytest tests" test-requires = "pytest" + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/src/ITS/Propagation/LFMF/LFMF.py b/src/ITS/Propagation/LFMF/LFMF.py index def17a9..50dbaa0 100644 --- a/src/ITS/Propagation/LFMF/LFMF.py +++ b/src/ITS/Propagation/LFMF/LFMF.py @@ -4,16 +4,33 @@ from .proplib_loader import PropLibCDLL -class Result(Structure): +class Polarization(IntEnum): + Horizontal = 0 + Vertical = 1 + + +class SolutionMethod(IntEnum): + FlatEarthCurveCorrection = 0 + ResidueSeries = 1 + + +class c_LFMFResult(Structure): # C Struct for library outputs _fields_ = [ - ("A_btl__db", c_double), - ("E__dBuVm", c_double), - ("P_rx__dbm", c_double), - ("method", c_int), + ("A_btl__db", c_double), # Basic transmission loss, in dB + ("E__dBuVm", c_double), # Electic field strength, in db(uV/m) + ("P_rx__dbm", c_double), # Received power, in dBm + ("method", c_int), # Solution method used ] +class LFMFResult(Structure): + A_btl__db: float = None # Basic transmission loss, in dB + E__dBuVm: float = None # Electic field strength, in db(uV/m) + P_rx__dbm: float = None # Received power, in dBm + method: SolutionMethod = None # Solution method used + + # Load the shared library lib = PropLibCDLL("LFMF-1.1") @@ -29,15 +46,10 @@ class Result(Structure): c_double, c_double, c_int, - POINTER(Result), + POINTER(c_LFMFResult), ) -class Polarization(IntEnum): - Horizontal = 0 - Vertical = 1 - - def LFMF( h_tx__meter: float, h_rx__meter: float, @@ -48,7 +60,7 @@ def LFMF( epsilon: float, sigma: float, pol: Polarization, -) -> Result: +) -> LFMFResult: """ Compute the Low Frequency / Medium Frequency (LF/MF) propagation prediction @@ -67,7 +79,7 @@ def LFMF( :return: In Result class. """ - result = Result() + result = c_LFMFResult() lib.err_check( lib.LFMF( c_double(h_tx__meter), @@ -78,9 +90,19 @@ def LFMF( c_double(d__km), c_double(epsilon), c_double(sigma), - c_int(int(pol)), + c_int(pol), byref(result), ) ) + return __convertResultStruct(result) + + +def __convertResultStruct(c_result): + result = LFMFResult + result.A_btl__db = c_result.A_btl__db + result.E__dBuVm = c_result.E__dBuVm + result.P_rx__dbm = c_result.P_rx__dbm + result.method = SolutionMethod(c_result.method) + return result diff --git a/src/ITS/Propagation/LFMF/__init__.py b/src/ITS/Propagation/LFMF/__init__.py index 46ddf3f..a0bde28 100644 --- a/src/ITS/Propagation/LFMF/__init__.py +++ b/src/ITS/Propagation/LFMF/__init__.py @@ -2,4 +2,9 @@ # and Z is the version of this Python wrapper __version__ = "1.1.0" -from .LFMF import LFMF, Polarization, Result +from .LFMF import ( + LFMF, + Polarization, + SolutionMethod, + LFMFResult +) diff --git a/src/ITS/Propagation/LFMF/proplib_loader.py b/src/ITS/Propagation/LFMF/proplib_loader.py index 21702e3..32d78b8 100644 --- a/src/ITS/Propagation/LFMF/proplib_loader.py +++ b/src/ITS/Propagation/LFMF/proplib_loader.py @@ -44,20 +44,27 @@ import platform import struct -from ctypes import * +from ctypes import CDLL, POINTER, c_char_p, c_int, cast from pathlib import Path class PropLibCDLL(CDLL): - def __init__(self, name): + """Load a shared library and expose common error.""" + def __init__(self, name: str) -> None: full_name = self.get_lib_name(name) + if not Path(full_name).is_file(): + raise FileNotFoundError( + f"Shared library '{name}' was not found at '{full_name}'." + ) super().__init__(full_name) + # Define expected function prototypes self.GetReturnStatusCharArray.restype = POINTER(c_char_p) self.GetReturnStatusCharArray.argtypes = (c_int,) self.FreeReturnStatusCharArray.restype = None self.FreeReturnStatusCharArray.argtypes = (POINTER(c_char_p),) + @staticmethod def get_lib_name(lib_name: str) -> str: """Get the full filename of the library specified by `lib_name`. @@ -72,24 +79,24 @@ def get_lib_name(lib_name: str) -> str: :return: The full filename, including path and extension, of the library. """ # Load the compiled library - if platform.uname()[0] == "Windows": + system = platform.system() + if system == "Windows": arch = struct.calcsize("P") * 8 # 32 or 64 if arch == 64: - lib_name += "-x64.dll" + suffix = "-x64.dll" elif arch == 32: - lib_name += "-x86.dll" + suffix = "-x86.dll" else: raise RuntimeError( "Failed to determine system architecture for DLL loading" ) - elif platform.uname()[0] == "Linux": - lib_name += "-x86_64.so" - elif platform.uname()[0] == "Darwin": - lib_name += "-universal.dylib" + elif system == "Linux": + suffix = "-x86_64.so" + elif system == "Darwin": + suffix = "-universal.dylib" else: - raise NotImplementedError("Your OS is not yet supported") - # Library should be in the same directory as this file - lib_path = Path(__file__).parent / lib_name + raise NotImplementedError(f"Unsupported operating system: {system}") + lib_path = Path(__file__).parent / f"{lib_name}{suffix}" return str(lib_path.resolve()) def err_check(self, rtn_code: int) -> None: @@ -105,8 +112,15 @@ def err_check(self, rtn_code: int) -> None: """ if rtn_code == 0: return - else: - msg = self.GetReturnStatusCharArray(c_int(rtn_code)) - msg_str = cast(msg, c_char_p).value.decode("utf-8") + + msg = self.GetReturnStatusCharArray(c_int(rtn_code)) + try: + msg_bytes = cast(msg, c_char_p).value + if msg_bytes is None: + raise RuntimeError( + f"Library call failed with code {rtn_code}, but no error text was returned." + ) + msg_str = msg_bytes.decode("utf-8") + finally: self.FreeReturnStatusCharArray(msg) - raise RuntimeError(msg_str) + raise RuntimeError(msg_str) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..82a8571 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for the LFMF Python wrapper.""" diff --git a/tests/data b/tests/data index d3cc4d6..c593360 160000 --- a/tests/data +++ b/tests/data @@ -1 +1 @@ -Subproject commit d3cc4d6efff973b1a4fac4dbf3074439aa3246ba +Subproject commit c593360c1c9f1cde463d98a3bf89447903d95861 diff --git a/tests/test_lfmf.py b/tests/test_lfmf.py index db2d577..792e50d 100644 --- a/tests/test_lfmf.py +++ b/tests/test_lfmf.py @@ -1,7 +1,6 @@ import pytest from ITS.Propagation import LFMF - from .test_utils import ABSTOL__DB, read_csv_test_data @@ -10,12 +9,17 @@ read_csv_test_data("LFMF_Examples.csv"), ) def test_lfmf(inputs, rtn, expected): - if rtn == 0: - result = LFMF.LFMF(*inputs) - assert result.A_btl__db == pytest.approx(expected[0], abs=ABSTOL__DB) - assert result.E__dBuVm == pytest.approx(expected[1], abs=ABSTOL__DB) - assert result.P_rx__dbm == pytest.approx(expected[2], abs=ABSTOL__DB) - assert result.method == int(expected[3]) + if rtn != 40: + pol = LFMF.Polarization(int(inputs[-1])) + if rtn == 0: + result = LFMF.LFMF(*inputs[:-1], pol) + assert result.A_btl__db == pytest.approx(expected[0], abs=ABSTOL__DB) + assert result.E__dBuVm == pytest.approx(expected[1], abs=ABSTOL__DB) + assert result.P_rx__dbm == pytest.approx(expected[2], abs=ABSTOL__DB) + assert result.method == LFMF.SolutionMethod(int(expected[3])) + else: + with pytest.raises(RuntimeError): + LFMF.LFMF(*inputs[:-1], pol) else: with pytest.raises(RuntimeError): - LFMF.LFMF(*inputs) + LFMF.LFMF(*inputs[:-1], int(inputs[-1])) diff --git a/tests/test_proplib_loader.py b/tests/test_proplib_loader.py new file mode 100644 index 0000000..77c69e8 --- /dev/null +++ b/tests/test_proplib_loader.py @@ -0,0 +1,91 @@ +from ctypes import POINTER, c_char_p, cast + +import pytest +from ITS.Propagation.LFMF.proplib_loader import PropLibCDLL + + +@pytest.mark.parametrize( + ("system_name", "pointer_size", "suffix"), + [ + ("Windows", 8, "-x64.dll"), + ("Windows", 4, "-x86.dll"), + ("Linux", 8, "-x86_64.so"), + ("Darwin", 8, "-universal.dylib"), + ], +) +def test_get_lib_name_uses_expected_platform_suffix( + monkeypatch: pytest.MonkeyPatch, + system_name: str, + pointer_size: int, + suffix: str, +) -> None: + monkeypatch.setattr("ITS.Propagation.LFMF.proplib_loader.platform.system", lambda: system_name) + monkeypatch.setattr("ITS.Propagation.LFMF.proplib_loader.struct.calcsize", lambda _: pointer_size) + + lib_path = PropLibCDLL.get_lib_name("Example-1.0") + + assert lib_path.endswith(suffix) + assert "Example-1.0" in lib_path + + +def test_get_lib_name_rejects_unknown_platform( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("ITS.Propagation.LFMF.proplib_loader.platform.system", lambda: "Plan9") + + with pytest.raises(NotImplementedError, match="Unsupported operating system: Plan9"): + PropLibCDLL.get_lib_name("Example-1.0") + + +def test_constructor_raises_clear_error_for_missing_library( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_path = r"C:\missing\Example-1.0-x64.dll" + monkeypatch.setattr(PropLibCDLL, "get_lib_name", staticmethod(lambda _: fake_path)) + + with pytest.raises(FileNotFoundError, match="Shared library 'Example-1.0' was not found"): + PropLibCDLL("Example-1.0") + + +def test_err_check_returns_for_success() -> None: + class FakeLibrary: + pass + + PropLibCDLL.err_check(FakeLibrary(), 0) + + +def test_err_check_raises_library_message() -> None: + error_message = b"example failure" + freed_messages: list[object] = [] + + class FakeLibrary: + @staticmethod + def GetReturnStatusCharArray(_code): + return cast(c_char_p(error_message), POINTER(c_char_p)) + + @staticmethod + def FreeReturnStatusCharArray(message) -> None: + freed_messages.append(message) + + with pytest.raises(RuntimeError, match="example failure"): + PropLibCDLL.err_check(FakeLibrary(), 5) + + assert len(freed_messages) == 1 + + +def test_err_check_handles_missing_error_text() -> None: + freed_messages: list[object] = [] + + class FakeLibrary: + @staticmethod + def GetReturnStatusCharArray(_code): + return cast(c_char_p(None), POINTER(c_char_p)) + + @staticmethod + def FreeReturnStatusCharArray(message) -> None: + freed_messages.append(message) + + with pytest.raises(RuntimeError, match="no error text was returned"): + PropLibCDLL.err_check(FakeLibrary(), 9) + + assert len(freed_messages) == 1 diff --git a/tests/test_utils.py b/tests/test_utils.py index a4b7633..a9d0e08 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,6 @@ import csv from pathlib import Path +from typing import Optional # Test data is expected to exist in tests/data TEST_DATA_DIR = Path(__file__).parent / "data" @@ -13,10 +14,27 @@ ) -def read_csv_test_data(filename: str): - with open(TEST_DATA_DIR / filename) as f: - reader = csv.reader(f) - next(reader) # Skip header row +# Read CSV into dictionary and convert to specified data type +def _resolve_test_data_file(filename: str, data_dir: Optional[Path] = None) -> Path: + """Resolve a CSV test-data file and raise a clear error when it is missing.""" + + base_dir = data_dir or TEST_DATA_DIR + file_path = base_dir / filename + if not file_path.is_file(): + raise FileNotFoundError( + f"Test data file '{filename}' was not found in '{base_dir}'. " + "Clone or populate the test-data submodule before running data-backed tests." + ) + return file_path + + +def read_csv_test_data(filename: str, data_dir: Optional[Path] = None): + """Yield ``(*inputs, rtn, output)`` tuples from a simple numeric CSV file.""" + + file_path = _resolve_test_data_file(filename, data_dir) + with file_path.open(encoding="utf_8_sig", newline="") as infile: + reader = csv.reader(infile) + next(reader) for row in reader: # yields (*inputs, rtn, *outputs) yield tuple(map(float, row[:-5])), int(row[-5]), tuple(map(float, row[-4:]))