diff --git a/.github/actions/upload-coverage/action.yml b/.github/actions/upload-coverage/action.yml new file mode 100644 index 000000000..b7083d0b9 --- /dev/null +++ b/.github/actions/upload-coverage/action.yml @@ -0,0 +1,32 @@ +name: Upload coverage to Coveralls +description: Install the macOS reporter if needed and upload lcov coverage to Coveralls. + +inputs: + github-token: + description: Token used to authenticate with Coveralls. + required: true + flag-name: + description: Unique flag identifying this coverage job. + required: true + fail-on-error: + description: Whether an upload failure should fail the job. + required: false + default: "true" + +runs: + using: composite + steps: + # Fix for coveralls on homebrew 6+: https://github.com/coverallsapp/github-action/pull/265 + - name: Install coveralls reporter (macOS) + if: runner.os == 'macOS' + shell: bash + run: brew install coverallsapp/coveralls/coveralls --quiet + + - name: Upload coverage data to coveralls.io + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ inputs.github-token }} + flag-name: ${{ inputs.flag-name }} + parallel: true + path-to-lcov: ./coverage.lcov + fail-on-error: ${{ inputs.fail-on-error }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7ca82b44d..20896457f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,6 +22,7 @@ env: CONDA_ENVIRONMENT_NAME: mhkit-dev-env # Coveralls: Set to false to ignore upload failures during outages. # Check status: https://status.coveralls.io/ + # Note this only affects the code coverage percentage badge in the README, nothing else COVERALLS_FAIL_ON_ERROR: true jobs: @@ -39,10 +40,35 @@ jobs: if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.base_ref }}" == "develop" ]]; then echo "matrix_os=[ \"ubuntu-latest\"]" >> $GITHUB_OUTPUT else - echo "matrix_os=[\"windows-latest\", \"ubuntu-latest\", \"macos-latest\"]" >> $GITHUB_OUTPUT + echo "matrix_os=[\"windows-latest\", \"ubuntu-latest\", \"macos-26\"]" >> $GITHUB_OUTPUT fi + # `coveralls-gate` controls whether code coverage data is uploaded to coveralls. + # The README code coverage badge is main-only. Because this is a simple metric we don't need to + # calculate this for every code push, just the ones that can change the badge coverage percentage + # + # Specific code changes that are relevant to the coverage badge are: + # * a PR targeting main + # * a push to main + # Coveralls badge: + # + coveralls-gate: + runs-on: ubuntu-latest + outputs: + should-upload: ${{ steps.gate.outputs.should-upload }} + steps: + - id: gate + run: | + should_upload=false + if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.base_ref }}" == "main" ]]; then + should_upload=true + fi + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then + should_upload=true + fi + echo "should-upload=$should_upload" >> "$GITHUB_OUTPUT" + # This job decides if the hindcast test suite should run. check-changes: runs-on: ubuntu-latest @@ -64,11 +90,15 @@ jobs: - id: hindcast-logic run: | - if [[ "${{ github.event.pull_request.base.ref }}" == "main" || "${{ steps.changes.outputs.wave_io_hindcast }}" == "true" ]]; then - echo "should-run-hindcast=true" >> "$GITHUB_OUTPUT" - else - echo "should-run-hindcast=false" >> "$GITHUB_OUTPUT" - fi + # TODO(restore-hindcast): NLR HSDS endpoint is down for v1.1, so hindcast + # jobs are disabled. Restore the logic below when the service is back online. + # See https://github.com/MHKiT-Software/MHKiT-Python/issues/450 + echo "should-run-hindcast=false" >> "$GITHUB_OUTPUT" + # if [[ "${{ github.event.pull_request.base.ref }}" == "main" || "${{ steps.changes.outputs.wave_io_hindcast }}" == "true" ]]; then + # echo "should-run-hindcast=true" >> "$GITHUB_OUTPUT" + # else + # echo "should-run-hindcast=false" >> "$GITHUB_OUTPUT" + # fi prepare-nonhindcast-cache: runs-on: ubuntu-latest @@ -201,7 +231,7 @@ jobs: conda-forge-build: name: conda-forge-${{ matrix.os }}/${{ matrix.python-version }} - needs: [set-os, prepare-nonhindcast-cache] + needs: [set-os, prepare-nonhindcast-cache, coveralls-gate] runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -249,17 +279,16 @@ jobs: coverage lcov - name: Upload coverage data to coveralls.io - uses: coverallsapp/github-action@v2 + if: needs.coveralls-gate.outputs.should-upload == 'true' + uses: ./.github/actions/upload-coverage with: github-token: ${{ secrets.GITHUB_TOKEN }} flag-name: conda-forge-${{ runner.os }}-py${{ matrix.python-version }} - parallel: true - path-to-lcov: ./coverage.lcov fail-on-error: ${{ env.COVERALLS_FAIL_ON_ERROR }} pip-build: name: pip-${{ matrix.os }}/${{ matrix.python-version }} - needs: [set-os, prepare-nonhindcast-cache] + needs: [set-os, prepare-nonhindcast-cache, coveralls-gate] runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -310,12 +339,11 @@ jobs: coverage lcov - name: Upload coverage data to coveralls.io - uses: coverallsapp/github-action@v2 + if: needs.coveralls-gate.outputs.should-upload == 'true' + uses: ./.github/actions/upload-coverage with: github-token: ${{ secrets.GITHUB_TOKEN }} flag-name: pip-${{ runner.os }}-py${{ matrix.python-version }} - parallel: true - path-to-lcov: ./coverage.lcov fail-on-error: ${{ env.COVERALLS_FAIL_ON_ERROR }} hindcast-calls: @@ -326,6 +354,7 @@ jobs: prepare-wave-hindcast-cache, prepare-wind-hindcast-cache, set-os, + coveralls-gate, ] if: (needs.check-changes.outputs.should-run-hindcast == 'true') @@ -388,12 +417,11 @@ jobs: coverage lcov - name: Upload coverage data to coveralls.io - uses: coverallsapp/github-action@v2 + if: needs.coveralls-gate.outputs.should-upload == 'true' + uses: ./.github/actions/upload-coverage with: github-token: ${{ secrets.GITHUB_TOKEN }} flag-name: hindcast-${{ runner.os }}-py${{ matrix.python-version }} - parallel: true - path-to-lcov: ./coverage.lcov fail-on-error: ${{ env.COVERALLS_FAIL_ON_ERROR }} test-wheel-packaging: @@ -636,9 +664,11 @@ jobs: conda-forge-build, pip-build, hindcast-calls, + coveralls-gate, ] if: | always() && + needs.coveralls-gate.outputs.should-upload == 'true' && ( ( needs.conda-forge-build.result == 'success' && diff --git a/.hscfg b/.hscfg index f9aa99caa..c280dc421 100644 --- a/.hscfg +++ b/.hscfg @@ -1,4 +1,4 @@ -hs_endpoint = https://developer.nrel.gov/api/hsds +hs_endpoint = https://developer.nlr.gov/api/hsds hs_username = hs_password = hs_api_key = jODGciIBnejrYd9GXxgXjbbAjMDLBMWQer05P98N diff --git a/environment-dev.yml b/environment-dev.yml index e624a7286..455c8eaeb 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -26,5 +26,10 @@ dependencies: - notebook - matplotlib>=3.9.1 - fatpack - - nrel-rex - cartopy + # rex is installed from PyPI (NLR-rex), not conda-forge. The conda-forge + # `nrel-rex` is frozen at 0.2.84, which predates the s3:// fsspec support + # needed to read the wave hindcast .h5 files directly from S3. The [hsds] extra + # pulls the HSDS client dependencies the hindcast reads use. + - pip: + - NLR-rex[hsds]>=0.5.0 diff --git a/examples/WPTO_hindcast_example.ipynb b/examples/WPTO_hindcast_example.ipynb index 1f251b7a9..3a6fe3faf 100644 --- a/examples/WPTO_hindcast_example.ipynb +++ b/examples/WPTO_hindcast_example.ipynb @@ -51,7 +51,7 @@ "\n", "### Setting up Access to WPTO Hindcast Data\n", "To access the WPTO hindcast data, you will need to configure h5pyd for data access on HSDS. \n", - " To get your own API key, visit https://developer.nrel.gov/signup/. \n", + " To get your own API key, visit https://developer.nlr.gov/signup/. \n", "\n", "To configure h5phd type:\n", "\n", @@ -59,7 +59,7 @@ " \n", "and enter at the prompt:\n", "\n", - " hs_endpoint = https://developer.nrel.gov/api/hsds\n", + " hs_endpoint = https://developer.nlr.gov/api/hsds\n", " hs_username = None\n", " hs_password = None\n", " hs_api_key = {your key}\n", diff --git a/examples/metocean_example.ipynb b/examples/metocean_example.ipynb index 4f4540ab8..06098aeb2 100644 --- a/examples/metocean_example.ipynb +++ b/examples/metocean_example.ipynb @@ -567,7 +567,7 @@ "source": [ "## 2. Request Surface Wind Data from WIND Toolkit\n", "\n", - "MHKiT can be used to request historical data from [WIND Toolkit](https://www.nrel.gov/grid/wind-toolkit.html). The WIND Toolkit is a high-spatial-resolution dataset of meteorological parameters covering several offshore regions including California, Hawaii, the Northwest Pacific, and the Mid-Atlantic. The offshore datasets span 2000-2019 (or to 2020 for the Mid-Atlantic region). \n", + "MHKiT can be used to request historical data from [WIND Toolkit](https://www.nlr.gov/grid/wind-toolkit). The WIND Toolkit is a high-spatial-resolution dataset of meteorological parameters covering several offshore regions including California, Hawaii, the Northwest Pacific, and the Mid-Atlantic. The offshore datasets span 2000-2019 (or to 2020 for the Mid-Atlantic region). \n", "\n", "\n", "### Included Variables: \n", @@ -588,7 +588,7 @@ "\n", "### Setting up Access to WIND Toolkit\n", "To access the WIND Toolkit, you will need to configure h5pyd for data access on HSDS. \n", - "To get your own API key, visit https://developer.nrel.gov/signup/. \n", + "To get your own API key, visit https://developer.nlr.gov/signup/. \n", "If you have already accessed WPTO Hindcast data, you may skip this step.\n", "\n", "To configure h5pyd type:\n", @@ -597,7 +597,7 @@ " \n", "and enter at the prompt:\n", "\n", - " hs_endpoint = https://developer.nrel.gov/api/hsds\n", + " hs_endpoint = https://developer.nlr.gov/api/hsds\n", " hs_username = None\n", " hs_password = None\n", " hs_api_key = {your key}\n", @@ -615,7 +615,7 @@ "* location\n", "* year\n", "\n", - "A full list of available historical parameters can be found [here](https://github.com/NREL/hsds-examples/blob/master/datasets/WINDToolkit.md#model). Here we are interested in historical surface wind data (`'windspeed_10m'`, `'winddirection_10m'`) and a vertical temperature profile. For demonstration purposes, not all temperature elevations will be used. Additionally, we will use the latitude and longitude of NDBC buoy `'46022'` to later compare to the NDBC dataset. The buoy location can be found [here](https://www.ndbc.noaa.gov/data/stations/station_table.txt). The `elevation_to_string` can be used to easily combine an array of elevations with a parameter name to create the correctly formatted parameter string. This is demonstrated for the array of temperature parameters." + "A full list of available historical parameters can be found [here](https://github.com/NatLabRockies/hsds-examples/blob/master/datasets/WINDToolkit.md#model). Here we are interested in historical surface wind data (`'windspeed_10m'`, `'winddirection_10m'`) and a vertical temperature profile. For demonstration purposes, not all temperature elevations will be used. Additionally, we will use the latitude and longitude of NDBC buoy `'46022'` to later compare to the NDBC dataset. The buoy location can be found [here](https://www.ndbc.noaa.gov/data/stations/station_table.txt). The `elevation_to_string` can be used to easily combine an array of elevations with a parameter name to create the correctly formatted parameter string. This is demonstrated for the array of temperature parameters." ] }, { diff --git a/mhkit/__init__.py b/mhkit/__init__.py index 6f9eefeec..0d248d505 100644 --- a/mhkit/__init__.py +++ b/mhkit/__init__.py @@ -11,7 +11,7 @@ configure_warnings() -__version__ = "v1.0.1" +__version__ = "v1.1.0" __copyright__ = """ Copyright 2019, Alliance for Energy Innovation, LLC under the terms of diff --git a/mhkit/tests/tidal/test_io.py b/mhkit/tests/tidal/test_io.py index be34cd444..a97c78c37 100644 --- a/mhkit/tests/tidal/test_io.py +++ b/mhkit/tests/tidal/test_io.py @@ -21,7 +21,6 @@ import numpy as np import mhkit.tidal as tidal - testdir = dirname(abspath(__file__)) plotdir = join(testdir, "plots") isdir = os.path.isdir(plotdir) @@ -68,6 +67,11 @@ def test_load_noaa_data_xarray(self): self.assertEqual(len(data["index"]), 18890) self.assertEqual(data.attrs["id"], "s08010") + # TODO(noaa_api_failures): NOAA Tides and Currents API intermittently + # returns 504 Gateway Timeout, causing this test to fail in CI. Re-enable + # once the upstream API is reliable. See + # https://github.com/MHKiT-Software/MHKiT-Python/issues/451 + # @unittest.skip("NOAA API intermittently unavailable; see issue #451") def test_request_noaa_data_basic(self): """ Test the request_noaa_data function with basic input parameters @@ -89,6 +93,11 @@ def test_request_noaa_data_basic(self): self.assertEqual(data.shape, (183, 3)) self.assertEqual(metadata["id"], "s08010") + # TODO(noaa_api_failures): NOAA Tides and Currents API intermittently + # returns 504 Gateway Timeout, causing this test to fail in CI. Re-enable + # once the upstream API is reliable. See + # https://github.com/MHKiT-Software/MHKiT-Python/issues/451 + # @unittest.skip("NOAA API intermittently unavailable; see issue #451") def test_request_noaa_data_basic_xarray(self): """ Test the request_noaa_data function with basic input parameters @@ -116,6 +125,11 @@ def test_request_noaa_data_basic_xarray(self): self.assertEqual(len(data["index"]), 183) self.assertEqual(data.attrs["id"], "s08010") + # TODO(noaa_api_failures): NOAA Tides and Currents API intermittently + # returns 504 Gateway Timeout, causing this test to fail in CI. Re-enable + # once the upstream API is reliable. See + # https://github.com/MHKiT-Software/MHKiT-Python/issues/451 + # @unittest.skip("NOAA API intermittently unavailable; see issue #451") def test_request_noaa_data_write_json(self): """ Test the request_noaa_data function with the write_json parameter diff --git a/mhkit/tests/wave/io/hindcast/test_hindcast.py b/mhkit/tests/wave/io/hindcast/test_hindcast.py index 456ea05b7..44cbc1aed 100644 --- a/mhkit/tests/wave/io/hindcast/test_hindcast.py +++ b/mhkit/tests/wave/io/hindcast/test_hindcast.py @@ -26,7 +26,6 @@ import unittest from os.path import abspath, dirname, join, normpath from pandas.testing import assert_frame_equal -import xarray.testing as xrt import pandas as pd import mhkit.wave as wave import xarray as xr @@ -37,6 +36,12 @@ ) +# TODO(restore-hindcast): 6/30/2026, NLR HSDS endpoint is non functional +# https://github.com/MHKiT-Software/MHKiT-Python/issues/450 +@unittest.skip( + "TODO(restore-hindcast): NLR HSDS endpoint unavailable. See " + "https://github.com/MHKiT-Software/MHKiT-Python/issues/450" +) class TestWPTOhindcast(unittest.TestCase): """ A test call designed to check the WPTO hindcast retrival diff --git a/mhkit/tests/wave/io/hindcast/test_wind_toolkit.py b/mhkit/tests/wave/io/hindcast/test_wind_toolkit.py index 0113a0cab..ad9dd7141 100644 --- a/mhkit/tests/wave/io/hindcast/test_wind_toolkit.py +++ b/mhkit/tests/wave/io/hindcast/test_wind_toolkit.py @@ -1,4 +1,4 @@ -from os.path import abspath, dirname, join, isfile, normpath, relpath +from os.path import abspath, dirname, join, normpath from pandas.testing import assert_frame_equal import matplotlib.pylab as plt import mhkit.wave.io.hindcast.wind_toolkit as wtk @@ -6,7 +6,6 @@ import unittest import pytest - testdir = dirname(abspath(__file__)) datadir = normpath( join( @@ -24,6 +23,12 @@ ) +# TODO(restore-hindcast): 6/30/2026, NLR HSDS endpoint is non functional +# https://github.com/MHKiT-Software/MHKiT-Python/issues/450 +@unittest.skip( + "TODO(restore-hindcast): NLR HSDS endpoint unavailable. See " + "https://github.com/MHKiT-Software/MHKiT-Python/issues/450" +) class TestWINDToolkit(unittest.TestCase): @classmethod def setUpClass(self): diff --git a/mhkit/wave/io/hindcast/hindcast.py b/mhkit/wave/io/hindcast/hindcast.py index 83119a782..5be667683 100644 --- a/mhkit/wave/io/hindcast/hindcast.py +++ b/mhkit/wave/io/hindcast/hindcast.py @@ -20,6 +20,8 @@ from mhkit.utils.cache import handle_caching from mhkit.utils.type_handling import convert_to_dataset +from mhkit.wave.io.hindcast.hindcast_exceptions import hindcast_guard + def region_selection(lat_lon: Union[List[float], Tuple[float, float]]) -> str: """ @@ -74,6 +76,7 @@ def region_search( # pylint: disable=too-many-locals # pylint: disable=too-many-branches # pylint: disable=too-many-statements +@hindcast_guard def request_wpto_point_data( data_type: str, parameter: Union[str, List[str]], @@ -283,6 +286,7 @@ def request_wpto_point_data( # pylint: disable=too-many-branches # pylint: disable=too-many-statements +@hindcast_guard def request_wpto_directional_spectrum( lat_lon: Union[Tuple[float, float], List[Tuple[float, float]]], year: str, diff --git a/mhkit/wave/io/hindcast/hindcast_exceptions.py b/mhkit/wave/io/hindcast/hindcast_exceptions.py new file mode 100644 index 000000000..d54dbdcf1 --- /dev/null +++ b/mhkit/wave/io/hindcast/hindcast_exceptions.py @@ -0,0 +1,92 @@ +""" +Exceptions for the hindcast API and a decorator that turns low-level request +failures into clear, actionable errors. + +A 502 or 503 response means the NLR HSDS service is down, which is tracked in +the MHKiT issue below. Any other failure asks the user to open a new issue and +paste the stack trace. To handle a new failure mode, add an exception class and +a branch to :func:`hindcast_guard`. +""" + +import functools +import traceback +from typing import Callable + +# Existing MHKiT issue tracking the known NLR HSDS service outage. +HINDCAST_UNAVALIABLE_ISSUE_URL = ( + "https://github.com/MHKiT-Software/MHKiT-Python/issues/450" +) +# Where to open a new MHKiT issue for any other hindcast request failure. +NEW_ISSUE_URL = "https://github.com/MHKiT-Software/MHKiT-Python/issues/new" +# HTTP status codes that mean the HSDS service is down (tracked in #450). +_SERVICE_DOWN_CODES = ("502", "503") + + +class HindcastApiError(RuntimeError): + """Base class for hindcast API errors, also raised for unexpected failures.""" + + +class HindcastApiUnavailableError(HindcastApiError): + """Raised when the hindcast API is down (HSDS 502 or 503).""" + + +def _is_service_down(err: Exception) -> bool: + """Return True when the error looks like an HSDS 502 or 503 response.""" + text = str(err) + return any(code in text for code in _SERVICE_DOWN_CODES) + + +def _unavailable_message(trace: str) -> str: + return ( + "Could not retrieve the hindcast data. The NLR HSDS endpoint " + "(developer.nlr.gov) returned a 502 or 503 error and is likely down.\n\n" + "This is a known outage tracked here:\n" + f" {HINDCAST_UNAVALIABLE_ISSUE_URL}\n\n" + f"Stack trace:\n{trace}" + ) + + +def _unexpected_message(trace: str) -> str: + return ( + "An unexpected error occurred while retrieving the hindcast data.\n\n" + "Please open an issue and paste the stack trace below:\n" + f" {NEW_ISSUE_URL}\n\n" + f"Stack trace:\n{trace}" + ) + + +def hindcast_guard(func: Callable) -> Callable: + """ + Convert a failed hindcast API request into a clear HindcastApiError. + + The wrapped function runs normally, so cached results and a recovered + service still work. Input validation errors (TypeError, ValueError) and + existing HindcastApiError subclasses pass through unchanged. An HSDS 502 + or 503 raises HindcastApiUnavailableError pointing to the tracking issue. + Any other failure raises HindcastApiError asking the user to open an + issue. Both include the stack trace for the user to copy into the issue. + + Parameters + ---------- + func : callable + Hindcast data-request function to wrap. + + Returns + ------- + callable + The wrapped function. + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except (TypeError, ValueError, HindcastApiError): + raise + except Exception as err: + trace = traceback.format_exc() + if _is_service_down(err): + raise HindcastApiUnavailableError(_unavailable_message(trace)) from err + raise HindcastApiError(_unexpected_message(trace)) from err + + return wrapper diff --git a/mhkit/wave/io/hindcast/wind_toolkit.py b/mhkit/wave/io/hindcast/wind_toolkit.py index a22cbe7ba..cfc55b8a1 100644 --- a/mhkit/wave/io/hindcast/wind_toolkit.py +++ b/mhkit/wave/io/hindcast/wind_toolkit.py @@ -30,6 +30,8 @@ from mhkit.utils.cache import handle_caching from mhkit.utils.type_handling import convert_to_dataset +from mhkit.wave.io.hindcast.hindcast_exceptions import hindcast_guard + def region_selection(lat_lon: Tuple[float, float], preferred_region: str = "") -> str: """ @@ -108,6 +110,7 @@ def region_search(x: str) -> bool: return region[0] +@hindcast_guard def get_region_data(region: str) -> Tuple[np.ndarray, np.ndarray]: """ Retrieves the latitude and longitude data points for the specified region @@ -210,7 +213,7 @@ def plot_region( supported_regions = ["Offshore_CA", "Hawaii", "Mid_Atlantic", "NW_Pacific"] if region not in supported_regions: raise ValueError( - f'{region} not in list of supported regions: {", ".join(supported_regions)}' + f"{region} not in list of supported regions: {', '.join(supported_regions)}" ) lats, lons = get_region_data(region) @@ -278,6 +281,7 @@ def elevation_to_string( # pylint: disable=too-many-statements # pylint: disable=too-many-positional-arguments # pylint: disable=duplicate-code +@hindcast_guard def request_wtk_point_data( time_interval: str, parameter: Union[str, List[str]], @@ -369,7 +373,6 @@ def request_wtk_point_data( meta: DataFrame Location metadata for the requested data location """ - if not isinstance(parameter, (str, list)): raise TypeError("parameter must be of type string or list") if not isinstance(lat_lon, (list, tuple)): @@ -427,6 +430,9 @@ def request_wtk_point_data( else: raise TypeError("Coordinates must be within the same region!") + # NOTE: NREL to NLR name changes + # * developer.nlr.gov must be configured via ~/.hscfg + # * As of June 2026 the HSDS data domain is still "/nrel/...". if time_interval == "1-hour": wind_path = f"/nrel/wtk/{region.lower()}/{region}_*.h5" elif time_interval == "5-minute": diff --git a/pyproject.toml b/pyproject.toml index c9805d480..02386a2b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ wave = [ "scikit-learn>=1.5.1", "statsmodels>=0.14.2", "pytz", - "NREL-rex>=0.2.63", + "NLR-rex[hsds]>=0.5.0", "beautifulsoup4", "bottleneck", "lxml",