diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6f7ea3c867..b30846b3a7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,6 +2,7 @@ - [ ] Closes #xxxx - [ ] I am familiar with the [contributing guidelines](https://pvlib-python.readthedocs.io/en/latest/contributing/index.html) + - [ ] I attest that all AI-generated material has been vetted for accuracy and is in compliance with the pvlib license - [ ] Tests added - [ ] Updates entries in [`docs/sphinx/source/reference`](https://github.com/pvlib/pvlib-python/blob/main/docs/sphinx/source/reference) for API changes. - [ ] Adds description and name entries in the appropriate "what's new" file in [`docs/sphinx/source/whatsnew`](https://github.com/pvlib/pvlib-python/tree/main/docs/sphinx/source/whatsnew) for all changes. Includes link to the GitHub Issue with `` :issue:`num` `` or this Pull Request with `` :pull:`num` ``. Includes contributor name and/or GitHub username (link with `` :ghuser:`user` ``). diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9579416e11..12425b87b2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish distributions to PyPI +name: Package build on: pull_request: @@ -9,8 +9,8 @@ on: - "v*" jobs: - build-n-publish: - name: Build and publish distributions to PyPI + build: + name: Build wheel and sdist if: github.repository == 'pvlib/pvlib-python' runs-on: ubuntu-latest steps: @@ -23,7 +23,7 @@ jobs: uses: actions/setup-python@v5 with: # Python version should be the minimum supported version - python-version: "3.9" + python-version: "3.10" - name: Install build tools run: | @@ -49,10 +49,28 @@ jobs: run: du -h pvlib working-directory: ./tmp + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish: + name: Release dist files to PyPI # only publish distribution to PyPI for tagged commits + if: startsWith(github.ref, 'refs/tags/v') + needs: + - build + runs-on: ubuntu-latest + permissions: + id-token: write # for PyPI trusted publishing + + steps: + - name: Download all dist files + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution to PyPI - if: startsWith(github.ref, 'refs/tags/v') uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/pytest-remote-data.yml b/.github/workflows/pytest-remote-data.yml index 6b214f7652..9aa72b1514 100644 --- a/.github/workflows/pytest-remote-data.yml +++ b/.github/workflows/pytest-remote-data.yml @@ -56,10 +56,10 @@ jobs: strategy: fail-fast: false # don't cancel other matrix jobs when one fails matrix: - python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] suffix: [''] # the alternative to "-min" include: - - python-version: 3.9 + - python-version: "3.10" suffix: -min runs-on: ubuntu-latest @@ -96,14 +96,17 @@ jobs: shell: bash -l {0} # necessary for conda env to be active env: # copy GitHub Secrets into environment variables for the tests to access - NREL_API_KEY: ${{ secrets.NRELAPIKEY }} + NLR_API_KEY: ${{ secrets.NRELAPIKEY }} SOLARANYWHERE_API_KEY: ${{ secrets.SOLARANYWHERE_API_KEY }} BSRN_FTP_USERNAME: ${{ secrets.BSRN_FTP_USERNAME }} BSRN_FTP_PASSWORD: ${{ secrets.BSRN_FTP_PASSWORD }} + ECMWF_API_KEY: ${{ secrets.ECMWF_API_KEY }} + EARTHDATA_USERNAME: ${{ secrets.EARTHDATA_USERNAME }} + EARTHDATA_PASSWORD: ${{ secrets.EARTHDATA_PASSWORD }} run: pytest tests/iotools --cov=./ --cov-report=xml --remote-data - name: Upload coverage to Codecov - if: matrix.python-version == 3.9 && matrix.suffix == '' + if: matrix.python-version == '3.10' && matrix.suffix == '' uses: codecov/codecov-action@v4 with: fail_ci_if_error: true diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 0dde7993d3..0008571d2b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -12,12 +12,12 @@ jobs: fail-fast: false # don't cancel other matrix jobs when one fails matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] environment-type: [conda, bare] suffix: [''] # placeholder as an alternative to "-min" include: - os: ubuntu-latest - python-version: 3.9 + python-version: "3.10" environment-type: conda suffix: -min exclude: @@ -83,7 +83,7 @@ jobs: pytest tests --cov=./ --cov-report=xml --ignore=tests/iotools - name: Upload coverage to Codecov - if: matrix.python-version == 3.9 && matrix.suffix == '' && matrix.os == 'ubuntu-latest' && matrix.environment-type == 'conda' + if: matrix.python-version == '3.10' && matrix.suffix == '' && matrix.os == 'ubuntu-latest' && matrix.environment-type == 'conda' uses: codecov/codecov-action@v4 with: fail_ci_if_error: true diff --git a/.github/workflows/top-ranked-issues.yml b/.github/workflows/top-ranked-issues.yml index 8b4ff30881..5845691d34 100644 --- a/.github/workflows/top-ranked-issues.yml +++ b/.github/workflows/top-ranked-issues.yml @@ -31,4 +31,4 @@ jobs: - name: Run update_top_ranking_issues.py run: | - python ./scripts/update_top_ranking_issues.py + python .github/workflows/update_top_ranking_issues.py diff --git a/scripts/update_top_ranking_issues.py b/.github/workflows/update_top_ranking_issues.py similarity index 100% rename from scripts/update_top_ranking_issues.py rename to .github/workflows/update_top_ranking_issues.py diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml new file mode 100644 index 0000000000..3007ec9453 --- /dev/null +++ b/.github/workflows/welcome.yml @@ -0,0 +1,23 @@ +name: Welcome First-Time Contributor + +on: + pull_request_target: + types: opened + +jobs: + welcome: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: plbstl/first-contribution@v4 + with: + pr-opened-msg: | + ### Hey @{fc-author}! :tada: + + Thanks for opening your first pull request! We appreciate your + contribution. Please ensure you have reviewed and understood the + [contributing guidelines](https://pvlib-python.readthedocs.io/en/latest/contributing/index.html). + + If AI is used for any portion of this PR, you must vet the content + for technical accuracy. diff --git a/.gitignore b/.gitignore index c9d19f557d..0258b3975c 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,8 @@ coverage.xml env results + +# Gas Town runtime +.beads/ +.claude/ +.runtime/ diff --git a/benchmarks/asv.conf.json b/benchmarks/asv.conf.json index 62a21bf505..3b2cee200e 100644 --- a/benchmarks/asv.conf.json +++ b/benchmarks/asv.conf.json @@ -113,19 +113,19 @@ "include": [ // minimum supported versions { - "python": "3.9", + "python": "3.10", "build": "", - "numpy": "1.19.5", - "pandas": "1.3.0", - "scipy": "1.6.0", + "numpy": "1.21.5", + "pandas": "1.4.1", // first anaconda version to support py 3.10 + "scipy": "1.7.2", // Note: these don't have a minimum in setup.py - "h5py": "3.1.0", - "ephem": "4.0.0.1", // first version to support py 3.9 - "numba": "0.53.0", // first version to support py 3.9 + "h5py": "3.6.0", // first version to support py 3.10 + "ephem": "4.1.1", // first version to support py 3.10 + "numba": "0.55.0", // first version to support py 3.10 }, // latest versions available { - "python": "3.9", + "python": "3.10", "build": "", "numpy": "", "pandas": "", diff --git a/benchmarks/benchmarks/scaling.py b/benchmarks/benchmarks/scaling.py index 8c2ed1471b..feeb9c60e2 100644 --- a/benchmarks/benchmarks/scaling.py +++ b/benchmarks/benchmarks/scaling.py @@ -15,7 +15,7 @@ def setup(self): lon = np.array((4.99, 5, 5.01)) self.coordinates = np.array([(lati, loni) for (lati, loni) in zip(lat, lon)]) - self.times = pd.date_range('2019-01-01', freq='1T', periods=self.n) + self.times = pd.date_range('2019-01-01', freq='1min', periods=self.n) self.positions = np.array([[0, 0], [100, 0], [100, 100], [0, 100]]) self.clearsky_index = pd.Series(np.random.rand(self.n), index=self.times) diff --git a/ci/requirements-py3.9-min.yml b/ci/requirements-py3.10-min.yml similarity index 71% rename from ci/requirements-py3.9-min.yml rename to ci/requirements-py3.10-min.yml index d17df337fd..a6fa18dffb 100644 --- a/ci/requirements-py3.9-min.yml +++ b/ci/requirements-py3.10-min.yml @@ -8,14 +8,14 @@ dependencies: - pytest-cov - pytest-mock - pytest-timeout - - python=3.9 + - python=3.10 - pytz - requests - pip: - - h5py==3.0.0 - - numpy==1.19.3 - - pandas==1.3.0 # min version of pvlib - - scipy==1.6.0 + - h5py==3.6.0 + - numpy==1.21.2 + - pandas==1.3.3 # min version of pvlib + - scipy==1.7.2 - pytest-rerunfailures # conda version is >3.6 - pytest-remotedata # conda package is 0.3.0, needs > 0.3.1 - requests-mock diff --git a/ci/requirements-py3.10.yml b/ci/requirements-py3.10.yml index fcb89d2e7f..444b7b58fe 100644 --- a/ci/requirements-py3.10.yml +++ b/ci/requirements-py3.10.yml @@ -8,8 +8,8 @@ dependencies: - ephem - h5py - numba - - numpy >= 1.17.3 - - pandas >= 1.3.0 + - numpy >= 1.21.2 + - pandas >= 1.3.3 - pip - pytest - pytest-cov @@ -21,7 +21,7 @@ dependencies: - python=3.10 - pytz - requests - - scipy >= 1.6.0 + - scipy >= 1.7.2 - statsmodels - pip: - nrel-pysam>=2.0 diff --git a/ci/requirements-py3.11.yml b/ci/requirements-py3.11.yml index f6556ecf94..c14676ab2f 100644 --- a/ci/requirements-py3.11.yml +++ b/ci/requirements-py3.11.yml @@ -8,8 +8,8 @@ dependencies: - ephem - h5py - numba - - numpy >= 1.17.3 - - pandas >= 1.3.0 + - numpy >= 1.21.2 + - pandas >= 1.3.3 - pip - pytest - pytest-cov @@ -21,7 +21,7 @@ dependencies: - python=3.11 - pytz - requests - - scipy >= 1.6.0 + - scipy >= 1.7.2 - statsmodels - pip: - nrel-pysam>=2.0 diff --git a/ci/requirements-py3.12.yml b/ci/requirements-py3.12.yml index 8293bffac0..1254dcb132 100644 --- a/ci/requirements-py3.12.yml +++ b/ci/requirements-py3.12.yml @@ -8,8 +8,8 @@ dependencies: - ephem - h5py - numba - - numpy >= 1.17.3 - - pandas >= 1.3.0 + - numpy >= 1.21.2 + - pandas >= 1.3.3 - pip - pytest - pytest-cov @@ -21,7 +21,7 @@ dependencies: - python=3.12 - pytz - requests - - scipy >= 1.6.0 + - scipy >= 1.7.2 - statsmodels - pip: - nrel-pysam>=2.0 diff --git a/ci/requirements-py3.13.yml b/ci/requirements-py3.13.yml index 21db6398cb..6647d72196 100644 --- a/ci/requirements-py3.13.yml +++ b/ci/requirements-py3.13.yml @@ -8,8 +8,8 @@ dependencies: - ephem - h5py - numba - - numpy >= 1.17.3 - - pandas >= 1.3.0 + - numpy >= 1.21.2 + - pandas >= 1.3.3 - pip - pytest - pytest-cov @@ -21,7 +21,7 @@ dependencies: - python=3.13 - pytz - requests - - scipy >= 1.6.0 + - scipy >= 1.7.2 - statsmodels - pip: - nrel-pysam>=2.0 diff --git a/ci/requirements-py3.9.yml b/ci/requirements-py3.14.yml similarity index 61% rename from ci/requirements-py3.9.yml rename to ci/requirements-py3.14.yml index b5aa976b4b..1b1501c66f 100644 --- a/ci/requirements-py3.9.yml +++ b/ci/requirements-py3.14.yml @@ -7,9 +7,9 @@ dependencies: - cython - ephem - h5py - - numba - - numpy >= 1.17.3 - - pandas >= 1.3.0 + # - numba # not available for py 3.14 as of 2025-11-03 + - numpy >= 1.21.2 + - pandas >= 1.3.3 - pip - pytest - pytest-cov @@ -18,11 +18,11 @@ dependencies: - pytest-timeout - pytest-rerunfailures - conda-forge::pytest-remotedata # version in default channel is old - - python=3.9 + - python=3.14 - pytz - requests - - scipy >= 1.6.0 + - scipy >= 1.7.2 - statsmodels - pip: - - nrel-pysam>=2.0 - - solarfactors \ No newline at end of file + # - nrel-pysam>=2.0 # not available for py 3.14 as of 2025-11-03 + - solarfactors diff --git a/docs/examples/adr-pvarray/plot_fit_to_matrix.py b/docs/examples/adr-pvarray/plot_fit_to_matrix.py index b256262664..9fec2d5e3c 100644 --- a/docs/examples/adr-pvarray/plot_fit_to_matrix.py +++ b/docs/examples/adr-pvarray/plot_fit_to_matrix.py @@ -51,7 +51,7 @@ 25 1000 75.0 273.651 26 1100 75.0 301.013 ''' -df = pd.read_csv(StringIO(iec61853data), delim_whitespace=True) +df = pd.read_csv(StringIO(iec61853data), sep=r"\s+") # %% # diff --git a/docs/examples/agrivoltaics/plot_diffuse_PAR_Spitters_relationship.py b/docs/examples/agrivoltaics/plot_diffuse_PAR_Spitters_relationship.py index 97ebe860a6..31e80bd9a2 100644 --- a/docs/examples/agrivoltaics/plot_diffuse_PAR_Spitters_relationship.py +++ b/docs/examples/agrivoltaics/plot_diffuse_PAR_Spitters_relationship.py @@ -57,7 +57,7 @@ solar_position = pvlib.solarposition.get_solarposition( # TMY timestamp is at end of hour, so shift to center of interval - tmy.index.shift(freq="-30T"), + tmy.index.shift(freq="-30min"), latitude=metadata["latitude"], longitude=metadata["longitude"], altitude=metadata["altitude"], diff --git a/docs/examples/bifacial/plot_bifi_model_mc.py b/docs/examples/bifacial/plot_bifi_model_mc.py index 679ff22921..f40e4e5d7b 100644 --- a/docs/examples/bifacial/plot_bifi_model_mc.py +++ b/docs/examples/bifacial/plot_bifi_model_mc.py @@ -40,7 +40,7 @@ # create site location and times characteristics lat, lon = 36.084, -79.817 tz = 'Etc/GMT+5' -times = pd.date_range('2021-06-21', '2021-6-22', freq='1T', tz=tz) +times = pd.date_range('2021-06-21', '2021-6-22', freq='1min', tz=tz) # create site system characteristics axis_tilt = 0 diff --git a/docs/examples/bifacial/plot_bifi_model_pvwatts.py b/docs/examples/bifacial/plot_bifi_model_pvwatts.py index 76a813fd4c..b6a6a582fb 100644 --- a/docs/examples/bifacial/plot_bifi_model_pvwatts.py +++ b/docs/examples/bifacial/plot_bifi_model_pvwatts.py @@ -32,7 +32,7 @@ # using Greensboro, NC for this example lat, lon = 36.084, -79.817 tz = 'Etc/GMT+5' -times = pd.date_range('2021-06-21', '2021-06-22', freq='1T', tz=tz) +times = pd.date_range('2021-06-21', '2021-06-22', freq='1min', tz=tz) # create location object and get clearsky data site_location = location.Location(lat, lon, tz=tz, name='Greensboro, NC') diff --git a/docs/examples/bifacial/plot_irradiance_nonuniformity_loss.py b/docs/examples/bifacial/plot_irradiance_nonuniformity_loss.py index cb0d4c4634..281f0954b5 100644 --- a/docs/examples/bifacial/plot_irradiance_nonuniformity_loss.py +++ b/docs/examples/bifacial/plot_irradiance_nonuniformity_loss.py @@ -47,7 +47,7 @@ # described in Figure 1 (A), [1]_. We will cover this case for educational # purposes, although it can be achieved with the packages # `solarfactors `_ and -# `bifacial_radiance `_. +# `bifacial_radiance `_. # # Here we set and plot the global irradiance level of each cell. diff --git a/docs/examples/bifacial/plot_pvfactors_fixed_tilt.py b/docs/examples/bifacial/plot_pvfactors_fixed_tilt.py index 63d7db902a..28b06110c2 100644 --- a/docs/examples/bifacial/plot_pvfactors_fixed_tilt.py +++ b/docs/examples/bifacial/plot_pvfactors_fixed_tilt.py @@ -30,7 +30,7 @@ # %% # First, generate the usual modeling inputs: -times = pd.date_range('2021-06-21', '2021-06-22', freq='1T', tz='Etc/GMT+5') +times = pd.date_range('2021-06-21', '2021-06-22', freq='1min', tz='Etc/GMT+5') loc = location.Location(latitude=40, longitude=-80, tz=times.tz) sp = loc.get_solarposition(times) cs = loc.get_clearsky(times) diff --git a/docs/examples/floating-pv/plot_floating_pv_cell_temperature.py b/docs/examples/floating-pv/plot_floating_pv_cell_temperature.py index c1b7c93f97..f7ecbd7d88 100644 --- a/docs/examples/floating-pv/plot_floating_pv_cell_temperature.py +++ b/docs/examples/floating-pv/plot_floating_pv_cell_temperature.py @@ -140,7 +140,7 @@ solar_position = pvlib.solarposition.get_solarposition( # TMY timestamp is at end of hour, so shift to center of interval - tmy.index.shift(freq='-30T'), + tmy.index.shift(freq='-30min'), latitude=metadata['latitude'], longitude=metadata['longitude'], altitude=metadata['altitude'], diff --git a/docs/examples/irradiance-decomposition/plot_diffuse_fraction.py b/docs/examples/irradiance-decomposition/plot_diffuse_fraction.py index 1c33824356..16b801b3c6 100644 --- a/docs/examples/irradiance-decomposition/plot_diffuse_fraction.py +++ b/docs/examples/irradiance-decomposition/plot_diffuse_fraction.py @@ -35,7 +35,7 @@ # NOTE: TMY3 files timestamps indicate the end of the hour, so shift indices # back 30-minutes to calculate solar position at center of the interval solpos = get_solarposition( - greensboro.index.shift(freq="-30T"), latitude=metadata['latitude'], + greensboro.index.shift(freq="-30min"), latitude=metadata['latitude'], longitude=metadata['longitude'], altitude=metadata['altitude'], pressure=greensboro.pressure*100, # convert from millibar to Pa temperature=greensboro.temp_air) @@ -44,7 +44,7 @@ # %% # pvlib Decomposition Functions # ----------------------------- -# Methods for separating DHI into diffuse and direct components include: +# Methods for separating GHI into diffuse and direct components include: # `DISC`_, `DIRINT`_, `Erbs`_, and `Boland`_. # %% @@ -52,7 +52,7 @@ # ---- # # DISC :py:func:`~pvlib.irradiance.disc` is an empirical correlation developed -# at SERI (now NREL) in 1987. The direct normal irradiance (DNI) is related to +# at SERI (now NLR) in 1987. The direct normal irradiance (DNI) is related to # clearness index (kt) by two polynomials split at kt = 0.6, then combined with # an exponential relation with airmass. @@ -112,7 +112,7 @@ # ---------------- # In the plots below we compare the four decomposition models to the TMY3 file # for Greensboro, North Carolina. We also compare the clearness index, kt, with -# GHI normalized by a reference irradiance, E0 = 1000 [W/m^2], to highlight +# GHI normalized by a reference irradiance, E0 = 1000 [Wm⁻²], to highlight # spikes caused when cosine of zenith approaches zero, particularly at sunset. # # First we combine the dataframes for the decomposition models and the TMY3 @@ -216,5 +216,5 @@ # correlations, which include additional variables such as airmass. These # methods seem to reduce DNI spikes over 1000 [W/m^2]. # -# .. _TMY3: https://www.nrel.gov/docs/fy08osti/43156.pdf -# .. _NSRDB: https://www.nrel.gov/docs/fy12osti/54824.pdf +# .. _TMY3: https://doi.org/10.2172/928611 +# .. _NSRDB: https://doi.org/10.2172/1054832 diff --git a/docs/examples/irradiance-transposition/plot_seasonal_tilt.py b/docs/examples/irradiance-transposition/plot_seasonal_tilt.py index dc4b433412..53e75cadc1 100644 --- a/docs/examples/irradiance-transposition/plot_seasonal_tilt.py +++ b/docs/examples/irradiance-transposition/plot_seasonal_tilt.py @@ -96,6 +96,6 @@ def get_orientation(self, solar_zenith, solar_azimuth): 'Seasonal 20/40 Production': mc.results.ac, 'Fixed 30 Production': mc2.results.ac, }) -results.resample('m').sum().plot() +results.resample('ME').sum().plot() plt.ylabel('Monthly Production') plt.show() diff --git a/docs/examples/irradiance-transposition/plot_transposition_gain.py b/docs/examples/irradiance-transposition/plot_transposition_gain.py index e0b7031f0b..b9e06126e6 100644 --- a/docs/examples/irradiance-transposition/plot_transposition_gain.py +++ b/docs/examples/irradiance-transposition/plot_transposition_gain.py @@ -80,7 +80,7 @@ def calculate_poa(tmy, solar_position, surface_tilt, surface_azimuth): column_name = f"FT-{tilt}" # TMYs are hourly, so we can just sum up irradiance [W/m^2] to get # insolation [Wh/m^2]: - df_monthly[column_name] = poa_irradiance.resample('m').sum() + df_monthly[column_name] = poa_irradiance.resample('ME').sum() # single-axis tracking: orientation = tracking.singleaxis(solar_position['apparent_zenith'], @@ -95,10 +95,10 @@ def calculate_poa(tmy, solar_position, surface_tilt, surface_azimuth): solar_position, orientation['surface_tilt'], orientation['surface_azimuth']) -df_monthly['SAT-0.4'] = poa_irradiance.resample('m').sum() +df_monthly['SAT-0.4'] = poa_irradiance.resample('ME').sum() # calculate the percent difference from GHI -ghi_monthly = tmy['ghi'].resample('m').sum() +ghi_monthly = tmy['ghi'].resample('ME').sum() df_monthly = 100 * (df_monthly.divide(ghi_monthly, axis=0) - 1) df_monthly.plot() diff --git a/docs/examples/shading/plot_simple_irradiance_adjustment_for_horizon_shading.py b/docs/examples/shading/plot_simple_irradiance_adjustment_for_horizon_shading.py index bdeb414adb..7aa868fe6c 100644 --- a/docs/examples/shading/plot_simple_irradiance_adjustment_for_horizon_shading.py +++ b/docs/examples/shading/plot_simple_irradiance_adjustment_for_horizon_shading.py @@ -14,7 +14,7 @@ # After location information and a date range is established, solar position # data is calculated using :py:func:`pvlib.solarposition.get_solarposition`. # Horizon data is assigned, and interpolated to the solar azimuth time -# series data. Finally, in times when solar elevation is greater than the +# series data. Finally, in times when solar elevation is less than the # interpolated horizon elevation angle, DNI is set to 0. import numpy as np @@ -27,7 +27,7 @@ # Set times in the morning of the December solstice. times = pd.date_range( - '2020-12-20 6:30', '2020-12-20 9:00', freq='1T', tz=tz + '2020-12-20 6:30', '2020-12-20 9:00', freq='1min', tz=tz ) # Create location object, and get solar position and clearsky irradiance data. diff --git a/docs/examples/solar-position/plot_sunpath_diagrams.py b/docs/examples/solar-position/plot_sunpath_diagrams.py index f6f237c706..282a8dee43 100644 --- a/docs/examples/solar-position/plot_sunpath_diagrams.py +++ b/docs/examples/solar-position/plot_sunpath_diagrams.py @@ -24,7 +24,7 @@ tz = 'Asia/Calcutta' lat, lon = 28.6, 77.2 -times = pd.date_range('2019-01-01 00:00:00', '2020-01-01', freq='H', tz=tz) +times = pd.date_range('2019-01-01 00:00:00', '2020-01-01', freq='h', tz=tz) solpos = solarposition.get_solarposition(times, lat, lon) # remove nighttime solpos = solpos.loc[solpos['apparent_elevation'] > 0, :] @@ -112,7 +112,7 @@ tz = 'Asia/Calcutta' lat, lon = 28.6, 77.2 -times = pd.date_range('2019-01-01 00:00:00', '2020-01-01', freq='H', tz=tz) +times = pd.date_range('2019-01-01 00:00:00', '2020-01-01', freq='h', tz=tz) solpos = solarposition.get_solarposition(times, lat, lon) # remove nighttime diff --git a/docs/examples/spectrum/average_photon_energy.py b/docs/examples/spectrum/average_photon_energy.py index a883f929ea..57918da3b5 100644 --- a/docs/examples/spectrum/average_photon_energy.py +++ b/docs/examples/spectrum/average_photon_energy.py @@ -11,8 +11,8 @@ # This example demonstrates how to use the # :py:func:`~pvlib.spectrum.average_photon_energy` function to calculate the # Average Photon Energy (APE, :math:`\overline{E_\gamma}`) of spectral -# irradiance distributions. This example uses spectral irradiance simulated -# using :py:func:`~pvlib.spectrum.spectrl2`, but the same method is +# irradiance distributions. This example uses clearsky spectral irradiance +# simulated using :py:func:`~pvlib.spectrum.spectrl2`, but the same method is # applicable to spectral irradiance from any source. # More information on the SPECTRL2 model can be found in [1]_. # The APE parameter is a useful indicator of the overall shape of the solar @@ -34,19 +34,20 @@ from scipy.integrate import trapezoid from pvlib import spectrum, solarposition, irradiance, atmosphere -lat, lon = 39.742, -105.18 # NREL SRRL location -tilt = 25 -azimuth = 180 # south-facing system +lat, lon = 39.742, -105.18 # SRRL location +surface_tilt = 25 +surface_azimuth = 180 # south-facing system pressure = 81190 # at 1828 metres AMSL, roughly -water_vapor_content = 0.5 # cm -tau500 = 0.1 +precipitable_water = 0.5 # cm +aerosol_turbidity_500nm = 0.1 ozone = 0.31 # atm-cm albedo = 0.2 times = pd.date_range('2023-01-01 08:00', freq='h', periods=9, tz='America/Denver') solpos = solarposition.get_solarposition(times, lat, lon) -aoi = irradiance.aoi(tilt, azimuth, solpos.apparent_zenith, solpos.azimuth) +aoi = irradiance.aoi(surface_tilt, surface_azimuth, + solpos.apparent_zenith, solpos.azimuth) relative_airmass = atmosphere.get_relative_airmass(solpos.apparent_zenith, model='kastenyoung1989') @@ -64,13 +65,13 @@ spectra_components = spectrum.spectrl2( apparent_zenith=solpos.apparent_zenith, aoi=aoi, - surface_tilt=tilt, + surface_tilt=surface_tilt, ground_albedo=albedo, surface_pressure=pressure, relative_airmass=relative_airmass, - precipitable_water=water_vapor_content, + precipitable_water=precipitable_water, ozone=ozone, - aerosol_turbidity_500nm=tau500, + aerosol_turbidity_500nm=aerosol_turbidity_500nm, ) # %% @@ -193,5 +194,5 @@ # for the solar spectral influence on photovoltaic device performance." # Energy 286 :doi:`10.1016/j.energy.2023.129461` # .. [4] Bird Simple Spectral Model: spectrl2_2.c -# https://www.nrel.gov/grid/solar-resource/spectral.html -# (Last accessed: 18/09/2024) +# https://www.nlr.gov/grid/solar-resource/spectral +# (Last accessed: 03/03/2026) diff --git a/docs/examples/system-models/plot_oedi_9068.py b/docs/examples/system-models/oedi_9068.py similarity index 95% rename from docs/examples/system-models/plot_oedi_9068.py rename to docs/examples/system-models/oedi_9068.py index 8544f34c9a..9a73bfa9ce 100644 --- a/docs/examples/system-models/plot_oedi_9068.py +++ b/docs/examples/system-models/oedi_9068.py @@ -143,10 +143,12 @@ keys = ['ghi', 'dni', 'dhi', 'temp_air', 'wind_speed', 'albedo', 'precipitable_water'] -psm3, psm3_metadata = pvlib.iotools.get_psm3(latitude, longitude, api_key, - email, interval=5, names=2019, - map_variables=True, leap_day=True, - attributes=keys) +psm3, psm3_metadata = pvlib.iotools.get_nsrdb_psm4_conus(latitude, longitude, + api_key, email, + year=2019, interval=5, + parameters=keys, + map_variables=True, + leap_day=True) # %% # Pre-generate some model inputs diff --git a/docs/examples/system-models/plot_modelchain_model_variations.py b/docs/examples/system-models/plot_modelchain_model_variations.py new file mode 100644 index 0000000000..f666311943 --- /dev/null +++ b/docs/examples/system-models/plot_modelchain_model_variations.py @@ -0,0 +1,196 @@ +""" +Varying Model Components in ModelChain +====================================== + +This example demonstrates how changing modeling components +within ``pvlib.modelchain.ModelChain`` affects simulation results. + +Using the same PV system and weather data, we create two +ModelChain instances that differ only in their temperature +model. By comparing the resulting cell temperature and AC +power output, we can see how changing a single modeling +component affects overall system behavior. +""" + +# %% +# Varying ModelChain components +# ------------------------------ +# +# Below, we create two ModelChain objects with identical system +# definitions and weather inputs. The only difference between them +# is the selected temperature model. This highlights how individual +# modeling components in ``ModelChain`` can be swapped while keeping +# the overall workflow unchanged. + +import pvlib +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt + +# %% +# Define location +# --------------- +# +# We select Tucson, Arizona, a location frequently used in pvlib +# examples due to its strong solar resource and available TMY data. +latitude = 32.2 +longitude = -110.9 +location = pvlib.location.Location(latitude, longitude) + +# %% +# Generate clear-sky weather data +# -------------------------------- +# +# We generate clear-sky irradiance using pvlib and create a +# varying air temperature profile instead of using constant +# values. +times = pd.date_range( + "2019-06-01 00:00", + "2019-06-07 23:00", + freq="1h", + tz="Etc/GMT+7", +) + +# Clear-sky irradiance +clearsky = location.get_clearsky(times) + +# Create a simple daily temperature cycle +temp_air = 20 + 10 * np.sin(2 * np.pi * (times.hour - 6) / 24) + +weather_subset = clearsky.copy() +weather_subset["temp_air"] = temp_air +weather_subset["wind_speed"] = 1 + +# %% +# Define a simple PV system +# ------------------------- +# +# To keep the focus on the temperature model comparison, +# we define a minimal PV system using the PVWatts DC and AC models. +# These models require only a few high-level parameters. +# +# The module DC rating (pdc0) represents the array capacity at +# reference conditions, and gamma_pdc describes the power +# temperature coefficient. +# +# For the temperature model parameters, we use the sapm values +# for an open-rack, glass-glass module configuration. These +# parameters describe how heat is transferred from the module +# to the surrounding environment. +module_parameters = dict(pdc0=5000, gamma_pdc=-0.003) +inverter_parameters = dict(pdc0=4000) + +temperature_model_parameters = ( + pvlib.temperature.TEMPERATURE_MODEL_PARAMETERS["sapm"] + ["open_rack_glass_glass"] +) + +system = pvlib.pvsystem.PVSystem( + surface_tilt=30, + surface_azimuth=180, + module_parameters=module_parameters, + inverter_parameters=inverter_parameters, + temperature_model_parameters=temperature_model_parameters, +) + +# %% +# ModelChain using the sapm temperature model +# -------------------------------------------- +# +# First, we construct a ModelChain that uses the sapm +# temperature model. All other modeling components remain +# identical between simulations. +# +# This ensures that any differences in the results arise +# solely from the temperature model choice. +temperature_model_parameters_sapm = ( + pvlib.temperature.TEMPERATURE_MODEL_PARAMETERS["sapm"] + ["open_rack_glass_glass"] +) + +system_sapm = pvlib.pvsystem.PVSystem( + surface_tilt=30, + surface_azimuth=180, + module_parameters=module_parameters, + inverter_parameters=inverter_parameters, + temperature_model_parameters=temperature_model_parameters_sapm, +) + +mc_sapm = pvlib.modelchain.ModelChain( + system_sapm, + location, + dc_model="pvwatts", + ac_model="pvwatts", + temperature_model="sapm", + aoi_model="no_loss", +) + +mc_sapm.run_model(weather_subset) + +# %% +# ModelChain using the Faiman temperature model +# ---------------------------------------------- +# +# Next, we repeat the same simulation but replace the +# temperature model with the Faiman model. +# +# No other system or weather parameters are changed. +# This illustrates how individual components within +# ModelChain can be varied independently. +temperature_model_parameters_faiman = dict(u0=25, u1=6.84) + +system_faiman = pvlib.pvsystem.PVSystem( + surface_tilt=30, + surface_azimuth=180, + module_parameters=module_parameters, + inverter_parameters=inverter_parameters, + temperature_model_parameters=temperature_model_parameters_faiman, +) + +mc_faiman = pvlib.modelchain.ModelChain( + system_faiman, + location, + dc_model="pvwatts", + ac_model="pvwatts", + temperature_model="faiman", + aoi_model="no_loss", +) + +mc_faiman.run_model(weather_subset) + +# %% +# Compare modeled cell temperature +# --------------------------------- +# +# Since module temperature directly affects DC power +# through the temperature coefficient, differences +# between temperature models can propagate into +# performance results. + +# %% +fig, ax = plt.subplots(figsize=(10, 4)) +mc_sapm.results.cell_temperature.plot(ax=ax, label="SAPM") +mc_faiman.results.cell_temperature.plot(ax=ax, label="Faiman") + +ax.set_ylabel("Cell Temperature (°C)") +ax.set_title("Comparison of Cell Temperature") +ax.legend() +plt.tight_layout() + +# %% +# Compare AC power output +# ------------------------ +# +# Finally, we compare the resulting AC power. In this case, the +# differences in temperature modeling lead to small +# differences in predicted energy production. + +# %% +fig, ax = plt.subplots(figsize=(10, 4)) +mc_sapm.results.ac.plot(ax=ax, label="SAPM") +mc_faiman.results.ac.plot(ax=ax, label="Faiman") + +ax.set_ylabel("AC Power (W)") +ax.set_title("AC Output with Different Temperature Models") +ax.legend() +plt.tight_layout() diff --git a/docs/sphinx/source/conf.py b/docs/sphinx/source/conf.py index 0415acb644..defbb2cdd0 100644 --- a/docs/sphinx/source/conf.py +++ b/docs/sphinx/source/conf.py @@ -388,8 +388,8 @@ def setup(app): sphinx_gallery_conf = { 'examples_dirs': ['../../examples'], # location of gallery scripts 'gallery_dirs': ['gallery'], # location of generated output - # execute all scripts except for ones in the "system-models" directory: - 'filename_pattern': '^((?!system-models).)*$', + # execute only files starting with plot_ + 'filename_pattern': 'plot_', # directory where function/class granular galleries are stored 'backreferences_dir': 'reference/generated/gallery_backreferences', diff --git a/docs/sphinx/source/contributing/how_to_contribute_new_code.rst b/docs/sphinx/source/contributing/how_to_contribute_new_code.rst index ca08436ab1..5a4546baae 100644 --- a/docs/sphinx/source/contributing/how_to_contribute_new_code.rst +++ b/docs/sphinx/source/contributing/how_to_contribute_new_code.rst @@ -40,6 +40,16 @@ information on these aspects. Pull requests (PRs) ~~~~~~~~~~~~~~~~~~~ +.. _pull-request-checklist: + +PR checklist +------------ + +Every pull request description must include the +`PR checklist `_. +If your workflow overrides the default PR template (e.g. ``gh pr create +--body "..."``) make sure to copy the checklist into the description. + .. _pull-request-scope: Scope diff --git a/docs/sphinx/source/contributing/introduction_to_contributing.rst b/docs/sphinx/source/contributing/introduction_to_contributing.rst index 0eb9d97e4b..a1b45a59fc 100644 --- a/docs/sphinx/source/contributing/introduction_to_contributing.rst +++ b/docs/sphinx/source/contributing/introduction_to_contributing.rst @@ -12,6 +12,27 @@ contributors from novice to expert. Don't worry if you don't (yet) understand parts of it. +.. _core-guidelines: + +Core guidelines +~~~~~~~~~~~~~~~ +While we welcome new contributors, reviewer capacity is limited. Therefore, +following these core guidelines is essential: + +* Pull requests (PRs) should address one or more open issues. Context should + be provided, e.g. a link to the relevant discussion. +* GSoC: For Google Summer of Code, we invite applications only from those with + PV modeling experience and who are motivated to join the pvlib maintenance + team. +* pvlib-python is an advanced scientific modeling toolbox. A background + in solar energy and/or experience in pvlib-python is required to + address some issues effectively. It is not always clear which issues + fall into this bucket. Don't be offended if maintainers close your PR for + this reason. +* Maintainers reserve the right to close PRs that do not comply with the + contributing guidelines. + + .. _easy-ways-to-contribute: Easy ways to contribute diff --git a/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst b/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst index 982bc91742..b453a300ee 100644 --- a/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst +++ b/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst @@ -12,9 +12,10 @@ Spectrum spectrum.calc_spectral_mismatch_field spectrum.spectral_factor_caballero spectrum.spectral_factor_firstsolar - spectrum.spectral_factor_sapm - spectrum.spectral_factor_pvspec spectrum.spectral_factor_jrc + spectrum.spectral_factor_polo + spectrum.spectral_factor_pvspec + spectrum.spectral_factor_sapm spectrum.sr_to_qe spectrum.qe_to_sr spectrum.average_photon_energy diff --git a/docs/sphinx/source/reference/iotools.rst b/docs/sphinx/source/reference/iotools.rst index 12db7d6818..9588a8daf7 100644 --- a/docs/sphinx/source/reference/iotools.rst +++ b/docs/sphinx/source/reference/iotools.rst @@ -81,9 +81,6 @@ Satellite-derived irradiance and weather data for the Americas. iotools.get_nsrdb_psm4_conus iotools.get_nsrdb_psm4_full_disc iotools.read_nsrdb_psm4 - iotools.get_psm3 - iotools.read_psm3 - iotools.parse_psm3 Commercial datasets @@ -182,7 +179,7 @@ A solar radiation network in the USA, run by NOAA. MIDC ^^^^ -A solar radiation network in the USA, run by NREL. +A solar radiation network in the USA, run by NLR (known as NREL prior to December 2025). .. autosummary:: :toctree: generated/ @@ -236,6 +233,27 @@ lower quality. iotools.read_crn +ECMWF ERA5 +^^^^^^^^^^ + +A global reanalysis dataset providing weather and solar resource data. + +.. autosummary:: + :toctree: generated/ + + iotools.get_era5 + +MERRA-2 +^^^^^^^ + +A global reanalysis dataset providing weather, aerosol, and solar irradiance +data. + +.. autosummary:: + :toctree: generated/ + + iotools.get_merra2 + Generic data file readers ------------------------- diff --git a/docs/sphinx/source/reference/irradiance/angle-of-incidence.rst b/docs/sphinx/source/reference/irradiance/angle-of-incidence.rst new file mode 100644 index 0000000000..6645648437 --- /dev/null +++ b/docs/sphinx/source/reference/irradiance/angle-of-incidence.rst @@ -0,0 +1,10 @@ +.. currentmodule:: pvlib + +Angle of incidence +------------------ + +.. autosummary:: + :toctree: ../generated/ + + irradiance.aoi + irradiance.aoi_projection diff --git a/docs/sphinx/source/reference/irradiance/class-methods.rst b/docs/sphinx/source/reference/irradiance/class-methods.rst deleted file mode 100644 index f7fa25663b..0000000000 --- a/docs/sphinx/source/reference/irradiance/class-methods.rst +++ /dev/null @@ -1,11 +0,0 @@ -.. currentmodule:: pvlib - -Methods for irradiance calculations ------------------------------------ - -.. autosummary:: - :toctree: ../generated/ - - pvsystem.PVSystem.get_irradiance - pvsystem.PVSystem.get_aoi - pvsystem.PVSystem.get_iam diff --git a/docs/sphinx/source/reference/irradiance/clearness-index.rst b/docs/sphinx/source/reference/irradiance/clearness-index.rst index bfc6d7bf8e..76825eaf5e 100644 --- a/docs/sphinx/source/reference/irradiance/clearness-index.rst +++ b/docs/sphinx/source/reference/irradiance/clearness-index.rst @@ -1,7 +1,7 @@ .. currentmodule:: pvlib -Clearness index models ----------------------- +Clearness and clearsky index +---------------------------- .. autosummary:: :toctree: ../generated/ diff --git a/docs/sphinx/source/reference/irradiance/components.rst b/docs/sphinx/source/reference/irradiance/components.rst deleted file mode 100644 index ce75d9d083..0000000000 --- a/docs/sphinx/source/reference/irradiance/components.rst +++ /dev/null @@ -1,17 +0,0 @@ -.. currentmodule:: pvlib - -Decomposing and combining irradiance ------------------------------------- - -.. autosummary:: - :toctree: ../generated/ - - irradiance.get_extra_radiation - irradiance.aoi - irradiance.aoi_projection - irradiance.beam_component - irradiance.poa_components - irradiance.get_ground_diffuse - irradiance.dni - irradiance.complete_irradiance - irradiance.diffuse_par_spitters diff --git a/docs/sphinx/source/reference/irradiance/decomposition.rst b/docs/sphinx/source/reference/irradiance/decomposition.rst index eede9df089..cf0d355ab4 100644 --- a/docs/sphinx/source/reference/irradiance/decomposition.rst +++ b/docs/sphinx/source/reference/irradiance/decomposition.rst @@ -2,8 +2,8 @@ .. _dniestmodels: -DNI estimation models ---------------------- +Decomposition models +-------------------- .. autosummary:: :toctree: ../generated/ @@ -16,6 +16,4 @@ DNI estimation models irradiance.orgill_hollands irradiance.boland irradiance.campbell_norman - irradiance.gti_dirint irradiance.louche - diff --git a/docs/sphinx/source/reference/irradiance/extraterrestrial-radiation.rst b/docs/sphinx/source/reference/irradiance/extraterrestrial-radiation.rst new file mode 100644 index 0000000000..b08b6334e2 --- /dev/null +++ b/docs/sphinx/source/reference/irradiance/extraterrestrial-radiation.rst @@ -0,0 +1,9 @@ +.. currentmodule:: pvlib + +Extraterrestrial radiation +-------------------------- + +.. autosummary:: + :toctree: ../generated/ + + irradiance.get_extra_radiation diff --git a/docs/sphinx/source/reference/irradiance/index.rst b/docs/sphinx/source/reference/irradiance/index.rst index 72064cccbc..b2d5917a0e 100644 --- a/docs/sphinx/source/reference/irradiance/index.rst +++ b/docs/sphinx/source/reference/irradiance/index.rst @@ -6,9 +6,10 @@ Irradiance .. toctree:: :maxdepth: 2 - class-methods - components - transposition decomposition + transposition + reverse-transposition + angle-of-incidence clearness-index - albedo + extraterrestrial-radiation + other diff --git a/docs/sphinx/source/reference/irradiance/albedo.rst b/docs/sphinx/source/reference/irradiance/other.rst similarity index 51% rename from docs/sphinx/source/reference/irradiance/albedo.rst rename to docs/sphinx/source/reference/irradiance/other.rst index 868a065d1a..ebeecbce9f 100644 --- a/docs/sphinx/source/reference/irradiance/albedo.rst +++ b/docs/sphinx/source/reference/irradiance/other.rst @@ -1,9 +1,12 @@ .. currentmodule:: pvlib -Albedo ------- +Other +----- .. autosummary:: :toctree: ../generated/ + irradiance.dni + irradiance.complete_irradiance + irradiance.diffuse_par_spitters albedo.inland_water_dvoracek diff --git a/docs/sphinx/source/reference/irradiance/reverse-transposition.rst b/docs/sphinx/source/reference/irradiance/reverse-transposition.rst new file mode 100644 index 0000000000..31ce27bedd --- /dev/null +++ b/docs/sphinx/source/reference/irradiance/reverse-transposition.rst @@ -0,0 +1,10 @@ +.. currentmodule:: pvlib + +Reverse transposition models +---------------------------- + +.. autosummary:: + :toctree: ../generated/ + + irradiance.ghi_from_poa_driesse_2023 + irradiance.gti_dirint diff --git a/docs/sphinx/source/reference/irradiance/transposition.rst b/docs/sphinx/source/reference/irradiance/transposition.rst index 22136f0c58..2e15b4cafb 100644 --- a/docs/sphinx/source/reference/irradiance/transposition.rst +++ b/docs/sphinx/source/reference/irradiance/transposition.rst @@ -8,6 +8,9 @@ Transposition models irradiance.get_total_irradiance irradiance.get_sky_diffuse + irradiance.get_ground_diffuse + irradiance.beam_component + irradiance.poa_components irradiance.isotropic irradiance.perez irradiance.perez_driesse @@ -15,4 +18,3 @@ Transposition models irradiance.klucher irradiance.reindl irradiance.king - irradiance.ghi_from_poa_driesse_2023 diff --git a/docs/sphinx/source/reference/location.rst b/docs/sphinx/source/reference/location.rst index 28ef46812a..5215ba81b7 100644 --- a/docs/sphinx/source/reference/location.rst +++ b/docs/sphinx/source/reference/location.rst @@ -9,3 +9,23 @@ Methods for information about locations. :toctree: generated/ location.lookup_altitude + +Classes +------- +.. autosummary:: + :toctree: generated/ + + location.Location + +A :py:class:`~pvlib.location.Location` object may be created from the +metadata returned by these file types: + +.. autosummary:: + :toctree: generated/ + + location.Location.from_tmy + location.Location.from_epw + +Methods for calculating time series of certain variables for a given +location are available through this class. + diff --git a/docs/sphinx/source/reference/pv_modeling/parameters.rst b/docs/sphinx/source/reference/pv_modeling/parameters.rst index 1f146eaccc..5ebda621b6 100644 --- a/docs/sphinx/source/reference/pv_modeling/parameters.rst +++ b/docs/sphinx/source/reference/pv_modeling/parameters.rst @@ -14,6 +14,7 @@ Functions for fitting single diode models ivtools.sdm.fit_pvsyst_sandia ivtools.sdm.fit_pvsyst_iec61853_sandia_2025 ivtools.sdm.fit_desoto_sandia + ivtools.sdm.fit_desoto_batzelis Functions for fitting the single diode equation diff --git a/docs/sphinx/source/reference/pv_modeling/sdm.rst b/docs/sphinx/source/reference/pv_modeling/sdm.rst index bfd5103ebe..077cdb16ee 100644 --- a/docs/sphinx/source/reference/pv_modeling/sdm.rst +++ b/docs/sphinx/source/reference/pv_modeling/sdm.rst @@ -17,6 +17,7 @@ Functions relevant for single diode models. pvsystem.v_from_i pvsystem.max_power_point ivtools.sdm.pvsyst_temperature_coeff + singlediode.batzelis Low-level functions for solving the single diode equation. @@ -37,3 +38,4 @@ Functions for fitting diode models ivtools.sde.fit_sandia_simple ivtools.sdm.fit_cec_sam ivtools.sdm.fit_desoto + ivtools.sdm.fit_desoto_batzelis diff --git a/docs/sphinx/source/reference/pv_modeling/system_models.rst b/docs/sphinx/source/reference/pv_modeling/system_models.rst index fb637ee8ed..3b8f29d0b2 100644 --- a/docs/sphinx/source/reference/pv_modeling/system_models.rst +++ b/docs/sphinx/source/reference/pv_modeling/system_models.rst @@ -55,3 +55,11 @@ PVGIS model :toctree: ../generated/ pvarray.huld + +Other +^^^^^ + +.. autosummary:: + :toctree: ../generated/ + + pvarray.batzelis diff --git a/docs/sphinx/source/user_guide/extras/faq.rst b/docs/sphinx/source/user_guide/extras/faq.rst index f87fa101b5..81b69731d9 100644 --- a/docs/sphinx/source/user_guide/extras/faq.rst +++ b/docs/sphinx/source/user_guide/extras/faq.rst @@ -52,15 +52,7 @@ Where can I get irradiance data for my simulation? pvlib has a module called iotools which has several functions for retrieving irradiance data as well as reading standard file formats -such as EPW, TMY2, and TMY3. For free irradiance data, you may -consider NREL's NSRDB which can be accessed using the -:py:func:`pvlib.iotools.get_psm3` function and is available for -North America. For Europe and Africa, you may consider looking into -CAMS (:py:func:`pvlib.iotools.get_cams`). -PVGIS (:py:func:`pvlib.iotools.get_pvgis_hourly`) is another option, which -provides irradiance from several different databases with near global coverage. -pvlib also has functions for accessing a plethora of ground-measured -irradiance datasets, including the BSRN, SURFRAD, SRML, and NREL's MIDC. +such as EPW, TMY2, and TMY3. See :ref:`weatherdata`. Can I use PVsyst (PAN/OND) files with pvlib? @@ -134,7 +126,7 @@ The CEC table doesn't include my module or inverter, what should I do? ---------------------------------------------------------------------- The CEC tables for module and inverter parameters included in pvlib are periodically -copied from `SAM `_, +copied from `SAM `_, so you can check the tables there for more up-to-date tables. For modules, if even the SAM files don't include the module you're looking for diff --git a/docs/sphinx/source/user_guide/extras/nomenclature.rst b/docs/sphinx/source/user_guide/extras/nomenclature.rst index 444b1ae5cc..9507680e7e 100644 --- a/docs/sphinx/source/user_guide/extras/nomenclature.rst +++ b/docs/sphinx/source/user_guide/extras/nomenclature.rst @@ -19,7 +19,16 @@ There is a convention on consistent variable names throughout the library: albedo Ratio of reflected solar irradiance to global horizontal irradiance [unitless] - + + aod + aod500 + aerosol optical depth [unitless]. Measure of aerosols (e.g., smoke + particles, desert dust) distributed within a column of air from the + instrument (Earth's surface) to the top of the atmosphere. The AOD + value indicates the level of extinction of sunlight in this column, and + when followed by a number (e.g. AOD500), indicates the extinction at + this wavelength (500nm). + aoi Angle of incidence. Angle between the surface normal vector and the vector pointing towards the sun's center. [°] @@ -33,9 +42,9 @@ There is a convention on consistent variable names throughout the library: apparent_zenith Refraction-corrected solar zenith angle. The solar - zenith angle describes the position of the sun relative to the vertical and is - defined as the angle between a vector pointed straight up and a vector pointed - at the sun, from the observer. [°] + zenith angle describes the position of the sun relative to the vertical + and is defined as the angle between a vector pointed straight up and a + vector pointed at the sun, from the observer. [°] apparent_elevation Refraction-corrected solar elevation angle. This is the complement of @@ -43,7 +52,16 @@ There is a convention on consistent variable names throughout the library: bhi Beam/direct horizontal irradiance - + + clearness_index + clearness index [unitless]. Ratio of global horizontal irraidance to + the extra terrestrial irriance. The clearness index ranges between + 0 and 1, with values closer to 1 indicating clear skies. + + clearsky_index + clearsky index [unitless]. Ratio of actual global irradiance to modeled + clearsky global irradiance. + dhi Diffuse horizontal irradiance @@ -88,6 +106,9 @@ There is a convention on consistent variable names throughout the library: gri Ground-reflected irradiance + iam + Incidence angle modifier + i_sc Short circuit module current @@ -95,10 +116,12 @@ There is a convention on consistent variable names throughout the library: Sandia Array Performance Model IV curve parameters latitude - Latitude in decimal degrees. Positive north of equator, negative to south. + Latitude in decimal degrees. Positive north of equator, negative to + south. longitude - Longitude in decimal degrees. Positive east of prime meridian, negative to west. + Longitude in decimal degrees. Positive east of prime meridian, negative + to west. pac, ac AC power @@ -149,15 +172,16 @@ There is a convention on consistent variable names throughout the library: Diode saturation current solar_azimuth - Azimuth angle of the sun in degrees East of North. The solar azimuth angle - describes the sun’s position along the horizon relative to the observer. - The pvlib-python convention is defined as degrees East of North, so - North = 0°, East = 90°, South = 180°, West = 270°. + Azimuth angle of the sun in degrees East of North. The solar azimuth + angle describes the sun’s position along the horizon relative to the + observer. Azimuth is defined as degrees East of + North, so North = 0°, East = 90°, South = 180°, West = 270°. solar_zenith - Zenith angle of the sun in degrees. This is the angle between is between a - vector pointed straight up and a vector pointed at the sun, from the observer. - This is the complement of solar elevation (90 - elevation). [°] + Zenith angle of the sun in degrees [°]. Zenith is the angle between is + between a vector pointed straight up and a vector pointed at the sun, + from the observer. Zenith is the complement of solar elevation, i.e., + zenith = 90 - elevation. spectra spectra_components @@ -167,16 +191,17 @@ There is a convention on consistent variable names throughout the library: is composed of direct and diffuse components. surface_azimuth - Azimuth angle of the surface in degrees East of North. This angle describes the - horizontal projection of the normal vector from the surface. The pvlib-python - convention is defined as degrees East (clockwise) of North, so North = 0°, - East = 90°, South = 180°, West = 270°. + Azimuth angle of the surface in degrees East of North. Surface azimuth + is specified by the horizontal projection of the normal vector from + the surface. Azimuth is defined as degrees East + (clockwise) of North, so North = 0°, East = 90°, South = 180°, + West = 270°. surface_tilt Tilt from horizontal [°]. The surface tilt angle is defined as degrees from the horizontal - such that a surface facing up would have a surface tilt of 0°, and one facing - the horizon would be 90°. [°] + such that a surface facing up would have a surface tilt of 0°, and one + facing the horizon would be 90°. [°] temp_air Temperature of the air diff --git a/docs/sphinx/source/user_guide/getting_started/installation.rst b/docs/sphinx/source/user_guide/getting_started/installation.rst index c50fe0a37c..be68ca94a6 100644 --- a/docs/sphinx/source/user_guide/getting_started/installation.rst +++ b/docs/sphinx/source/user_guide/getting_started/installation.rst @@ -185,7 +185,7 @@ With your conda/virtual environment still active... Consider installing pvlib using ``pip install -e .[all]`` so that you can run the unit tests and build the documentation. Your clone directory is probably similar to - ``C:\Users\%USER%\Documents\GitHub\pvlib-python``(Windows) or + ``C:\Users\%USER%\Documents\GitHub\pvlib-python`` (Windows) or ``/Users/%USER%/Documents/pvlib-python`` (Mac). #. **Test** your installation by running ``python -c 'import pvlib'``. You're good to go if it returns without an exception. @@ -242,15 +242,15 @@ pvlib-python is distributed with several validated, high-precision, and high-performance solar position calculators. We strongly recommend using the built-in solar position calculators. -pvlib-python also includes unsupported wrappers for the official NREL -SPA algorithm. NREL's license does not allow redistribution of the +pvlib-python also includes unsupported wrappers for the official NLR +implementation of NREL SPA. NLR's license does not allow redistribution of the source code, so you must jump through some hoops to use it with pvlib. You will need a C compiler to use this code. To install the NREL SPA algorithm for use with pvlib: #. Download the pvlib repository (as described in :ref:`obtainsource`) -#. Download the `SPA files from NREL `_ +#. Download the `SPA files from NLR `_ #. Copy the SPA files into ``pvlib-python/pvlib/spa_c_files`` #. From the ``pvlib-python`` directory, run ``pip uninstall pvlib`` followed by ``pip install .`` diff --git a/docs/sphinx/source/user_guide/index.rst b/docs/sphinx/source/user_guide/index.rst index 07102a5630..af2a4cbb92 100644 --- a/docs/sphinx/source/user_guide/index.rst +++ b/docs/sphinx/source/user_guide/index.rst @@ -26,6 +26,8 @@ This user guide is an overview and explains some of the key features of pvlib. modeling_topics/clearsky modeling_topics/weather_data modeling_topics/singlediode + modeling_topics/temperature + modeling_topics/iam .. toctree:: :maxdepth: 2 diff --git a/docs/sphinx/source/user_guide/modeling_topics/clearsky.rst b/docs/sphinx/source/user_guide/modeling_topics/clearsky.rst index f074f21c99..cde1c3c58c 100644 --- a/docs/sphinx/source/user_guide/modeling_topics/clearsky.rst +++ b/docs/sphinx/source/user_guide/modeling_topics/clearsky.rst @@ -255,10 +255,11 @@ wavelengths [Bir80]_, and is implemented in @savefig kasten-tl.png width=10in In [1]: plt.tight_layout() - Examples ^^^^^^^^ +.. _clearsky-examples: + A clear sky time series using only basic pvlib functions. .. ipython:: @@ -281,7 +282,6 @@ A clear sky time series using only basic pvlib functions. In [1]: dni_extra = pvlib.irradiance.get_extra_radiation(times) - # an input is a pandas Series, so solis is a DataFrame In [1]: ineichen = clearsky.ineichen(apparent_zenith, airmass, linke_turbidity, altitude, dni_extra) In [1]: plt.figure(); diff --git a/docs/sphinx/source/user_guide/modeling_topics/iam.rst b/docs/sphinx/source/user_guide/modeling_topics/iam.rst new file mode 100644 index 0000000000..3097164604 --- /dev/null +++ b/docs/sphinx/source/user_guide/modeling_topics/iam.rst @@ -0,0 +1,100 @@ +.. _iam: + + +Incidence angle modifier +======================== + +Some fraction of the light incident on a PV module surface is reflected away or +absorbed before it reaches the PV cell. This irradiance reduction depends +on the angle at which the light strikes the module (the angle of incidence, +:term:`AOI `) and the optical properties of the module. + +Some reduction occurs at all angles of incidence, even normal incidence. +However, because PV modules are rated with irradiance at normal incidence, +the reduction at normal incidence is implicit in the PV module's power rating +and does not need to be accounted for separately in a performance model. +Therefore, only the extra reduction at non-normal incidence should be modeled. + +This is done using incidence angle modififer (:term:`IAM `) models. +Conceptually, IAM is the fraction of incident light that is +transmitted to the PV cell, normalized to the fraction transmitted at normal incidence: + +.. math:: + + IAM(\theta) = \frac{T(\theta)}{T(0)}, + +where :math:`T(\theta)` represents the transmitted light fraction at AOI :math:`\theta`. +IAM equals (by definition) 1.0 when AOI is zero and typically approaches zero +as AOI approaches 90°. The shape of the IAM profile at intermediate AOI +is nonlinear and depends on the module's optical properties. + +IAM may also depend on the wavelength of the light, the polarization of the light, +and which side of the module the light comes from. However, IAM models usually +neglect these minor effects. + +IAM functions in pvlib take an input angle in degrees and return a unitless ratio +in the range 0–1. + + +Types of models +--------------- + +Because total in-plane irradiance is the combination of light from many +directions, IAM values are computed for each component separately: + +- *direct IAM*: IAM computed for the AOI of direct irradiance +- *circumsolar IAM*: typically approximated as equal to the direct IAM +- *diffuse IAM*: IAM integrated across the ranges of AOI spanning the sky and/or + ground surfaces + +Because diffuse light can be thought of as a field of many small beams of +direct light, diffuse IAM can then be understood as the IAM averaged across +those individual beams. This averaging can be done explicitly or empirically. + +In principle, IAM should be applied to all components of incident irradiance. +In practice, IAM is sometimes applied only to the direct component of in-plane +irradiance, as the direct component is often the largest contributor to total +in-plane irradiance and has a highly variable AOI across the day and year. + +The IAM models currently available in pvlib are summarized in the +following table: + ++-------------------------------------------+---------+-------------------------------------------+ +| Model | Type | Notes | ++===========================================+=========+===========================================+ +| :py:func:`~pvlib.iam.ashrae` | direct | Once common, now less used | ++-------------------------------------------+---------+-------------------------------------------+ +| :py:func:`~pvlib.iam.martin_ruiz` | direct | Used in the IEC 61853 standard | ++-------------------------------------------+---------+-------------------------------------------+ +| :py:func:`~pvlib.iam.martin_ruiz_diffuse` | diffuse | Used in the IEC 61853 standard | ++-------------------------------------------+---------+-------------------------------------------+ +| :py:func:`~pvlib.iam.physical` | direct | Physics-based; optional AR coating | ++-------------------------------------------+---------+-------------------------------------------+ +| :py:func:`~pvlib.iam.sapm` | direct | Can be non-monotonic and exceed 1.0 | ++-------------------------------------------+---------+-------------------------------------------+ +| :py:func:`~pvlib.iam.schlick` | direct | Does not take module-specific parameters | ++-------------------------------------------+---------+-------------------------------------------+ +| :py:func:`~pvlib.iam.schlick_diffuse` | diffuse | Does not take module-specific parameters | ++-------------------------------------------+---------+-------------------------------------------+ + +In addition to the core models above, pvlib provides several other functions +for IAM modeling: + +- :py:func:`~pvlib.iam.interp`: interpolate between points on a measured IAM profile +- :py:func:`~pvlib.iam.marion_diffuse` and :py:func:`~pvlib.iam.marion_integrate`: + numerically integrate any IAM model across AOI to compute sky, horizon, and ground IAMs + + +Model parameters +---------------- + +Some IAM model functions provide default values for their parameters. +However, these generic values may not be suitable for all PV modules. +It should be noted that using the default parameter values for each +model generally leads to different IAM profiles. + +Module-specific values can be obtained via testing. For example, IEC 61853-2 +testing produces measured IAM values across the range of AOI and a corresponding +parameter value for the Martin-Ruiz model. Parameter values for other models can +be determined using :py:func:`pvlib.iam.fit`. Parameter values can also be approximately +converted between models using :py:func:`pvlib.iam.convert`. diff --git a/docs/sphinx/source/user_guide/modeling_topics/singlediode.rst b/docs/sphinx/source/user_guide/modeling_topics/singlediode.rst index 085b0b66cc..987ced10a1 100644 --- a/docs/sphinx/source/user_guide/modeling_topics/singlediode.rst +++ b/docs/sphinx/source/user_guide/modeling_topics/singlediode.rst @@ -1,7 +1,112 @@ .. _singlediode: -Single Diode Equation -===================== +Single diode models +=================== + +Single-diode models are a popular means of simulating the electrical output +of a PV module under any given irradiance and temperature conditions. +A single-diode model (SDM) pairs the single-diode equation (SDE) with a set of +auxiliary equations that predict the SDE parameters at any given irradiance +and temperature. All SDMs use the SDE, but their auxiliary equations differ. +For more background on SDMs, see the `PVPMC website +`_. + +Three SDMs are currently available in pvlib: the CEC SDM, the PVsyst SDM, +and the De Soto SDM. pvlib splits these models into two steps. The first +is to compute the auxiliary equations using one of the following functions: + +* CEC SDM: :py:func:`~pvlib.pvsystem.calcparams_cec` +* PVsyst SDM: :py:func:`~pvlib.pvsystem.calcparams_pvsyst` +* De Soto SDM: :py:func:`~pvlib.pvsystem.calcparams_desoto` + +The second step is to use the output of these functions to compute points on +the SDE's I-V curve. Three points on the SDE I-V curve are typically of special +interest for PV modeling: the maximum power (MP), open circuit (OC), and +short circuit (SC) points. The most convenient function for computing these +points is :py:func:`pvlib.pvsystem.singlediode`. It provides several methods +for solving the SDE: + ++------------------+------------+-----------+-------------------------+ +| Method | Type | Speed | Guaranteed convergence? | ++==================+============+===========+=========================+ +| ``newton`` | iterative | fast | no | ++------------------+------------+-----------+-------------------------+ +| ``brentq`` | iterative | slow | yes | ++------------------+------------+-----------+-------------------------+ +| ``chandrupatla`` | iterative | fast | yes | ++------------------+------------+-----------+-------------------------+ +| ``lambertw`` | explicit | medium | yes | ++------------------+------------+-----------+-------------------------+ + + + +Computing full I-V curves +------------------------- + +Full I-V curves can be computed using +:py:func:`pvlib.pvsystem.i_from_v` and :py:func:`pvlib.pvsystem.v_from_i`, which +calculate either current or voltage from the other, with the methods listed +above. It is often useful to +first compute the open-circuit or short-circuit values using +:py:func:`pvlib.pvsystem.singlediode` and then compute a range +of voltages/currents from zero to those extreme points. This range can then +be used with the above functions to compute the I-V curve. + + +IV curves in reverse bias +------------------------- + +The standard SDE does not account for diode breakdown at reverse bias. The +following functions can optionally include an extra term for modeling it: +:py:func:`pvlib.pvsystem.max_power_point`, +:py:func:`pvlib.singlediode.bishop88_i_from_v`, +and :py:func:`pvlib.singlediode.bishop88_v_from_i`. + + +Recombination current for thin film cells +----------------------------------------- + +The PVsyst SDM optionally modifies the SDE to better represent recombination +current in CdTe and a-Si modules. The modified SDE requires two additional +parameters. pvlib functions can compute the key points or full I-V curves using +the modified SDE: +:py:func:`pvlib.pvsystem.max_power_point`, +:py:func:`pvlib.singlediode.bishop88_i_from_v`, +and :py:func:`pvlib.singlediode.bishop88_v_from_i`. + +Model parameter values +---------------------- + +Despite some models having parameters with similar names, parameter values are +specific to each model and thus must be produced with the intended model in mind. +For some models, sets of parameter values can be read from external sources, +for example: + +* CEC SDM parameter database can be read using :py:func:`~pvlib.pvsystem.retrieve_sam` +* PAN files, which can be read using :py:func:`~pvlib.iotools.read_panond` + +pvlib also provides a set of functions that can estimate SDM parameter values +from various datasources: + ++---------------------------------------------------------------+---------+--------------------+ +| Function | SDM | Inputs | ++===============================================================+=========+====================+ +| :py:func:`~pvlib.ivtools.sdm.fit_cec_sam` | CEC | datasheet | ++---------------------------------------------------------------+---------+--------------------+ +| :py:func:`~pvlib.ivtools.sdm.fit_desoto` | De Soto | datasheet | ++---------------------------------------------------------------+---------+--------------------+ +| :py:func:`~pvlib.ivtools.sdm.fit_desoto_batzelis` | De Soto | datasheet | ++---------------------------------------------------------------+---------+--------------------+ +| :py:func:`~pvlib.ivtools.sdm.fit_desoto_sandia` | De Soto | I-V curves | ++---------------------------------------------------------------+---------+--------------------+ +| :py:func:`~pvlib.ivtools.sdm.fit_pvsyst_sandia` | PVsyst | I-V curves | ++---------------------------------------------------------------+---------+--------------------+ +| :py:func:`~pvlib.ivtools.sdm.fit_pvsyst_iec61853_sandia_2025` | PVsyst | IEC 61853-1 matrix | ++---------------------------------------------------------------+---------+--------------------+ + + +Single-diode equation +--------------------- This section reviews the solutions to the single diode equation used in pvlib-python to generate an IV curve of a PV module. @@ -15,7 +120,7 @@ The :func:`pvlib.pvsystem.singlediode` function allows the user to choose the method using the ``method`` keyword. Lambert W-Function ------------------- +****************** When ``method='lambertw'``, the Lambert W-function is used as previously shown by Jain, Kapoor [1, 2] and Hansen [3]. The following algorithm can be found on `Wikipedia: Theory of Solar Cells @@ -50,7 +155,7 @@ Then the module current can be solved using the Lambert W-function, Bishop's Algorithm ------------------- +****************** The function :func:`pvlib.singlediode.bishop88` uses an explicit solution [4] that finds points on the IV curve by first solving for pairs :math:`(V_d, I)` where :math:`V_d` is the diode voltage :math:`V_d = V + I*Rs`. Then the voltage diff --git a/docs/sphinx/source/user_guide/modeling_topics/spectrum.rst b/docs/sphinx/source/user_guide/modeling_topics/spectrum.rst index 9b0b92cbda..1ff5fa1df4 100644 --- a/docs/sphinx/source/user_guide/modeling_topics/spectrum.rst +++ b/docs/sphinx/source/user_guide/modeling_topics/spectrum.rst @@ -61,11 +61,21 @@ Reference [2]_. | +-----------------------------+ | ✓ | ✓ | | | + [4]_ | | | :term:`clearsky_index` | | | | | | | | +-----------------------------------------------------+-----------------------------+---------+---------+------+------+------+------------+-----------+ +| :py:func:`Polo ` | :term:`precipitable_water`, | | | | | | | | +| +-----------------------------+ ✓ | | ✓ | ✓ | ✓ | + [5]_ | +| | :term:`airmass_absolute`, | | | | | | | | +| +-----------------------------+ | | | | | | | +| | :term:`aod500` | | | | | | | | +| +-----------------------------+ | | | | | | | +| | :term:`aoi`, | | | | | | | | +| +-----------------------------+ | | | | | | | +| | :term:`pressure` | | | | | | | | ++-----------------------------------------------------+-----------------------------+---------+---------+------+------+------+------------+-----------+ | :py:func:`PVSPEC ` | :term:`airmass_absolute`, | | | | | | | | -| +-----------------------------+ ✓ | ✓ | ✓ | ✓ | ✓ | | [5]_ | -| | :term:`clearsky_index` | | | | | | | | +| +-----------------------------+ ✓ | ✓ | ✓ | ✓ | ✓ | | [6]_ | +| | clearsky_index | | | | | | | | +-----------------------------------------------------+-----------------------------+---------+---------+------+------+------+------------+-----------+ -| :py:func:`SAPM ` | :term:`airmass_absolute` | | | | | | | [6]_ | +| :py:func:`SAPM ` | :term:`airmass_absolute` | | | | | | | [7]_ | +-----------------------------------------------------+-----------------------------+---------+---------+------+------+------+------------+-----------+ @@ -88,16 +98,19 @@ References PVSPEC Model of Photovoltaic Spectral Mismatch Factor," in Proc. 2020 IEEE 47th Photovoltaic Specialists Conference (PVSC), Calgary, AB, Canada, 2020, pp. 1–6. :doi:`10.1109/PVSC45281.2020.9300932` -.. [5] D. L. King, W. E. Boyson, and J. A. Kratochvil, Photovoltaic Array +.. [5] J. Polo and C. Sanz-Saiz, 'Development of spectral mismatch models + for BIPV applications in building façades', Renewable Energy, vol. 245, + p. 122820, Jun. 2025, :doi:`10.1016/j.renene.2025.122820` +.. [6] D. L. King, W. E. Boyson, and J. A. Kratochvil, Photovoltaic Array Performance Model, Sandia National Laboratories, Albuquerque, NM, USA, Tech. Rep. SAND2004-3535, Aug. 2004. :doi:`10.2172/919131` -.. [6] M. Lee and A. Panchula, "Spectral Correction for Photovoltaic Module +.. [7] M. Lee and A. Panchula, "Spectral Correction for Photovoltaic Module Performance Based on Air Mass and Precipitable Water," 2016 IEEE 43rd Photovoltaic Specialists Conference (PVSC), Portland, OR, USA, 2016, pp. 3696-3699. :doi:`10.1109/PVSC.2016.7749836` -.. [7] H. Thomas, S. Tony, and D. Ewan, “A Simple Model for Estimating the - Influence of Spectrum Variations on PV Performance,” pp. 3385–3389, Nov. +.. [8] T. Huld, T. Sample, and E. Dunlop, "A Simple Model for Estimating the + Influence of Spectrum Variations on PV Performance," pp. 3385–3389, Nov. 2009, :doi:`10.4229/24THEUPVSEC2009-4AV.3.27` -.. [8] IEC 60904-7:2019, Photovoltaic devices — Part 7: Computation of the +.. [9] IEC 60904-7:2019, Photovoltaic devices — Part 7: Computation of the spectral mismatch correction for measurements of photovoltaic devices, International Electrotechnical Commission, Geneva, Switzerland, 2019. \ No newline at end of file diff --git a/docs/sphinx/source/user_guide/modeling_topics/temperature.rst b/docs/sphinx/source/user_guide/modeling_topics/temperature.rst new file mode 100644 index 0000000000..5b292b9f2c --- /dev/null +++ b/docs/sphinx/source/user_guide/modeling_topics/temperature.rst @@ -0,0 +1,107 @@ +.. _temperature: + +Temperature models +================== + +pvlib provides a variety of models for predicting the operating temperature +of a PV module from irradiance and weather inputs. These models range from +simple empirical equations requiring just a few multiplications to more complex +thermal balance models with numerical integration. + +Types of models +--------------- + +Temperature models predict one of two quantities: + +- *module temperature*: the temperature as measured at the back surface + of a PV module. Easy to measure, but usually marginally less + than the cell temperature which determines efficiency. +- *cell temperature*: the temperature of the PV cell itself. The relevant + temperature for PV modeling, but almost never measured directly. + +Temperature models estimate these quantities using inputs like incident +irradiance, ambient temperature, and wind speed. Each model also takes +a set of parameter values that represent how a PV module responds to +those inputs. + +Parameter values generally depend on both the PV +module technologies, the mounting configuration of the module, +and on any weather parameters that are not included in the model. +Note that, despite models conventionally being associated with either +cell or module temperature, it is actually the parameter values that determine +which of the two temperatures are predicted, as they will produce the same +type of temperature from which they were originally derived. + +Another aspect of temperature models is whether they account for +the thermal inertia of a PV module. Temperature models are either: + +- *steady-state*: the module is assumed to have been at the specified operating + conditions for a sufficiently long time for its temperature to reach + equilibrium. +- *transient*: the module's thermal inertia is included in the model, + causing a lag in modeled temperature change following changes in the inputs. + +Other effects that temperature models may consider include the +photoconversion efficiency and radiative cooling. + +The temperature models currently available in pvlib are summarized in the +following table: + ++----------------------------------------------+--------+------------+---------------------------------------------------------------------------+ +| Model | Type | Transient? | Weather inputs | +| | | +----------------+---------------------+------------+-----------------------+ +| | | | POA irradiance | Ambient temperature | Wind speed | Downwelling IR [#f1]_ | ++==============================================+========+============+================+=====================+============+=======================+ +| :py:func:`~pvlib.temperature.faiman` | either | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.faiman_rad` | either | | ✓ | ✓ | ✓ | ✓ | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.fuentes` | either | ✓ | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.generic_linear` | either | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.noct_sam` | cell | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.pvsyst_cell` | cell | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.ross` | cell | | ✓ | ✓ | | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.sapm_cell` | cell | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.sapm_module` | module | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ + +.. [#f1] Downwelling infrared radiation. + +In addition to the core models above, pvlib provides several other functions +for temperature modeling: + +- :py:func:`~pvlib.temperature.prilliman`: an "add-on" model that reprocesses + the output of a steady-state model to apply transient effects. +- :py:func:`~pvlib.temperature.sapm_cell_from_module`: a model for + estimating cell temperature from module temperature. + + +Model parameters +---------------- + +Some temperature model functions provide default values for their parameters, +and several additional sets of temperature model parameter values are +available in :py:data:`pvlib.temperature.TEMPERATURE_MODEL_PARAMETERS`. +However, these generic values may not be suitable for all modules and mounting +configurations. It should be noted that using the default parameter values for each +model generally leads to different modules temperature predictions. This alone +does not mean one model is better than another; it's just evidence that the measurements +used to derive the default parameter values were taken on different PV systems in different +locations under different conditions. + +Parameter values for one model (e.g. ``u0``, ``u1`` for :py:func:`~pvlib.temperature.faiman`) +can be converted to another model (e.g. ``u_c``, ``u_v`` for :py:func:`~pvlib.temperature.pvsyst_cell`) +using :py:class:`~pvlib.temperature.GenericLinearModel`. + +Module-specific values can be obtained via testing, for example following +the IEC 61853-2 standard for the Faiman model; however, such values still do not capture +the dependency of temperature on system design and other variables. + +Currently, pvlib provides no functionality for fitting parameter values +using measured temperature. diff --git a/docs/sphinx/source/user_guide/modeling_topics/weather_data.rst b/docs/sphinx/source/user_guide/modeling_topics/weather_data.rst index 8199044a5c..8e99895ab5 100644 --- a/docs/sphinx/source/user_guide/modeling_topics/weather_data.rst +++ b/docs/sphinx/source/user_guide/modeling_topics/weather_data.rst @@ -112,7 +112,8 @@ online web APIs. For example, :py:func:`~pvlib.iotools.get_pvgis_hourly` downloads data from PVGIS's webservers and returns it as a python variable. Functions that retrieve data from the internet are named ``get_``, followed by the name of the data source: :py:func:`~pvlib.iotools.get_bsrn`, -:py:func:`~pvlib.iotools.get_psm3`, :py:func:`~pvlib.iotools.get_pvgis_tmy`, +:py:func:`~pvlib.iotools.get_nsrdb_psm4_conus`, +:py:func:`~pvlib.iotools.get_pvgis_tmy`, and so on. For satellite/reanalysis datasets, the location is specified by latitude and @@ -121,7 +122,7 @@ longitude in decimal degrees: .. code-block:: python latitude, longitude = 33.75, -84.39 # Atlanta, Georgia, United States - df, metadata = pvlib.iotools.get_psm3(latitude, longitude, map_variables=True, ...) + df, metadata = pvlib.iotools.get_pvgis_tmy(latitude, longitude, map_variables=True, ...) For ground station networks, the location identifier is the station ID: diff --git a/docs/sphinx/source/whatsnew.rst b/docs/sphinx/source/whatsnew.rst index 715cb16d49..f8ea2905ea 100644 --- a/docs/sphinx/source/whatsnew.rst +++ b/docs/sphinx/source/whatsnew.rst @@ -6,6 +6,10 @@ What's New These are new features and improvements of note in each release. +.. include:: whatsnew/v0.15.2.rst +.. include:: whatsnew/v0.15.1.rst +.. include:: whatsnew/v0.15.0.rst +.. include:: whatsnew/v0.14.0.rst .. include:: whatsnew/v0.13.1.rst .. include:: whatsnew/v0.13.0.rst .. include:: whatsnew/v0.12.0.rst diff --git a/docs/sphinx/source/whatsnew/v0.10.0.rst b/docs/sphinx/source/whatsnew/v0.10.0.rst index 6b6ae1abe9..eaefe12672 100644 --- a/docs/sphinx/source/whatsnew/v0.10.0.rst +++ b/docs/sphinx/source/whatsnew/v0.10.0.rst @@ -109,9 +109,9 @@ Enhancements :py:func:`pvlib.iotools.get_pvgis_horizon`. (:issue:`1290`, :pull:`1395`) * Update the URL used in the :py:func:`pvlib.iotools.get_cams` function. The new URL supports load-balancing and redirects to the fastest server. (:issue:`1688`, :pull:`1740`) -* :py:func:`pvlib.iotools.get_psm3` now has a ``url`` parameter to give the user +* :py:func:`!pvlib.iotools.get_psm3` now has a ``url`` parameter to give the user the option of controlling what NSRDB endpoint is used. (:pull:`1736`) -* :py:func:`pvlib.iotools.get_psm3` now uses the new NSRDB 3.2.2 endpoint for +* :py:func:`!pvlib.iotools.get_psm3` now uses the new NSRDB 3.2.2 endpoint for hourly and half-hourly single-year datasets. (:issue:`1591`, :pull:`1736`) * The default solar position algorithm (NREL SPA) is now 50-100% faster. (:pull:`1748`) * Added functions to retrieve daily precipitation, temperature, and snowfall data @@ -146,7 +146,7 @@ Testing Documentation ~~~~~~~~~~~~~ * Updated the description of the interval parameter in - :py:func:`pvlib.iotools.get_psm3`. (:issue:`1702`, :pull:`1712`) + :py:func:`!pvlib.iotools.get_psm3`. (:issue:`1702`, :pull:`1712`) * Fixed outdated nbviewer links. (:issue:`1721`, :pull:`1726`) diff --git a/docs/sphinx/source/whatsnew/v0.10.2.rst b/docs/sphinx/source/whatsnew/v0.10.2.rst index 3b82d98613..0bba4c69d1 100644 --- a/docs/sphinx/source/whatsnew/v0.10.2.rst +++ b/docs/sphinx/source/whatsnew/v0.10.2.rst @@ -28,7 +28,7 @@ Enhancements Bug fixes ~~~~~~~~~ -* :py:func:`~pvlib.iotools.get_psm3` no longer incorrectly returns clear-sky +* :py:func:`!pvlib.iotools.get_psm3` no longer incorrectly returns clear-sky DHI instead of clear-sky GHI when requesting ``ghi_clear``. (:pull:`1819`) * :py:func:`pvlib.singlediode.bishop88` with ``method='newton'`` no longer crashes when passed ``pandas.Series`` of length one. diff --git a/docs/sphinx/source/whatsnew/v0.10.3.rst b/docs/sphinx/source/whatsnew/v0.10.3.rst index 4d222fca06..6e4ac8e0ca 100644 --- a/docs/sphinx/source/whatsnew/v0.10.3.rst +++ b/docs/sphinx/source/whatsnew/v0.10.3.rst @@ -29,7 +29,7 @@ Bug fixes * Fixed CAMS error message handler in :py:func:`pvlib.iotools.get_cams`. (:issue:`1799`, :pull:`1905`) * Fix mapping of the dew point column to ``temp_dew`` when ``map_variables`` - is True in :py:func:`pvlib.iotools.get_psm3`. (:pull:`1920`) + is True in :py:func:`!pvlib.iotools.get_psm3`. (:pull:`1920`) * Fix :py:class:`pvlib.modelchain.ModelChain` to use attribute `clearsky_model`. (:pull:`1924`) diff --git a/docs/sphinx/source/whatsnew/v0.11.0.rst b/docs/sphinx/source/whatsnew/v0.11.0.rst index 219b57a059..d71fdcd9d9 100644 --- a/docs/sphinx/source/whatsnew/v0.11.0.rst +++ b/docs/sphinx/source/whatsnew/v0.11.0.rst @@ -14,10 +14,10 @@ Breaking changes * ``pvlib.iotools.read_srml_month_from_solardat`` was deprecated in v0.10.0 and has now been completely removed. The function is replaced by :py:func:`~pvlib.iotools.get_srml()`. (:pull:`1779`, :pull:`1989`) -* The ``leap_day`` parameter in :py:func:`~pvlib.iotools.get_psm3` +* The ``leap_day`` parameter in :py:func:`!pvlib.iotools.get_psm3` now defaults to True instead of False. (:issue:`1481`, :pull:`1991`) -* :py:func:`~pvlib.iotools.get_psm3`, :py:func:`~pvlib.iotools.read_psm3`, and - :py:func:`~pvlib.iotools.parse_psm3` all now have ``map_variables=True`` by +* :py:func:`!pvlib.iotools.get_psm3`, :py:func:`!pvlib.iotools.read_psm3`, and + :py:func:`!pvlib.iotools.parse_psm3` all now have ``map_variables=True`` by default. (:issue:`1425`, :pull:`2094`) * The deprecated ``ivcurve_pnts`` parameter of :py:func:`pvlib.pvsystem.singlediode` is removed. Use :py:func:`pvlib.pvsystem.v_from_i` and diff --git a/docs/sphinx/source/whatsnew/v0.12.0.rst b/docs/sphinx/source/whatsnew/v0.12.0.rst index 98abb20d14..34aa38ad0c 100644 --- a/docs/sphinx/source/whatsnew/v0.12.0.rst +++ b/docs/sphinx/source/whatsnew/v0.12.0.rst @@ -94,7 +94,7 @@ Contributors * Adam R. Jensen (:ghuser:`AdamRJensen`) * Ioannis Sifnaios (:ghuser:`IoannisSifnaios`) * Will Holmgren (:ghuser:`wholmgren`) -* Sophie Pelland (:ghuser:`solphie-pelland`) +* Sophie Pelland (:ghuser:`sophie-pelland`) * Will Hobbs (:ghuser:`williamhobbs`) * Karel De Brabandere (:ghuser:`kdebrab`) * Kenneth J. Sauer (:ghuser:`kjsauer`) diff --git a/docs/sphinx/source/whatsnew/v0.13.0.rst b/docs/sphinx/source/whatsnew/v0.13.0.rst index 754d2a539b..94b7aac356 100644 --- a/docs/sphinx/source/whatsnew/v0.13.0.rst +++ b/docs/sphinx/source/whatsnew/v0.13.0.rst @@ -28,7 +28,7 @@ Deprecations :pull:`2467`, :pull:`2466`) - :py:func:`~pvlib.iotools.parse_epw` - - :py:func:`~pvlib.iotools.parse_psm3` + - :py:func:`!pvlib.iotools.parse_psm3` - :py:func:`~pvlib.iotools.parse_cams` - :py:func:`~pvlib.iotools.parse_bsrn` diff --git a/docs/sphinx/source/whatsnew/v0.13.1.rst b/docs/sphinx/source/whatsnew/v0.13.1.rst index 66c642954d..00e117b86b 100644 --- a/docs/sphinx/source/whatsnew/v0.13.1.rst +++ b/docs/sphinx/source/whatsnew/v0.13.1.rst @@ -1,25 +1,26 @@ .. _whatsnew_0_13_1: -v0.13.1 (Anticipated September, 2025) +v0.13.1 (September 24, 2025) ------------------------------------- -Breaking Changes -~~~~~~~~~~~~~~~~ - - Deprecations ~~~~~~~~~~~~ -* Deprecate :py:func:`~pvlib.modelchain.get_orientation`. (:pull:`2495`) -* Rename parameter name ``aparent_azimuth`` to ``solar_azimuth`` in :py:func:`~pvlib.tracking.singleaxis`. +* Deprecate :py:func:`pvlib.modelchain.get_orientation`. (:pull:`2495`) +* Rename parameter name ``apparent_azimuth`` to ``solar_azimuth`` in :py:func:`~pvlib.tracking.singleaxis`. (:issue:`2479`, :pull:`2480`) Bug fixes ~~~~~~~~~ - +* Fix :py:func:`~pvlib.clearsky.detect_clearsky` to use inferred window length when + ``infer_limits=True``. (:issue:`2542`, :pull:`2550`) Enhancements ~~~~~~~~~~~~ +* Add option to use the latest parameters for the Huld PV array model + :py:func:`~pvlib.pvarray.huld`. (:issue:`2461`, :pull:`2486`) +* Add option to specify ``k`` coefficient in :py:func:`~pvlib.temperature.ross`. + (:issue:`2506`, :pull:`2521`) * Add iotools functions to retrieve irradiance and weather data from Meteonorm: :py:func:`~pvlib.iotools.get_meteonorm_forecast_basic`, :py:func:`~pvlib.iotools.get_meteonorm_forecast_precision`, :py:func:`~pvlib.iotools.get_meteonorm_observation_realtime`, :py:func:`~pvlib.iotools.get_meteonorm_observation_training`, @@ -29,39 +30,50 @@ Enhancements (:pull:`2500`) * :py:func:`pvlib.spectrum.spectral_factor_firstsolar` no longer emits warnings when airmass and precipitable water values fall out of range. (:pull:`2512`) +* Allow reading TMY data from a Path or file-like object in :py:func:`~pvlib.iotools.read_tmy3`. + (:pull:`2544`) Documentation ~~~~~~~~~~~~~ +* Update :py:mod:`pvlib.irradiance` module documentation to include links to + parameter definitions from the nomenclature page, ensure consistent + parameter description structure, add units to all parameters where required, + and other miscellaneous edits. (:issue:`2205`, :issue:`2248`, :pull:`2311`) * Substantiate definitions of solar/surface azimuth/zenith and aoi on the :ref:`nomenclature` page. (:issue:`2448`, :pull:`2503`) -* Add a new reference page for the spectrum (:ref:`_spectrum_user_guide`) to the +* Add a new reference page for the spectrum (:ref:`spectrum_user_guide`) to the Modeling Topics section of the user guide, documenting pvlib-python's spectrum functionality, which includes a comparison table of spectral mismatch estimation models. (:issue:`2329`, :pull:`2353`) - - -Testing -~~~~~~~ - - -Benchmarking -~~~~~~~~~~~~ - +* Fix FAQ URL in ``README.md``. (:pull:`2488`) Requirements ~~~~~~~~~~~~ - +* Drop support for Python 3.9 (reaches End of Life in Oct 2025). (:pull:`2547`) +* Advance minimum numpy to 1.21.2. (:pull:`2547`) +* Advance minimum scipy to 1.7.2. (:pull:`2547`) +* Advance minimum pandas to 1.3.3. (:pull:`2547`) Maintenance ~~~~~~~~~~~ -* Fix FAQ URL in ``README.md``. (:pull:`2488`) - +* Switch to using Trusted Publishing for deploying releases to PyPI. (:issue:`2511`, :pull:`2549`) Contributors ~~~~~~~~~~~~ * Elijah Passmore (:ghuser:`eljpsm`) +* Omar Bahamida (:ghuser:`OmarBahamida`) +* Cliff Hansen (:ghuser:`cwhanse`) * Ioannis Sifnaios (:ghuser:`IoannisSifnaios`) * Rajiv Daxini (:ghuser:`RDaxini`) -* Omar Bahamida (:ghuser:`OmarBahamida`) +* Rodrigo Amaro e Silva (:ghuser:`ramaroesilva`) * Kevin Anderson (:ghuser:`kandersolar`) * Mikaella Brewer (:ghuser:`brwerx`) +* Will Holmgren (:ghuser:`wholmgren`) +* Jeremy Lucas (:ghuser:`jerluc`) +* Adam R. Jensen (:ghuser:`AdamRJensen`) +* Will Hobbs (:ghuser:`williamhobbs`) +* Echedey Luis (:ghuser:`echedey-ls`) +* Anton Driesse (:ghuser:`adriesse`) +* Mark Mikofski (:ghuser:`mikofski`) +* Mathias Aschwanden (:ghuser:`maschwanden`) +* :ghuser:`leopardracer` diff --git a/docs/sphinx/source/whatsnew/v0.14.0.rst b/docs/sphinx/source/whatsnew/v0.14.0.rst new file mode 100644 index 0000000000..4d7c4b1a50 --- /dev/null +++ b/docs/sphinx/source/whatsnew/v0.14.0.rst @@ -0,0 +1,80 @@ +.. _whatsnew_0_14_0: + + +v0.14.0 (January 16, 2026) +-------------------------- + +Breaking Changes +~~~~~~~~~~~~~~~~ +* Following the removal of the NSRDB PSM3 API, the :func:`!pvlib.iotools.get_psm3`, + :func:`!pvlib.iotools.read_psm3`, and :func:`!pvlib.iotools.parse_psm3` + functions are removed. (:issue:`2581`, :pull:`2582`) +* Rename output column names to be prefixed with ``"poa_"`` when ``return_components=True`` + in :py:func:`~pvlib.irradiance.haydavies`, :py:func:`~pvlib.irradiance.perez`, + and :py:func:`~pvlib.irradiance.perez_driesse`. (:issue:`2529`, :pull:`2627`) + +Enhancements +~~~~~~~~~~~~ +* Add :py:func:`~pvlib.ivtools.sdm.fit_desoto_batzelis`, a function to estimate + parameters for the De Soto single-diode model from datasheet values. (:pull:`2563`) +* Add :py:func:`~pvlib.singlediode.batzelis`, a function to estimate + maximum power, open circuit, and short circuit points using parameters for + the single-diode equation. (:pull:`2563`) +* Add :py:func:`~pvlib.pvarray.batzelis`, a function to estimate maximum power + open circuit, and short circuit points from datasheet values. (:pull:`2563`) +* Add ``method='chandrupatla'`` (faster than ``brentq`` and slower than ``newton``, + but convergence is guaranteed) as an option for + :py:func:`pvlib.pvsystem.singlediode`, + :py:func:`~pvlib.pvsystem.i_from_v`, + :py:func:`~pvlib.pvsystem.v_from_i`, + :py:func:`~pvlib.pvsystem.max_power_point`, + :py:func:`~pvlib.singlediode.bishop88_mpp`, + :py:func:`~pvlib.singlediode.bishop88_v_from_i`, and + :py:func:`~pvlib.singlediode.bishop88_i_from_v`. (:issue:`2497`, :pull:`2498`) +* Add Marion 2008 non-linear irradiance adjustment factor to + :py:func:`pvlib.pvsystem.pvwatts_dc`. (:issue:`2566`, :pull:`2569`) +* Accelerate :py:func:`~pvlib.pvsystem.singlediode` when scipy>=1.15 is + installed. (:issue:`2497`, :pull:`2571`) +* Add :py:func:`~pvlib.iotools.get_era5`, a function for accessing + ERA5 reanalysis data. (:pull:`2573`) +* Add :py:func:`~pvlib.iotools.get_merra2`, a function for accessing + MERRA-2 reanalysis data. (:pull:`2572`) +* Add :py:func:`~pvlib.spectrum.spectral_factor_polo`, a function for estimating + spectral mismatch factors for vertical PV façades. (:issue:`2406`, :pull:`2491`) + +Documentation +~~~~~~~~~~~~~ +* Provide an overview of single-diode modeling functionality in :ref:`singlediode`. (:pull:`2565`) +* Provide an overview of temperature modeling functionality in :ref:`temperature`. (:pull:`2591`) +* Fix typo in parameter name ``atmos_refract`` in :py:func:`pvlib.solarposition.spa_python` + and :py:func:`pvlib.spa.solar_position`. (:issue:`2532`, :pull:`2592`) + +Testing +~~~~~~~ +* Add Python 3.14 to test suite. (:pull:`2590`) +* Update pytest configuration in ``pyproject.toml`` to work with pytest 9.0. + (:pull:`2596`) +* Correct argument and value order in :py:func:`~pvlib.tests.ivtools.test_sde`, + in tests of :py:func:`~pvlib.ivtools.sde._fit_sandia_cocontent`. (:issue:`2613`, :pull:`2615`) + +Requirements +~~~~~~~~~~~~ +* Advance minimum solarfactors to v1.6.1. (:pull:`2656`) + +Contributors +~~~~~~~~~~~~ +* Will Hobbs (:ghuser:`williamhobbs`) +* Cliff Hansen (:ghuser:`cwhanse`) +* Joseph Radford (:ghuser:`josephradford`) +* Jesús Polo (:ghuser:`jesuspolo`) +* Adam R. Jensen (:ghuser:`adamrjensen`) +* Echedey Luis (:ghuser:`echedey-ls`) +* Anton Driesse (:ghuser:`adriesse`) +* Rajiv Daxini (:ghuser:`RDaxini`) +* Kevin Anderson (:ghuser:`kandersolar`) +* Mark Mikofski (:ghuser:`mikofski`) +* Will Holmgren (:ghuser:`wholmgren`) +* Ioannis Sifnaios (:ghuser:`IoannisSifnaios`) +* Mark Campanelli (:ghuser:`markcampanelli`) +* Aman Srivastava (:ghuser:`aman-coder03`) +* Vincent Filter (:ghuser:`vfilter`) diff --git a/docs/sphinx/source/whatsnew/v0.15.0.rst b/docs/sphinx/source/whatsnew/v0.15.0.rst new file mode 100644 index 0000000000..856aa32bf1 --- /dev/null +++ b/docs/sphinx/source/whatsnew/v0.15.0.rst @@ -0,0 +1,28 @@ +.. _whatsnew_0_15_0: + + +v0.15.0 (February 3, 2026) +-------------------------- + +Breaking Changes +~~~~~~~~~~~~~~~~ +* Removed expired parameter name deprecations (:issue:`2662`, :pull:`2666`): + + - Parameter ``clearsky_ghi`` in :py:func:`pvlib.irradiance.clearsky_index` + - Parameters ``ghi_clearsky`` and ``dni_clearsky`` in :py:func:`pvlib.irradiance.dirindex` + - Parameter ``clearsky_dni`` in :py:func:`pvlib.irradiance.dni` + +Documentation +~~~~~~~~~~~~~ +- Update gallery examples to work with ``pandas==3`` (:pull:`2664`). + +Contributors +~~~~~~~~~~~~ +* Echedey Luis (:ghuser:`echedey-ls`) +* Aman Srivastava (:ghuser:`aman-coder03`) +* Cliff Hansen (:ghuser:`cwhanse`) +* Adam R. Jensen (:ghuser:`adamrjensen`) +* Will Holmgren (:ghuser:`wholmgren`) +* Rajiv Daxini (:ghuser:`RDaxini`) +* Anton Driesse (:ghuser:`adriesse`) +* Kevin Anderson (:ghuser:`kandersolar`) diff --git a/docs/sphinx/source/whatsnew/v0.15.1.rst b/docs/sphinx/source/whatsnew/v0.15.1.rst new file mode 100644 index 0000000000..c2f3a1de3c --- /dev/null +++ b/docs/sphinx/source/whatsnew/v0.15.1.rst @@ -0,0 +1,95 @@ +.. _whatsnew_0_15_1: + + +v0.15.1 (April 21, 2026) +------------------------ + + +Bug fixes +~~~~~~~~~ +* Fix a bug in :py:func:`pvlib.scaling.latlon_to_xy` where latitude + scaling was incorrectly applied to both latitude and longitude components. + (:issue:`2614`, :pull:`2712`) +* Fix a division-by-zero condition in + :py:func:`pvlib.transformer.simple_efficiency` when ``load_loss = 0``. + (:issue:`2645`, :pull:`2646`) +* Update URLs in :py:mod:`pvlib.iotools.psm4` to use nlr.gov instead of + nrel.gov. (:issue:`2701`, :pull:`2705`) +* Fix a bug in :py:func:`pvlib.iotools.get_era5` where conversion of + precipitation in meters to ``cm`` was underestimated by a factor of 10,000 when ``map_variables=True``. + (:issue:`2724`, :pull:`2725`) + + +Enhancements +~~~~~~~~~~~~ +* Use ``k`` and ``cap_adjustment`` from :py:attr:`pvlib.pvsystem.Array.module_parameters` + in :py:func:`pvlib.pvsystem.PVSystem.pvwatts_dc` + (:issue:`2714`, :pull:`2715`) +* Include ``ross`` and ``faiman_rad`` in the allowed models within + :py:meth:`pvlib.pvsystem.PVSystem.get_cell_temperature` (:issue:`2625`, :pull:`2631`) +* Accelerate the internals of :py:func:`~pvlib.solarposition.ephemeris`. (:pull:`2626`) +* Accelerate the internals of :py:func:`~pvlib.pvsystem.singlediode` when + ``method='lambertw'``. (:pull:`2732`, :pull:`2723`) +* Accept negative ``axis_tilt`` values in :py:func:`~pvlib.tracking.singleaxis`. (:pull:`2702`, :issue:`1976`) + + +Documentation +~~~~~~~~~~~~~ +* Add examples for :py:meth:`pvlib.modelchain.ModelChain.run_model_from_poa` and :py:meth:`~pvlib.modelchain.ModelChain.run_model_from_effective_irradiance` + (:issue:`1043`, :pull:`2621`) +* Add the following terms to the :ref:`nomenclature` page + (:issue:`2564`, :pull:`2663`): + + - :term:`clearness_index` + - :term:`clearsky_index` + - :term:`aod` + - :term:`iam` + - :term:`aod500` + +* Add an overview of IAM modeling functionality in new documentation page :ref:`iam`. (:pull:`2683`) +* Add AI checkbox to PR template, and auto-generate a comment on PRs + from first-time contributors regarding AI and contributing guidelines. + (:issue:`2617`, :pull:`2624`) +* Clarify :py:class:`pvlib.pvsystem.PVSystem` parameter ``module_type`` is optional. + (:issue:`2634`, :pull:`2713`) +* Fix a broken docstring reference to ``grounddiffuse`` in + :py:func:`pvlib.irradiance.poa_components` + (:issue:`2089`, :pull:`2708`) +* Add :ref:`core-guidelines` to the :ref:`introduction-to-contributing` page. + (:issue:`2716`, :pull:`2726`) + + +Testing +~~~~~~~ +* Add test to verify that :py:func:`~pvlib.transformer.simple_efficiency` + produces consistent results for vectorized and scalar inputs. + (:issue:`2649`, :pull:`2661`) + + +Maintenance +~~~~~~~~~~~ +* Update all NREL references to NLR (National Laboratory of the Rockies) + following the laboratory rename and domain migration from ``nrel.gov`` + to ``nlr.gov``. Rename ``NREL_API_KEY`` environment variable to + ``NLR_API_KEY``. (:issue:`2701`, :pull:`2705`) + + +Contributors +~~~~~~~~~~~~ +* Aman Srivastava (:ghuser:`aman-coder03`) +* Rajiv Daxini (:ghuser:`RDaxini`) +* Echedey Luis (:ghuser:`echedey-ls`) +* Cliff Hansen (:ghuser:`cwhanse`) +* Anton Driesse (:ghuser:`adriesse`) +* Kevin Anderson (:ghuser:`kandersolar`) +* Jason Curtis (:ghuser:`jason-curtis`) +* Rohan Saxena (:ghuser:`r0hansaxena`) +* Marco Fumagalli (:ghuser:`fuma900`) +* Jean-Baptiste Pasquier (:ghuser:`pasquierjb`) +* Rodrigo Amaro e Silva (:ghuser:`ramaroesilva`) +* James Fulton (:ghuser:`dfulu`) +* Will Holmgren (:ghuser:`wholmgren`) +* Mark Campanelli (:ghuser:`markcampanelli`) +* Chirag Sharma (:ghuser:`Chirag3841`) +* Will Hobbs (:ghuser:`williamhobbs`) +* Adam R. Jensen (:ghuser:`adamrjensen`) diff --git a/docs/sphinx/source/whatsnew/v0.15.2.rst b/docs/sphinx/source/whatsnew/v0.15.2.rst new file mode 100644 index 0000000000..1f4524d893 --- /dev/null +++ b/docs/sphinx/source/whatsnew/v0.15.2.rst @@ -0,0 +1,64 @@ +.. _whatsnew_0_15_2: + + +v0.15.2 (Anticipated June 2026) +------------------------------- + +Breaking Changes +~~~~~~~~~~~~~~~~ + + +Deprecations +~~~~~~~~~~~~ + + +Bug fixes +~~~~~~~~~ +* Corrects a bug in :py:func:`pvlib.temperature.fuentes`. If inputs were + data type integer, users can expect modeled cell temperature values to + increase slightly. + (:issue:`2608`, :pull:`2745`) +* Fixes a regression in :py:func:`pvlib.tracking.calc_surface_orientation` + introduced in v0.15.1 (:pull:`2702`) that caused a broadcasting + ``ValueError`` when ``tracker_theta`` was a 2-D (or higher rank) array. + (:issue:`2747`, :pull:`2749`) + +Enhancements +~~~~~~~~~~~~ +* Added mapping of the parameter ``"albedo"`` in + :py:func:`~pvlib.iotools.get_nasa_power` when ``map_variables=True`` + (:pull:`2753`) + + +Documentation +~~~~~~~~~~~~~ +* Clarifies that :py:func:`pvlib.soiling.hsu` has an implicit minimum + soiling ratio of approximately 0.6563 due to the mathematical form + of the model. (:issue:`2534`, :pull:`2743`) +* Clarifies how Linke turbidity values can be provided to + :py:func:`pvlib.clearsky.ineichen` via + :py:func:`pvlib.clearsky.lookup_linke_turbidity` (:issue:`2598`, :pull:`2746`) + + +Testing +~~~~~~~ + + +Benchmarking +~~~~~~~~~~~~ + + +Requirements +~~~~~~~~~~~~ + + +Maintenance +~~~~~~~~~~~ + + +Contributors +~~~~~~~~~~~~ +* :ghuser:`Omesh37` +* Cliff Hansen (:ghuser:`cwhanse`) +* Arthur Onno (:ghuser:`ArthurOnnoTerabase`) +* Adam R. Jensen (:ghuser:`AdamRJensen`) diff --git a/docs/sphinx/source/whatsnew/v0.7.0.rst b/docs/sphinx/source/whatsnew/v0.7.0.rst index 011852e513..41df67ac85 100644 --- a/docs/sphinx/source/whatsnew/v0.7.0.rst +++ b/docs/sphinx/source/whatsnew/v0.7.0.rst @@ -148,7 +148,7 @@ Enhancements diode model fitting function '6parsolve' from NREL's System Advisor Model. * Add :py:func:`~pvlib.ivtools.fit_sdm_desoto`, a method to fit the De Soto single diode model to the typical specifications given in manufacturers datasheets. -* Add `timeout` to :py:func:`pvlib.iotools.get_psm3`. +* Add `timeout` to :py:func:`!pvlib.iotools.get_psm3`. * Add :py:func:`~pvlib.scaling.wvm`, a port of the wavelet variability model for computing reductions in variability due to a spatially distributed plant. * Add :py:meth:`~pvlib.location.Location.from_epw`, a method to create a Location diff --git a/docs/sphinx/source/whatsnew/v0.7.1.rst b/docs/sphinx/source/whatsnew/v0.7.1.rst index af0368821d..630da2cff1 100644 --- a/docs/sphinx/source/whatsnew/v0.7.1.rst +++ b/docs/sphinx/source/whatsnew/v0.7.1.rst @@ -5,8 +5,8 @@ v0.7.1 (January 17, 2020) Enhancements ~~~~~~~~~~~~ -* Added :py:func:`~pvlib.iotools.read_psm3` to read local NSRDB PSM3 files and - :py:func:`~pvlib.iotools.parse_psm3` to parse local NSRDB PSM3 file-like +* Added :py:func:`!pvlib.iotools.read_psm3` to read local NSRDB PSM3 files and + :py:func:`!pvlib.iotools.parse_psm3` to parse local NSRDB PSM3 file-like objects. (:issue:`841`) * Added `leap_day` parameter to `iotools.get_psm3` instead of hardcoding it as False. diff --git a/docs/sphinx/source/whatsnew/v0.8.0.rst b/docs/sphinx/source/whatsnew/v0.8.0.rst index 86fb81574f..e322b9fb0c 100644 --- a/docs/sphinx/source/whatsnew/v0.8.0.rst +++ b/docs/sphinx/source/whatsnew/v0.8.0.rst @@ -19,7 +19,7 @@ Breaking changes * :py:func:`pvlib.iotools.read_tmy3` can now only read local data files because the NREL RREDC server hosting the TMY3 dataset has been retired. For - fetching TMY data from NREL servers, :py:func:`pvlib.iotools.get_psm3` is + fetching TMY data from NREL servers, :py:func:`!pvlib.iotools.get_psm3` is now recommended to retrieve newer PSM3 data over the older TMY3 data. (:issue:`996`) (:pull:`1004`) diff --git a/docs/sphinx/source/whatsnew/v0.8.1.rst b/docs/sphinx/source/whatsnew/v0.8.1.rst index 885973f786..f3ce66c5ff 100644 --- a/docs/sphinx/source/whatsnew/v0.8.1.rst +++ b/docs/sphinx/source/whatsnew/v0.8.1.rst @@ -24,7 +24,7 @@ Enhancements 10 minutes. (:pull:`1074`) * Added :py:func:`pvlib.inverter.sandia_multi` and :py:func:`pvlib.inverter.pvwatts_multi` for modeling inverters with multiple MPPTs (:issue:`457`, :pull:`1085`, :pull:`1106`) -* Added optional ``attributes`` parameter to :py:func:`pvlib.iotools.get_psm3` +* Added optional ``attributes`` parameter to :py:func:`!pvlib.iotools.get_psm3` and added the option of fetching 5- and 15-minute PSM3 data. (:pull:`1086`) * Added :py:func:`pvlib.irradiance.campbell_norman` for estimating DNI, DHI and GHI from extraterrestrial irradiance. This function replaces ``pvlib.irradiance.liujordan``; diff --git a/docs/sphinx/source/whatsnew/v0.9.0.rst b/docs/sphinx/source/whatsnew/v0.9.0.rst index d508f8871a..f574852126 100644 --- a/docs/sphinx/source/whatsnew/v0.9.0.rst +++ b/docs/sphinx/source/whatsnew/v0.9.0.rst @@ -50,7 +50,7 @@ Breaking changes :py:meth:`~pvlib.pvsystem.PVSystem.calcparams_cec` (:issue:`1118`, :pull:`1222`) * Switched the order of the outputs from the PSM3 iotools, notably - :py:func:`~pvlib.iotools.get_psm3` and :py:func:`~pvlib.iotools.read_psm3` + :py:func:`!pvlib.iotools.get_psm3` and :py:func:`!pvlib.iotools.read_psm3` (:issue:`1245`, :pull:`1268`) * Changed the naming of the inputs ``startdate``/``enddate`` to ``start``/``end`` in @@ -223,7 +223,7 @@ Documentation and to make the procedural and OO results match exactly. (:issue:`1116`, :pull:`1144`) * Add a gallery example showing how to appropriately use interval-averaged weather data for modeling. (:pull:`1152`) -* Update documentation links in :py:func:`pvlib.iotools.get_psm3` (:pull:`1169`) +* Update documentation links in :py:func:`!pvlib.iotools.get_psm3` (:pull:`1169`) * Use ``Mount`` classes in ``introtutorial`` and ``pvsystem`` docs pages (:pull:`1267`) * Clarified how statistics are calculated for :py:func:`pvlib.clearsky.detect_clearsky` (:issue:`1070`, :pull:`1243`) diff --git a/docs/sphinx/source/whatsnew/v0.9.1.rst b/docs/sphinx/source/whatsnew/v0.9.1.rst index 0a6a0d70f9..1734398c47 100644 --- a/docs/sphinx/source/whatsnew/v0.9.1.rst +++ b/docs/sphinx/source/whatsnew/v0.9.1.rst @@ -18,8 +18,8 @@ Deprecations Enhancements ~~~~~~~~~~~~ -* Added ``map_variables`` option to :py:func:`pvlib.iotools.get_psm3` and - :py:func:`pvlib.iotools.read_psm3` (:pull:`1374`) +* Added ``map_variables`` option to :py:func:`!pvlib.iotools.get_psm3` and + :py:func:`!pvlib.iotools.read_psm3` (:pull:`1374`) * Added ``pvlib.bifacial.infinite_sheds``, containing a model for irradiance on front and back surfaces of bifacial arrays. (:pull:`717`) * Added ``map_variables`` option to :func:`~pvlib.iotools.read_crn` (:pull:`1368`) diff --git a/docs/sphinx/source/whatsnew/v0.9.2.rst b/docs/sphinx/source/whatsnew/v0.9.2.rst index 2616734036..01612b2fad 100644 --- a/docs/sphinx/source/whatsnew/v0.9.2.rst +++ b/docs/sphinx/source/whatsnew/v0.9.2.rst @@ -35,7 +35,7 @@ Bug fixes timestamps as either 24:00 (which is the standard) as well as 00:00. Previously 00:00 timestamps would incorrectly be moved one day forward. (:pull:`1494`) -* :py:func:`pvlib.iotools.get_psm3` now raises a deprecation warning if +* :py:func:`!pvlib.iotools.get_psm3` now raises a deprecation warning if the ``leap_day`` parameter is not specified in a single-year request. Starting in pvlib 0.11.0 ``leap_day`` will default to True instead of False. (:issue:`1481`, :pull:`1511`) diff --git a/docs/sphinx/source/whatsnew/v0.9.5.rst b/docs/sphinx/source/whatsnew/v0.9.5.rst index 23766d566e..8d9c1b0aec 100644 --- a/docs/sphinx/source/whatsnew/v0.9.5.rst +++ b/docs/sphinx/source/whatsnew/v0.9.5.rst @@ -44,7 +44,7 @@ Bug fixes incorrect loss results for systems that are near the ground. (:issue:`1636`, :pull:`1653`) * Fixed incorrect mapping of requested parameters names when using - :py:func:`pvlib.iotools.get_psm3`. + :py:func:`!pvlib.iotools.get_psm3`. Also fixed the random reordering of the dataframe columns. (:issue:`1629`, :issue:`1647`, :pull:`1648`) * When using ``utc_time_range`` with :py:func:`pvlib.iotools.read_ecmwf_macc`, diff --git a/docs/tutorials/tmy_to_power.ipynb b/docs/tutorials/tmy_to_power.ipynb index ae8fa43eb0..47fa9b4648 100644 --- a/docs/tutorials/tmy_to_power.ipynb +++ b/docs/tutorials/tmy_to_power.ipynb @@ -70,11 +70,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "pvlib comes with a couple of TMY files, and we'll use one of them for simplicity. You could also load a file from disk, or specify a url. See this NREL website for a list of TMY files:\n", - "\n", - "http://rredc.nrel.gov/solar/old_data/nsrdb/1991-2005/tmy3/by_state_and_city.html" - ] + "source": "pvlib comes with a couple of TMY files, and we'll use one of them for simplicity. You could also load a file from disk, or specify a url. See this NLR website for a list of TMY files:\n\nhttps://nsrdb.nlr.gov/data-sets/archives" }, { "cell_type": "code", @@ -1515,13 +1511,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "Next, we will assume that the SAPM model is representative of the real world performance so that we can use scipy's optimization routine to derive simulated PVUSA coefficients. You will need to install scipy to run these functions.\n", - "\n", - "Here's one PVUSA reference:\n", - "\n", - "http://www.nrel.gov/docs/fy09osti/45376.pdf\n" - ] + "source": "Next, we will assume that the SAPM model is representative of the real world performance so that we can use scipy's optimization routine to derive simulated PVUSA coefficients. You will need to install scipy to run these functions.\n\nHere's one PVUSA reference:\n\nhttps://www.osti.gov/biblio/951223\n" }, { "cell_type": "code", diff --git a/pvlib/atmosphere.py b/pvlib/atmosphere.py index dcd34da6bc..f17a8e5866 100644 --- a/pvlib/atmosphere.py +++ b/pvlib/atmosphere.py @@ -448,7 +448,7 @@ def bird_hulstrom80_aod_bb(aod380, aod500): References ---------- .. [1] Bird and Hulstrom, "Direct Insolation Models" (1980) - `SERI/TR-335-344 `_ + `SERI/TR-335-344 `_ .. [2] R. E. Bird and R. L. Hulstrom, "Review, Evaluation, and Improvement of Direct Irradiance Models", Journal of Solar Energy Engineering diff --git a/pvlib/bifacial/infinite_sheds.py b/pvlib/bifacial/infinite_sheds.py index 2418fd71bb..f99c536315 100644 --- a/pvlib/bifacial/infinite_sheds.py +++ b/pvlib/bifacial/infinite_sheds.py @@ -166,7 +166,7 @@ def _shaded_fraction(solar_zenith, solar_azimuth, surface_tilt, :doi:`10.1109/PVSC40753.2019.8980572`. .. [2] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020. - https://www.nrel.gov/docs/fy20osti/76626.pdf + :doi:`10.2172/1660126` """ tan_phi = utils._solar_projection_tangent( solar_zenith, solar_azimuth, surface_azimuth) @@ -301,7 +301,8 @@ def get_irradiance_poa(surface_tilt, surface_azimuth, solar_zenith, sky_diffuse_comps_horizontal = haydavies(0, 180, dhi, dni, dni_extra, solar_zenith, solar_azimuth, return_components=True) - circumsolar_horizontal = sky_diffuse_comps_horizontal['circumsolar'] + circumsolar_horizontal = \ + sky_diffuse_comps_horizontal['poa_circumsolar'] # Call haydavies a second time where circumsolar_normal is facing # directly towards sun, and can be added to DNI @@ -309,7 +310,7 @@ def get_irradiance_poa(surface_tilt, surface_azimuth, solar_zenith, dni, dni_extra, solar_zenith, solar_azimuth, return_components=True) - circumsolar_normal = sky_diffuse_comps_normal['circumsolar'] + circumsolar_normal = sky_diffuse_comps_normal['poa_circumsolar'] dhi = dhi - circumsolar_horizontal dni = dni + circumsolar_normal diff --git a/pvlib/bifacial/loss_models.py b/pvlib/bifacial/loss_models.py index 3582e8a6c9..4c6f0febf3 100644 --- a/pvlib/bifacial/loss_models.py +++ b/pvlib/bifacial/loss_models.py @@ -135,7 +135,7 @@ def power_mismatch_deline( -------- `solarfactors `_ Calculate the irradiance at different points of the module. - `bifacial_radiance `_ + `bifacial_radiance `_ Calculate the irradiance at different points of the module. References diff --git a/pvlib/clearsky.py b/pvlib/clearsky.py index be75ecd47a..0f5ab63392 100644 --- a/pvlib/clearsky.py +++ b/pvlib/clearsky.py @@ -19,46 +19,46 @@ def ineichen(apparent_zenith, airmass_absolute, linke_turbidity, altitude=0, dni_extra=1364., perez_enhancement=False): ''' - Determine clear sky GHI, DNI, and DHI from Ineichen/Perez model. + Determine clear-sky GHI, DNI, and DHI using the Ineichen/Perez model. - Implements the Ineichen and Perez clear sky model for global - horizontal irradiance (GHI), direct normal irradiance (DNI), and - calculates the clear-sky diffuse horizontal (DHI) component as the - difference between GHI and DNI*cos(zenith) as presented in [1]_ [2]_. A - report on clear sky models found the Ineichen/Perez model to have + The Ineichen and Perez clear sky model [1]_ [2]_ estimates global + horizontal irradiance (GHI) and direct normal irradiance (DNI). Diffuse + horizontal irradiance (DHI) is then computed as DHI = GHI - DNI*cos(zenith) + Analysis of clear sky models found the Ineichen/Perez model to have excellent performance with a minimal input data set [3]_. - Default values for monthly Linke turbidity provided by SoDa [4]_, [5]_. + The Ineichen/Perez model requires Linke turbidity as input. Monthly + averages of gridded Linke turbidity (historical data from SoDa [4]_, [5]_) + are available using :py:func:`~pvlib.clearsky.lookup_linke_turbidity`. Parameters ----------- apparent_zenith : numeric - Refraction corrected solar zenith angle in degrees. + Refraction-corrected solar zenith angle. [°] airmass_absolute : numeric - Pressure corrected airmass. + Pressure-corrected airmass. [unitless] linke_turbidity : numeric - Linke Turbidity. + Linke turbidity. [unitless] altitude : numeric, default 0 - Altitude above sea level in meters. + Altitude above sea level. [m] - dni_extra : numeric, default 1364 - Extraterrestrial irradiance. The units of ``dni_extra`` - determine the units of the output. + dni_extra : numeric, default 1364 Wm⁻² + Extraterrestrial irradiance. perez_enhancement : bool, default False - Controls if the Perez enhancement factor should be applied. - Setting to True may produce spurious results for times when + If ``True``, the Perez enhancement factor is applied. + The Perez enhancement factor may produce spurious results when the Sun is near the horizon and the airmass is high. See https://github.com/pvlib/pvlib-python/issues/435 Returns ------- clearsky : DataFrame (if Series input) or OrderedDict of arrays - DataFrame/OrderedDict contains the columns/keys - ``'dhi', 'dni', 'ghi'``. + Contains the columns/keys ``'dhi', 'dni', 'ghi'``, with the same + unit as the input parameter ``dni_extra``. See also -------- @@ -84,6 +84,11 @@ def ineichen(apparent_zenith, airmass_absolute, linke_turbidity, .. [5] J. Remund, et. al., "Worldwide Linke Turbidity Information", Proc. ISES Solar World Congress, June 2003. Goteborg, Sweden. + + Examples + -------- + See :ref:`Clearsky modeling examples ` + ''' # noqa: E501 # ghi is calculated using either the equations in [1] by setting @@ -818,22 +823,31 @@ def detect_clearsky(measured, clearsky, times=None, infer_limits=False, sample_interval, samples_per_window = \ tools._get_sample_intervals(times, window_length) - if samples_per_window < 3: - raise ValueError(f"Samples per window of {samples_per_window}" - " found. Each window must contain at least 3 data" - " points." - f" Window length of {window_length} found; increase" - f" window length to {3*sample_interval} or longer.") - # if infer_limits, find threshold values using the sample interval if infer_limits: - window_length, mean_diff, max_diff, lower_line_length, \ - upper_line_length, var_diff, slope_dev = \ - _clearsky_get_threshold(sample_interval) + ( + window_length, + mean_diff, + max_diff, + lower_line_length, + upper_line_length, + var_diff, + slope_dev, + ) = _clearsky_get_threshold(sample_interval) # recalculate samples_per_window using returned window_length - _, samples_per_window = \ - tools._get_sample_intervals(times, window_length) + sample_interval, samples_per_window = tools._get_sample_intervals( + times, window_length + ) + + if samples_per_window < 3: + raise ValueError( + f"Samples per window of {samples_per_window}" + " found. Each window must contain at least 3 data" + " points." + f" Window length of {window_length} found. Increase" + f" window length to {3 * sample_interval} or longer." + ) # check that we have enough data to produce a nonempty hankel matrix if len(times) < samples_per_window: @@ -933,7 +947,7 @@ def bird(zenith, airmass_relative, aod380, aod500, precipitable_water, """ Bird Simple Clear Sky Broadband Solar Radiation Model - Based on NREL Excel implementation by Daryl R. Myers [1, 2]. + Based on NLR Excel implementation by Daryl R. Myers [1, 2]. Bird and Hulstrom define the zenith as the "angle between a line to the sun and the local zenith". There is no distinction in the paper @@ -944,7 +958,7 @@ def bird(zenith, airmass_relative, aod380, aod500, precipitable_water, was to compare existing clear sky models with "rigorous radiative transfer models" (RTM) it is possible that apparent zenith was obtained as output from the RTM. However, the implementation presented - in PVLIB is tested against the NREL Excel implementation by Daryl + in PVLIB is tested against the NLR Excel implementation by Daryl Myers which uses an analytical expression for solar zenith instead of apparent zenith. @@ -992,13 +1006,13 @@ def bird(zenith, airmass_relative, aod380, aod500, precipitable_water, .. [2] Daryl R. Myers, "Solar Radiation: Practical Modeling for Renewable Energy Applications", pp. 46-51 CRC Press (2013) - .. [3] `NREL Bird Clear Sky Model `_ + .. [3] `Bird Clear Sky Model `_ - .. [4] `SERI/TR-642-761 `_ + .. [4] SERI/TR-642-761 :doi:`10.2172/6510849` - .. [5] `Error Reports `_ + .. [5] `Error Reports `_ """ etr = dni_extra # extraradiation ze_rad = np.deg2rad(zenith) # zenith in radians diff --git a/pvlib/inverter.py b/pvlib/inverter.py index 8207e6bba9..622fb9b958 100644 --- a/pvlib/inverter.py +++ b/pvlib/inverter.py @@ -114,10 +114,10 @@ def sandia(v_dc, p_dc, inverter): References ---------- .. [1] D. King, S. Gonzalez, G. Galbraith, W. Boyson, "Performance Model - for Grid-Connected Photovoltaic Inverters", SAND2007-5036, Sandia - National Laboratories. - - .. [2] System Advisor Model web page. https://sam.nrel.gov. + for Grid-Connected Photovoltaic Inverters", Sandia National + Laboratories, Albuquerque, N.M., USA, SAND2007-5036, Sept. 2007. + :doi:`10.2172/920449` + .. [2] System Advisor Model web page. https://sam.nlr.gov. See also -------- @@ -176,11 +176,13 @@ def sandia_multi(v_dc, p_dc, inverter): References ---------- .. [1] D. King, S. Gonzalez, G. Galbraith, W. Boyson, "Performance Model - for Grid-Connected Photovoltaic Inverters", SAND2007-5036, Sandia - National Laboratories. + for Grid-Connected Photovoltaic Inverters", Sandia National + Laboratories, Albuquerque, N.M., USA, SAND2007-5036, Sept. 2007. + :doi:`10.2172/920449` .. [2] C. Hansen, J. Johnson, R. Darbali-Zamora, N. Gurule. "Modeling Efficiency Of Inverters With Multiple Inputs", 49th IEEE Photovoltaic Specialist Conference, Philadelphia, PA, USA. June 2022. + :doi:`10.1109/PVSC48317.2022.9938490` See also -------- @@ -272,6 +274,7 @@ def adr(v_dc, p_dc, inverter, vtol=0.10): .. [1] A. Driesse, "Beyond the Curves: Modeling the Electrical Efficiency of Photovoltaic Inverters", 33rd IEEE Photovoltaic Specialist Conference (PVSC), June 2008 + :doi:`10.1109/PVSC.2008.4922827` See also -------- @@ -332,7 +335,7 @@ def adr(v_dc, p_dc, inverter, vtol=0.10): def pvwatts(pdc, pdc0, eta_inv_nom=0.96, eta_inv_ref=0.9637): r""" - NREL's PVWatts inverter model. + NLR's PVWatts inverter model. The PVWatts inverter model [1]_ calculates inverter efficiency :math:`\eta` as a function of input DC power :math:`P_{dc}` @@ -385,8 +388,8 @@ def pvwatts(pdc, pdc0, eta_inv_nom=0.96, eta_inv_ref=0.9637): References ---------- - .. [1] A. P. Dobos, "PVWatts Version 5 Manual," - http://pvwatts.nrel.gov/downloads/pvwattsv5.pdf (2014). + .. [1] A. P. Dobos, "PVWatts Version 5 Manual", NREL, Golden, CO, USA, + Technical Report NREL/TP-6A20-62641, 2014, :doi:`10.2172/1158421`. """ pac0 = eta_inv_nom * pdc0 @@ -411,7 +414,7 @@ def pvwatts(pdc, pdc0, eta_inv_nom=0.96, eta_inv_ref=0.9637): def pvwatts_multi(pdc, pdc0, eta_inv_nom=0.96, eta_inv_ref=0.9637): r""" - Extend NREL's PVWatts inverter model for multiple MPP inputs. + Extend NLR's PVWatts inverter model for multiple MPP inputs. DC input power is summed over MPP inputs to obtain the DC power input to the PVWatts inverter model. See :py:func:`pvlib.inverter.pvwatts` @@ -487,8 +490,9 @@ def fit_sandia(ac_power, dc_power, dc_voltage, dc_voltage_level, p_ac_0, p_nt): References ---------- .. [1] D. King, S. Gonzalez, G. Galbraith, W. Boyson, "Performance Model - for Grid-Connected Photovoltaic Inverters", SAND2007-5036, Sandia - National Laboratories. + for Grid-Connected Photovoltaic Inverters", Sandia National + Laboratories, Albuquerque, N.M., USA, SAND2007-5036, Sept. 2007. + :doi:`10.2172/920449` .. [2] Sandia Inverter Model page, PV Performance Modeling Collaborative https://pvpmc.sandia.gov/modeling-steps/dc-to-ac-conversion/sandia-inverter-model/ .. [3] W. Bower, et al., "Performance Test Protocol for Evaluating diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 75663507f3..e680e38c2d 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -8,9 +8,6 @@ from pvlib.iotools.crn import read_crn # noqa: F401 from pvlib.iotools.solrad import read_solrad # noqa: F401 from pvlib.iotools.solrad import get_solrad # noqa: F401 -from pvlib.iotools.psm3 import get_psm3 # noqa: F401 -from pvlib.iotools.psm3 import read_psm3 # noqa: F401 -from pvlib.iotools.psm3 import parse_psm3 # noqa: F401 from pvlib.iotools.psm4 import get_nsrdb_psm4_aggregated # noqa: F401 from pvlib.iotools.psm4 import get_nsrdb_psm4_tmy # noqa: F401 from pvlib.iotools.psm4 import get_nsrdb_psm4_conus # noqa: F401 @@ -45,3 +42,5 @@ from pvlib.iotools.meteonorm import get_meteonorm_observation_training # noqa: F401, E501 from pvlib.iotools.meteonorm import get_meteonorm_tmy # noqa: F401 from pvlib.iotools.nasa_power import get_nasa_power # noqa: F401 +from pvlib.iotools.era5 import get_era5 # noqa: F401 +from pvlib.iotools.merra2 import get_merra2 # noqa: F401 diff --git a/pvlib/iotools/acis.py b/pvlib/iotools/acis.py index 3be16cfa4c..634af07933 100644 --- a/pvlib/iotools/acis.py +++ b/pvlib/iotools/acis.py @@ -413,8 +413,8 @@ def get_acis_station_data(station, start, end, trace_val=0.001, 'climdiv,valid_daterange,tzo,network') } df, metadata = _get_acis(start, end, params, map_variables, url, **kwargs) - df = df.replace("M", np.nan) - df = df.replace("T", trace_val) + df = df.mask(df == 'M', np.nan) + df = df.mask(df == 'T', trace_val) df = df.astype(float) return df, metadata diff --git a/pvlib/iotools/bsrn.py b/pvlib/iotools/bsrn.py index 7830853826..3cb77b239e 100644 --- a/pvlib/iotools/bsrn.py +++ b/pvlib/iotools/bsrn.py @@ -148,9 +148,9 @@ def get_bsrn(station, start, end, username, password, `_ .. [2] `BSRN Data Retrieval via FTP `_ - .. [4] `BSRN Data Release Guidelines + .. [3] `BSRN Data Release Guidelines `_ - .. [3] `Update of the Technical Plan for BSRN Data Management, 2013, + .. [4] `Update of the Technical Plan for BSRN Data Management, 2013, Global Climate Observing System (GCOS) GCOS-174. `_ """ # noqa: E501 @@ -322,7 +322,7 @@ def _parse_bsrn(fbuf, logical_records=('0100',)): LR_0100 = LR_0100.reindex(sorted(LR_0100.columns), axis='columns') LR_0100.columns = BSRN_LR0100_COLUMNS # Set datetime index - LR_0100.index = (start_date+pd.to_timedelta(LR_0100['day']-1, unit='d') + LR_0100.index = (start_date+pd.to_timedelta(LR_0100['day']-1, unit='D') + pd.to_timedelta(LR_0100['minute'], unit='minutes')) # Drop empty, minute, and day columns LR_0100 = LR_0100.drop(columns=['empty', 'day', 'minute']) @@ -336,7 +336,7 @@ def _parse_bsrn(fbuf, logical_records=('0100',)): na_values=[-999.0, -99.9], colspecs=BSRN_LR0300_COL_SPECS, names=BSRN_LR0300_COLUMNS) - LR_0300.index = (start_date+pd.to_timedelta(LR_0300['day']-1, unit='d') + LR_0300.index = (start_date+pd.to_timedelta(LR_0300['day']-1, unit='D') + pd.to_timedelta(LR_0300['minute'], unit='minutes')) LR_0300 = LR_0300.drop(columns=['day', 'minute']).astype(float) dfs.append(LR_0300) @@ -353,13 +353,13 @@ def _parse_bsrn(fbuf, logical_records=('0100',)): # Sort columns to match original order and assign column names LR_0500 = LR_0500.reindex(sorted(LR_0500.columns), axis='columns') LR_0500.columns = BSRN_LR0500_COLUMNS - LR_0500.index = (start_date+pd.to_timedelta(LR_0500['day']-1, unit='d') + LR_0500.index = (start_date+pd.to_timedelta(LR_0500['day']-1, unit='D') + pd.to_timedelta(LR_0500['minute'], unit='minutes')) LR_0500 = LR_0500.drop(columns=['empty', 'day', 'minute']) dfs.append(LR_0500) if len(dfs): - data = pd.concat(dfs, axis='columns') + data = pd.concat(dfs, axis='columns', sort=False) else: data = _empty_dataframe_from_logical_records(logical_records) metadata = {} diff --git a/pvlib/iotools/era5.py b/pvlib/iotools/era5.py new file mode 100644 index 0000000000..6736a4801b --- /dev/null +++ b/pvlib/iotools/era5.py @@ -0,0 +1,207 @@ +import requests +import pandas as pd +from io import BytesIO, StringIO +import zipfile +import time + + +VARIABLE_MAP = { + # short names + 'd2m': 'temp_dew', + 't2m': 'temp_air', + 'sp': 'pressure', + 'ssrd': 'ghi', + 'tp': 'precipitation', + 'strd': 'longwave_down', + + # long names + '2m_dewpoint_temperature': 'temp_dew', + '2m_temperature': 'temp_air', + 'surface_pressure': 'pressure', + 'surface_solar_radiation_downwards': 'ghi', + 'total_precipitation': 'precipitation', + 'surface_thermal_radiation_downwards': 'longwave_down', +} + + +def _same(x): + return x + + +def _k_to_c(temp_k): + return temp_k - 273.15 + + +def _j_to_w(j): + return j / 3600 + + +def _m_to_cm(m): + return m * 100 + + +UNITS = { + 'u100': _same, + 'v100': _same, + 'u10': _same, + 'v10': _same, + 'd2m': _k_to_c, + 't2m': _k_to_c, + 'msl': _same, + 'sst': _k_to_c, + 'skt': _k_to_c, + 'sp': _same, + 'ssrd': _j_to_w, + 'strd': _j_to_w, + 'tp': _m_to_cm, +} + + +def get_era5(latitude, longitude, start, end, variables, api_key, + map_variables=True, timeout=60, + url='https://cds.climate.copernicus.eu/api/retrieve/v1/'): + """ + Retrieve ERA5 reanalysis data from the ECMWF's Copernicus Data Store. + + A CDS API key is needed to access this API. Register for one at [1]_. + + This API [2]_ provides a subset of the full ERA5 dataset. See [3]_ for + the available variables. Data are available on a 0.25° x 0.25° grid. + + Parameters + ---------- + latitude : float + In decimal degrees, north is positive (ISO 19115). + longitude: float + In decimal degrees, east is positive (ISO 19115). + start : datetime like or str + First day of the requested period. Assumed to be UTC if not localized. + end : datetime like or str + Last day of the requested period. Assumed to be UTC if not localized. + variables : list of str + List of variable names to retrieve, for example + ``['ghi', 'temp_air']``. Both pvlib and ERA5 names can be used. + See [1]_ for additional options. + api_key : str + ECMWF CDS API key. + map_variables : bool, default True + When true, renames columns of the DataFrame to pvlib variable names + where applicable. Also converts units of some variables. See variable + :const:`VARIABLE_MAP` and :const:`UNITS`. + timeout : int, default 60 + Number of seconds to wait for the requested data to become available + before timeout. + url : str, optional + API endpoint URL. + + Raises + ------ + Exception + If ``timeout`` is reached without the job finishing. + + Returns + ------- + data : pd.DataFrame + Time series data. The index corresponds to the start of the interval. + meta : dict + Metadata. + + References + ---------- + .. [1] https://cds.climate.copernicus.eu/ + .. [2] https://cds.climate.copernicus.eu/datasets/reanalysis-era5-single-levels-timeseries?tab=overview + .. [3] https://confluence.ecmwf.int/pages/viewpage.action?pageId=505390919 + """ # noqa: E501 + + def _to_utc_dt_notz(dt): + dt = pd.to_datetime(dt) + if dt.tzinfo is not None: + dt = dt.tz_convert("UTC") + return dt + + start = _to_utc_dt_notz(start).strftime("%Y-%m-%d") + end = _to_utc_dt_notz(end).strftime("%Y-%m-%d") + + headers = {'PRIVATE-TOKEN': api_key} + + # allow variables to be specified with pvlib names + reverse_map = {v: k for k, v in VARIABLE_MAP.items()} + variables = [reverse_map.get(k, k) for k in variables] + + # Step 1: submit data request (add it to the queue) + params = { + "inputs": { + "variable": variables, + "location": {"longitude": longitude, "latitude": latitude}, + "date": [f"{start}/{end}"], + "data_format": "csv" + } + } + slug = "processes/reanalysis-era5-single-levels-timeseries/execution" + response = requests.post(url + slug, json=params, headers=headers, + timeout=timeout) + submission_response = response.json() + if not response.ok: + raise Exception(submission_response) # likely need to accept license + + job_id = submission_response['jobID'] + + # Step 2: poll until the data request is ready + slug = "jobs/" + job_id + poll_interval = 1 + num_polls = 0 + while True: + response = requests.get(url + slug, headers=headers, timeout=timeout) + poll_response = response.json() + job_status = poll_response['status'] + + if job_status == 'successful': + break # ready to proceed to next step + elif job_status == 'failed': + msg = ( + 'Request failed. Please check the ECMWF website for details: ' + 'https://cds.climate.copernicus.eu/requests?tab=all' + ) + raise Exception(msg) + + num_polls += 1 + if num_polls * poll_interval > timeout: + raise requests.exceptions.Timeout( + 'Request timed out. Try increasing the timeout parameter or ' + 'reducing the request size.' + ) + + time.sleep(1) + + # Step 3: get the download link for our requested dataset + slug = "jobs/" + job_id + "/results" + response = requests.get(url + slug, headers=headers, timeout=timeout) + results_response = response.json() + download_url = results_response['asset']['value']['href'] + + # Step 4: finally, download our dataset. it's a zipfile of one CSV + response = requests.get(download_url, timeout=timeout) + zipbuffer = BytesIO(response.content) + archive = zipfile.ZipFile(zipbuffer) + filename = archive.filelist[0].filename + csvbuffer = StringIO(archive.read(filename).decode('utf-8')) + df = pd.read_csv(csvbuffer) + + # and parse into the usual formats + metadata = submission_response['metadata'] # include messages from ECMWF + metadata['jobID'] = job_id + if not df.empty: + metadata['latitude'] = df['latitude'].values[0] + metadata['longitude'] = df['longitude'].values[0] + + df.index = pd.to_datetime(df['valid_time']).dt.tz_localize('UTC') + df = df.drop(columns=['valid_time', 'latitude', 'longitude']) + + if map_variables: + # convert units and rename + for shortname in df.columns: + converter = UNITS.get(shortname, _same) + df[shortname] = converter(df[shortname]) + df = df.rename(columns=VARIABLE_MAP) + + return df, metadata diff --git a/pvlib/iotools/merra2.py b/pvlib/iotools/merra2.py new file mode 100644 index 0000000000..8a7770b9f4 --- /dev/null +++ b/pvlib/iotools/merra2.py @@ -0,0 +1,196 @@ +import pandas as pd +import requests +from io import StringIO + + +VARIABLE_MAP = { + 'SWGDN': 'ghi', + 'SWGDNCLR': 'ghi_clear', + 'ALBEDO': 'albedo', + 'LWGNT': 'longwave_net', + 'LWGEM': 'longwave_up', + 'LWGAB': 'longwave_down', + 'T2M': 'temp_air', + 'T2MDEW': 'temp_dew', + 'PS': 'pressure', + 'TOTEXTTAU': 'aod550', +} + + +def get_merra2(latitude, longitude, start, end, username, password, dataset, + variables, map_variables=True): + """ + Retrieve MERRA-2 time-series irradiance and meteorological reanalysis data + from NASA's GESDISC data archive. + + MERRA-2 [1]_ offers modeled data for many atmospheric quantities at hourly + resolution on a 0.5° x 0.625° global grid. + + Access must be granted to the GESDISC data archive before EarthData + credentials will work. See [2]_ for instructions. + + Parameters + ---------- + latitude : float + In decimal degrees, north is positive (ISO 19115). + longitude: float + In decimal degrees, east is positive (ISO 19115). + start : datetime like or str + First timestamp of the requested period. If a timezone is not + specified, UTC is assumed. + end : datetime like or str + Last timestamp of the requested period. If a timezone is not + specified, UTC is assumed. Must be in the same year as ``start``. + username : str + NASA EarthData username. + password : str + NASA EarthData password. + dataset : str + Dataset name (with version), e.g. "M2T1NXRAD.5.12.4". + variables : list of str + List of variable names to retrieve. See the documentation of the + specific dataset you are accessing for options. + map_variables : bool, default True + When true, renames columns of the DataFrame to pvlib variable names + where applicable. See variable :const:`VARIABLE_MAP`. + + Raises + ------ + ValueError + If ``start`` and ``end`` are in different years, when converted to UTC. + + Returns + ------- + data : pd.DataFrame + Time series data. The index corresponds to the middle of the interval. + meta : dict + Metadata. + + Notes + ----- + The following datasets provide quantities useful for PV modeling: + + +------------------------------------+-----------+---------------+ + | Dataset | Variable | pvlib name | + +====================================+===========+===============+ + | `M2T1NXRAD.5.12.4 `_ | SWGDN | ghi | + | +-----------+---------------+ + | | SWGDNCLR | ghi_clear | + | +-----------+---------------+ + | | ALBEDO | albedo | + | +-----------+---------------+ + | | LWGAB | longwave_down | + | +-----------+---------------+ + | | LWGNT | longwave_net | + | +-----------+---------------+ + | | LWGEM | longwave_up | + +------------------------------------+-----------+---------------+ + | `M2T1NXSLV.5.12.4 `_ | T2M | temp_air | + | +-----------+---------------+ + | | U10 | n/a | + | +-----------+---------------+ + | | V10 | n/a | + | +-----------+---------------+ + | | T2MDEW | temp_dew | + | +-----------+---------------+ + | | PS | pressure | + | +-----------+---------------+ + | | TO3 | n/a | + | +-----------+---------------+ + | | TQV | n/a | + +------------------------------------+-----------+---------------+ + | `M2T1NXAER.5.12.4 `_ | TOTEXTTAU | aod550 | + | +-----------+---------------+ + | | TOTSCATAU | n/a | + | +-----------+---------------+ + | | TOTANGSTR | n/a | + +------------------------------------+-----------+---------------+ + + .. _M2T1NXRAD: https://disc.gsfc.nasa.gov/datasets/M2T1NXRAD_5.12.4/summary + .. _M2T1NXSLV: https://disc.gsfc.nasa.gov/datasets/M2T1NXSLV_5.12.4/summary + .. _M2T1NXAER: https://disc.gsfc.nasa.gov/datasets/M2T1NXAER_5.12.4/summary + + A complete list of datasets and their documentation is available at [3]_. + + Note that MERRA2 does not currently provide DNI or DHI. + + References + ---------- + .. [1] https://gmao.gsfc.nasa.gov/gmao-products/merra-2/ + .. [2] https://disc.gsfc.nasa.gov/earthdata-login + .. [3] https://disc.gsfc.nasa.gov/datasets?project=MERRA-2 + """ + + # general API info here: + # https://docs.unidata.ucar.edu/tds/5.0/userguide/netcdf_subset_service_ref.html # noqa: E501 + + def _to_utc_dt_notz(dt): + dt = pd.to_datetime(dt) + if dt.tzinfo is not None: + # convert to utc, then drop tz so that isoformat() is clean + dt = dt.tz_convert("UTC").tz_localize(None) + return dt + + start = _to_utc_dt_notz(start) + end = _to_utc_dt_notz(end) + + if (year := start.year) != end.year: + raise ValueError("start and end must be in the same year (in UTC)") + + url = ( + "https://goldsmr4.gesdisc.eosdis.nasa.gov/thredds/ncss/grid/" + f"MERRA2_aggregation/{dataset}/{dataset}_Aggregation_{year}.ncml" + ) + + parameters = { + 'var': ",".join(variables), + 'latitude': latitude, + 'longitude': longitude, + 'time_start': start.isoformat() + "Z", + 'time_end': end.isoformat() + "Z", + 'accept': 'csv', + } + + auth = (username, password) + + with requests.Session() as session: + session.auth = auth + login = session.request('get', url, params=parameters) + response = session.get(login.url, auth=auth, params=parameters) + + response.raise_for_status() + + content = response.content.decode('utf-8') + buffer = StringIO(content) + df = pd.read_csv(buffer) + + df.index = pd.to_datetime(df['time']) + + meta = {} + meta['dataset'] = dataset + meta['station'] = df['station'].values[0] + meta['latitude'] = df['latitude[unit="degrees_north"]'].values[0] + meta['longitude'] = df['longitude[unit="degrees_east"]'].values[0] + + # drop the non-data columns + dropcols = ['time', 'station', 'latitude[unit="degrees_north"]', + 'longitude[unit="degrees_east"]'] + df = df.drop(columns=dropcols) + + # column names are like T2M[unit="K"] by default. extract the unit + # for the metadata, then rename col to just T2M + units = {} + rename = {} + for col in df.columns: + name, _ = col.split("[", maxsplit=1) + unit = col.split('"')[1] + units[name] = unit + rename[col] = name + + meta['units'] = units + df = df.rename(columns=rename) + + if map_variables: + df = df.rename(columns=VARIABLE_MAP) + + return df, meta diff --git a/pvlib/iotools/meteonorm.py b/pvlib/iotools/meteonorm.py index 5cc24f071c..88af396e42 100644 --- a/pvlib/iotools/meteonorm.py +++ b/pvlib/iotools/meteonorm.py @@ -532,19 +532,21 @@ def _get_meteonorm( # Check for None type in case of TMY request # Check for DateParseError in case of relative times, e.g., '+3hours' + # TODO: remove ValueError when our minimum pandas version is high enough + # to make it unnecessary (2.0?) if (start is not None) & (start != 'now'): try: start = pd.Timestamp(start) start = start.tz_localize("UTC") if start.tzinfo is None else start start = start.strftime("%Y-%m-%dT%H:%M:%SZ") - except DateParseError: + except (ValueError, DateParseError): pass if (end is not None) & (end != 'now'): try: end = pd.Timestamp(end) end = end.tz_localize("UTC") if end.tzinfo is None else end end = end.strftime("%Y-%m-%dT%H:%M:%SZ") - except DateParseError: + except (ValueError, DateParseError): pass params = { diff --git a/pvlib/iotools/midc.py b/pvlib/iotools/midc.py index c0dfd370eb..e2fe435125 100644 --- a/pvlib/iotools/midc.py +++ b/pvlib/iotools/midc.py @@ -1,4 +1,4 @@ -"""Functions to read NREL MIDC data. +"""Functions to read NLR MIDC data. """ import io @@ -15,7 +15,7 @@ # # In particular, these mappings coincide with the raw ddata files. # All site's field list can be found at: -# https://midcdmz.nrel.gov/apps/daily.pl?site=&live=1 +# https://midcdmz.nlr.gov/apps/daily.pl?site=&live=1 # Where id is the key found in this dictionary MIDC_VARIABLE_MAP = { 'BMS': { @@ -158,7 +158,7 @@ def _format_index_raw(data): def read_midc(filename, variable_map={}, raw_data=False, **kwargs): - """Read in National Renewable Energy Laboratory Measurement and + """Read in National Laboratory of the Rockies Measurement and Instrumentation Data Center weather data. The MIDC is described in [1]_. Parameters @@ -196,12 +196,12 @@ def read_midc(filename, variable_map={}, raw_data=False, **kwargs): :ref:`nomenclature`. Be sure to check the units for the variables you will use on the - `MIDC site `_. + `MIDC site `_. References ---------- - .. [1] NREL: Measurement and Instrumentation Data Center - `https://midcdmz.nrel.gov/ `_ + .. [1] NLR: Measurement and Instrumentation Data Center + `https://midcdmz.nlr.gov/ `_ """ data = pd.read_csv(filename, **kwargs) if raw_data: @@ -248,13 +248,13 @@ def read_midc_raw_data_from_nrel(site, start, end, variable_map={}, ----- Requests spanning an instrumentation change will yield an error. See the MIDC raw data api page - `here `_ + `here `_ for more details and considerations. """ args = {'site': site, 'begin': pd.to_datetime(start).strftime('%Y%m%d'), 'end': pd.to_datetime(end).strftime('%Y%m%d')} - url = 'https://midcdmz.nrel.gov/apps/data_api.pl' + url = 'https://midcdmz.nlr.gov/apps/data_api.pl' # NOTE: just use requests.get(url, params=args) to build querystring # number of header columns and data columns do not always match, # so first parse the header to determine the number of data columns diff --git a/pvlib/iotools/nasa_power.py b/pvlib/iotools/nasa_power.py index c7c74760f7..47dad5f2d3 100644 --- a/pvlib/iotools/nasa_power.py +++ b/pvlib/iotools/nasa_power.py @@ -18,6 +18,7 @@ 'T2M': 'temp_air', 'WS2M': 'wind_speed_2m', 'WS10M': 'wind_speed', + 'ALLSKY_SRF_ALB': 'albedo', } diff --git a/pvlib/iotools/psm3.py b/pvlib/iotools/psm3.py deleted file mode 100644 index 184a4a7028..0000000000 --- a/pvlib/iotools/psm3.py +++ /dev/null @@ -1,365 +0,0 @@ -""" -Get PSM3 TMY -see https://developer.nrel.gov/docs/solar/nsrdb/psm3_data_download/ -""" - -import io -import requests -import pandas as pd -from json import JSONDecodeError -from pvlib._deprecation import deprecated -from pvlib import tools - -NSRDB_API_BASE = "https://developer.nrel.gov" -PSM_URL = NSRDB_API_BASE + "/api/nsrdb/v2/solar/psm3-2-2-download.csv" -TMY_URL = NSRDB_API_BASE + "/api/nsrdb/v2/solar/psm3-tmy-download.csv" -PSM5MIN_URL = NSRDB_API_BASE + "/api/nsrdb/v2/solar/psm3-5min-download.csv" - -ATTRIBUTES = ( - 'air_temperature', 'dew_point', 'dhi', 'dni', 'ghi', 'surface_albedo', - 'surface_pressure', 'wind_direction', 'wind_speed') -PVLIB_PYTHON = 'pvlib python' - -# Dictionary mapping PSM3 response names to pvlib names -VARIABLE_MAP = { - 'GHI': 'ghi', - 'DHI': 'dhi', - 'DNI': 'dni', - 'Clearsky GHI': 'ghi_clear', - 'Clearsky DHI': 'dhi_clear', - 'Clearsky DNI': 'dni_clear', - 'Solar Zenith Angle': 'solar_zenith', - 'Temperature': 'temp_air', - 'Dew Point': 'temp_dew', - 'Relative Humidity': 'relative_humidity', - 'Pressure': 'pressure', - 'Wind Speed': 'wind_speed', - 'Wind Direction': 'wind_direction', - 'Surface Albedo': 'albedo', - 'Precipitable Water': 'precipitable_water', -} - -# Dictionary mapping pvlib names to PSM3 request names -# Note, PSM3 uses different names for the same variables in the -# response and the request -REQUEST_VARIABLE_MAP = { - 'ghi': 'ghi', - 'dhi': 'dhi', - 'dni': 'dni', - 'ghi_clear': 'clearsky_ghi', - 'dhi_clear': 'clearsky_dhi', - 'dni_clear': 'clearsky_dni', - 'solar_zenith': 'solar_zenith_angle', - 'temp_air': 'air_temperature', - 'temp_dew': 'dew_point', - 'relative_humidity': 'relative_humidity', - 'pressure': 'surface_pressure', - 'wind_speed': 'wind_speed', - 'wind_direction': 'wind_direction', - 'albedo': 'surface_albedo', - 'precipitable_water': 'total_precipitable_water', -} - - -def get_psm3(latitude, longitude, api_key, email, names='tmy', interval=60, - attributes=ATTRIBUTES, leap_day=True, full_name=PVLIB_PYTHON, - affiliation=PVLIB_PYTHON, map_variables=True, url=None, - timeout=30): - """ - Retrieve NSRDB PSM3 timeseries weather data from the PSM3 API. The NSRDB - is described in [1]_ and the PSM3 API is described in [2]_, [3]_, and [4]_. - - .. versionchanged:: 0.9.0 - The function now returns a tuple where the first element is a dataframe - and the second element is a dictionary containing metadata. Previous - versions of this function had the return values switched. - - .. versionchanged:: 0.10.0 - The default endpoint for hourly single-year datasets is now v3.2.2. - The previous datasets can still be accessed (for now) by setting - the ``url`` parameter to the original API endpoint - (``"https://developer.nrel.gov/api/nsrdb/v2/solar/psm3-download.csv"``). - - Parameters - ---------- - latitude : float or int - in decimal degrees, between -90 and 90, north is positive - longitude : float or int - in decimal degrees, between -180 and 180, east is positive - api_key : str - NREL Developer Network API key - email : str - NREL API uses this to automatically communicate messages back - to the user only if necessary - names : str, default 'tmy' - PSM3 API parameter specifing year (e.g. ``2020``) or TMY variant - to download (e.g. ``'tmy'`` or ``'tgy-2019'``). The allowed values - update periodically, so consult the NSRDB references below for the - current set of options. - interval : int, {60, 5, 15, 30} - interval size in minutes, must be 5, 15, 30 or 60. Must be 60 for - typical year requests (i.e., tmy/tgy/tdy). - attributes : list of str, optional - meteorological fields to fetch. If not specified, defaults to - ``pvlib.iotools.psm3.ATTRIBUTES``. See references [2]_, [3]_, and [4]_ - for lists of available fields. Alternatively, pvlib names may also be - used (e.g. 'ghi' rather than 'GHI'); see :const:`REQUEST_VARIABLE_MAP`. - To retrieve all available fields, set ``attributes=[]``. - leap_day : bool, default : True - include leap day in the results. Only used for single-year requests - (i.e., it is ignored for tmy/tgy/tdy requests). - full_name : str, default 'pvlib python' - optional - affiliation : str, default 'pvlib python' - optional - map_variables : bool, default True - When true, renames columns of the Dataframe to pvlib variable names - where applicable. See variable :const:`VARIABLE_MAP`. - url : str, optional - API endpoint URL. If not specified, the endpoint is determined from - the ``names`` and ``interval`` parameters. - timeout : int, default 30 - time in seconds to wait for server response before timeout - - Returns - ------- - data : pandas.DataFrame - timeseries data from NREL PSM3 - metadata : dict - metadata from NREL PSM3 about the record, see - :func:`pvlib.iotools.read_psm3` for fields - - Raises - ------ - requests.HTTPError - if the request response status is not ok, then the ``'errors'`` field - from the JSON response or any error message in the content will be - raised as an exception, for example if the `api_key` was rejected or if - the coordinates were not found in the NSRDB - - Notes - ----- - The required NREL developer key, `api_key`, is available for free by - registering at the `NREL Developer Network `_. - - .. warning:: The "DEMO_KEY" `api_key` is severely rate limited and may - result in rejected requests. - - .. warning:: PSM3 is limited to data found in the NSRDB, please consult the - references below for locations with available data. Additionally, - querying data with < 30-minute resolution uses a different API endpoint - with fewer available fields (see [4]_). - - See Also - -------- - pvlib.iotools.read_psm3 - - References - ---------- - - .. [1] `NREL National Solar Radiation Database (NSRDB) - `_ - .. [2] `Physical Solar Model (PSM) v3.2.2 - `_ - .. [3] `Physical Solar Model (PSM) v3 TMY - `_ - .. [4] `Physical Solar Model (PSM) v3 - Five Minute Temporal Resolution - `_ - """ - # The well know text (WKT) representation of geometry notation is strict. - # A POINT object is a string with longitude first, then the latitude, with - # four decimals each, and exactly one space between them. - longitude = ('%9.4f' % longitude).strip() - latitude = ('%8.4f' % latitude).strip() - # TODO: make format_WKT(object_type, *args) in tools.py - - # convert to string to accomodate integer years being passed in - names = str(names) - - # convert pvlib names in attributes to psm3 convention - attributes = [REQUEST_VARIABLE_MAP.get(a, a) for a in attributes] - - # required query-string parameters for request to PSM3 API - params = { - 'api_key': api_key, - 'full_name': full_name, - 'email': email, - 'affiliation': affiliation, - 'reason': PVLIB_PYTHON, - 'mailing_list': 'false', - 'wkt': 'POINT(%s %s)' % (longitude, latitude), - 'names': names, - 'attributes': ','.join(attributes), - 'leap_day': str(leap_day).lower(), - 'utc': 'false', - 'interval': interval - } - # request CSV download from NREL PSM3 - if url is None: - # determine the endpoint that suits the user inputs - if any(prefix in names for prefix in ('tmy', 'tgy', 'tdy')): - url = TMY_URL - elif interval in (5, 15): - url = PSM5MIN_URL - else: - url = PSM_URL - - response = requests.get(url, params=params, timeout=timeout) - if not response.ok: - # if the API key is rejected, then the response status will be 403 - # Forbidden, and then the error is in the content and there is no JSON - try: - errors = response.json()['errors'] - except JSONDecodeError: - errors = response.content.decode('utf-8') - raise requests.HTTPError(errors, response=response) - # the CSV is in the response content as a UTF-8 bytestring - # to use pandas we need to create a file buffer from the response - fbuf = io.StringIO(response.content.decode('utf-8')) - return read_psm3(fbuf, map_variables) - - -def read_psm3(filename, map_variables=True): - """ - Read an NSRDB PSM3 weather file (formatted as SAM CSV). The NSRDB - is described in [1]_ and the SAM CSV format is described in [2]_. - - .. versionchanged:: 0.9.0 - The function now returns a tuple where the first element is a dataframe - and the second element is a dictionary containing metadata. Previous - versions of this function had the return values switched. - - Parameters - ---------- - filename: str, path-like, or buffer - Filename or in-memory buffer of a file containing data to read. - map_variables: bool, default True - When true, renames columns of the Dataframe to pvlib variable names - where applicable. See variable :const:`VARIABLE_MAP`. - - Returns - ------- - data : pandas.DataFrame - timeseries data from NREL PSM3 - metadata : dict - metadata from NREL PSM3 about the record, see notes for fields - - Notes - ----- - The return is a tuple with two items. The first item is a dataframe with - the PSM3 timeseries data. - - The second item is a dictionary with metadata from NREL PSM3 about the - record containing the following fields: - - * Source - * Location ID - * City - * State - * Country - * Latitude - * Longitude - * Time Zone - * Elevation - * Local Time Zone - * Clearsky DHI Units - * Clearsky DNI Units - * Clearsky GHI Units - * Dew Point Units - * DHI Units - * DNI Units - * GHI Units - * Solar Zenith Angle Units - * Temperature Units - * Pressure Units - * Relative Humidity Units - * Precipitable Water Units - * Wind Direction Units - * Wind Speed Units - * Cloud Type -15 - * Cloud Type 0 - * Cloud Type 1 - * Cloud Type 2 - * Cloud Type 3 - * Cloud Type 4 - * Cloud Type 5 - * Cloud Type 6 - * Cloud Type 7 - * Cloud Type 8 - * Cloud Type 9 - * Cloud Type 10 - * Cloud Type 11 - * Cloud Type 12 - * Fill Flag 0 - * Fill Flag 1 - * Fill Flag 2 - * Fill Flag 3 - * Fill Flag 4 - * Fill Flag 5 - * Surface Albedo Units - * Version - - Examples - -------- - >>> # Read a local PSM3 file: - >>> df, metadata = iotools.read_psm3("data.csv") # doctest: +SKIP - - >>> # Read a file object or an in-memory buffer: - >>> with open(filename, 'r') as f: # doctest: +SKIP - ... df, metadata = iotools.read_psm3(f) # doctest: +SKIP - - See Also - -------- - pvlib.iotools.get_psm3 - - References - ---------- - .. [1] `NREL National Solar Radiation Database (NSRDB) - `_ - .. [2] `Standard Time Series Data File Format - `_ - """ - with tools._file_context_manager(filename) as fbuf: - # The first 2 lines of the response are headers with metadata - metadata_fields = fbuf.readline().split(',') - metadata_values = fbuf.readline().split(',') - # get the column names so we can set the dtypes - columns = fbuf.readline().split(',') - columns[-1] = columns[-1].strip() # strip trailing newline - # Since the header has so many columns, excel saves blank cols in the - # data below the header lines. - columns = [col for col in columns if col != ''] - dtypes = dict.fromkeys(columns, float) # all floats except datevec - dtypes.update({'Year': int, 'Month': int, 'Day': int, 'Hour': int, - 'Minute': int, 'Cloud Type': int, 'Fill Flag': int}) - data = pd.read_csv( - fbuf, header=None, names=columns, usecols=columns, dtype=dtypes, - delimiter=',', lineterminator='\n') # skip carriage returns \r - - metadata_fields[-1] = metadata_fields[-1].strip() # trailing newline - metadata_values[-1] = metadata_values[-1].strip() # trailing newline - metadata = dict(zip(metadata_fields, metadata_values)) - # the response is all strings, so set some metadata types to numbers - metadata['Local Time Zone'] = int(metadata['Local Time Zone']) - metadata['Time Zone'] = int(metadata['Time Zone']) - metadata['Latitude'] = float(metadata['Latitude']) - metadata['Longitude'] = float(metadata['Longitude']) - metadata['Elevation'] = int(metadata['Elevation']) - - # the response 1st 5 columns are a date vector, convert to datetime - dtidx = pd.to_datetime(data[['Year', 'Month', 'Day', 'Hour', 'Minute']]) - # in USA all timezones are integers - tz = 'Etc/GMT%+d' % -metadata['Time Zone'] - data.index = pd.DatetimeIndex(dtidx).tz_localize(tz) - - if map_variables: - data = data.rename(columns=VARIABLE_MAP) - metadata['latitude'] = metadata.pop('Latitude') - metadata['longitude'] = metadata.pop('Longitude') - metadata['altitude'] = metadata.pop('Elevation') - - return data, metadata - - -parse_psm3 = deprecated(since="0.13.0", name="parse_psm3", - alternative="read_psm3")(read_psm3) diff --git a/pvlib/iotools/psm4.py b/pvlib/iotools/psm4.py index 44325364c7..9eb760f382 100644 --- a/pvlib/iotools/psm4.py +++ b/pvlib/iotools/psm4.py @@ -1,9 +1,9 @@ """ Functions for reading and retrieving data from NSRDB PSM4. See: -https://developer.nrel.gov/docs/solar/nsrdb/nsrdb-GOES-aggregated-v4-0-0-download/ -https://developer.nrel.gov/docs/solar/nsrdb/nsrdb-GOES-tmy-v4-0-0-download/ -https://developer.nrel.gov/docs/solar/nsrdb/nsrdb-GOES-conus-v4-0-0-download/ -https://developer.nrel.gov/docs/solar/nsrdb/nsrdb-GOES-full-disc-v4-0-0-download/ +https://developer.nlr.gov/docs/solar/nsrdb/nsrdb-GOES-aggregated-v4-0-0-download/ +https://developer.nlr.gov/docs/solar/nsrdb/nsrdb-GOES-tmy-v4-0-0-download/ +https://developer.nlr.gov/docs/solar/nsrdb/nsrdb-GOES-conus-v4-0-0-download/ +https://developer.nlr.gov/docs/solar/nsrdb/nsrdb-GOES-full-disc-v4-0-0-download/ """ import io @@ -13,7 +13,7 @@ from json import JSONDecodeError from pvlib import tools -NSRDB_API_BASE = "https://developer.nrel.gov/api/nsrdb/v2/solar/" +NSRDB_API_BASE = "https://developer.nlr.gov/api/nsrdb/v2/solar/" PSM4_AGG_ENDPOINT = "nsrdb-GOES-aggregated-v4-0-0-download.csv" PSM4_TMY_ENDPOINT = "nsrdb-GOES-tmy-v4-0-0-download.csv" PSM4_CON_ENDPOINT = "nsrdb-GOES-conus-v4-0-0-download.csv" @@ -92,9 +92,9 @@ def get_nsrdb_psm4_aggregated(latitude, longitude, api_key, email, longitude : float or int in decimal degrees, between -180 and 180, east is positive api_key : str - NREL Developer Network API key + NLR Developer Network API key email : str - NREL API uses this to automatically communicate messages back + NLR API uses this to automatically communicate messages back to the user only if necessary year : int or str PSM4 API parameter specifing year (e.g. ``2023``) to download. The @@ -130,9 +130,9 @@ def get_nsrdb_psm4_aggregated(latitude, longitude, api_key, email, Returns ------- data : pandas.DataFrame - timeseries data from NREL PSM4 + timeseries data from NLR PSM4 metadata : dict - metadata from NREL PSM4 about the record, see + metadata from NLR PSM4 about the record, see :func:`pvlib.iotools.read_nsrdb_psm4` for fields Raises @@ -145,8 +145,8 @@ def get_nsrdb_psm4_aggregated(latitude, longitude, api_key, email, Notes ----- - The required NREL developer key, `api_key`, is available for free by - registering at the `NREL Developer Network `_. + The required NLR developer key, `api_key`, is available for free by + registering at the `NLR Developer Network `_. .. warning:: The "DEMO_KEY" `api_key` is severely rate limited and may result in rejected requests. @@ -161,10 +161,10 @@ def get_nsrdb_psm4_aggregated(latitude, longitude, api_key, email, References ---------- - .. [1] `NREL National Solar Radiation Database (NSRDB) - `_ + .. [1] `NLR National Solar Radiation Database (NSRDB) + `_ .. [2] `NSRDB GOES Aggregated V4.0.0 - `_ + `_ """ # The well know text (WKT) representation of geometry notation is strict. # A POINT object is a string with longitude first, then the latitude, with @@ -191,7 +191,7 @@ def get_nsrdb_psm4_aggregated(latitude, longitude, api_key, email, 'utc': str(utc).lower(), 'interval': time_step } - # request CSV download from NREL PSM4 + # request CSV download from NLR PSM4 if url is None: url = PSM4_AGG_URL @@ -228,9 +228,9 @@ def get_nsrdb_psm4_tmy(latitude, longitude, api_key, email, year='tmy', longitude : float or int in decimal degrees, between -180 and 180, east is positive api_key : str - NREL Developer Network API key + NLR Developer Network API key email : str - NREL API uses this to automatically communicate messages back + NLR API uses this to automatically communicate messages back to the user only if necessary year : str, default 'tmy' PSM4 API parameter specifing TMY variant to download (e.g. ``'tmy'`` @@ -267,9 +267,9 @@ def get_nsrdb_psm4_tmy(latitude, longitude, api_key, email, year='tmy', Returns ------- data : pandas.DataFrame - timeseries data from NREL PSM4 + timeseries data from NLR PSM4 metadata : dict - metadata from NREL PSM4 about the record, see + metadata from NLR PSM4 about the record, see :func:`pvlib.iotools.read_nsrdb_psm4` for fields Raises @@ -282,8 +282,8 @@ def get_nsrdb_psm4_tmy(latitude, longitude, api_key, email, year='tmy', Notes ----- - The required NREL developer key, `api_key`, is available for free by - registering at the `NREL Developer Network `_. + The required NLR developer key, `api_key`, is available for free by + registering at the `NLR Developer Network `_. .. warning:: The "DEMO_KEY" `api_key` is severely rate limited and may result in rejected requests. @@ -299,10 +299,10 @@ def get_nsrdb_psm4_tmy(latitude, longitude, api_key, email, year='tmy', References ---------- - .. [1] `NREL National Solar Radiation Database (NSRDB) - `_ + .. [1] `NLR National Solar Radiation Database (NSRDB) + `_ .. [2] `NSRDB GOES Tmy V4.0.0 - `_ + `_ """ # The well know text (WKT) representation of geometry notation is strict. # A POINT object is a string with longitude first, then the latitude, with @@ -329,7 +329,7 @@ def get_nsrdb_psm4_tmy(latitude, longitude, api_key, email, year='tmy', 'utc': str(utc).lower(), 'interval': time_step } - # request CSV download from NREL PSM4 + # request CSV download from NLR PSM4 if url is None: url = PSM4_TMY_URL @@ -366,9 +366,9 @@ def get_nsrdb_psm4_conus(latitude, longitude, api_key, email, year, longitude : float or int in decimal degrees, between -180 and 180, east is positive api_key : str - NREL Developer Network API key + NLR Developer Network API key email : str - NREL API uses this to automatically communicate messages back + NLR API uses this to automatically communicate messages back to the user only if necessary year : int or str PSM4 API parameter specifing year (e.g. ``2023``) to download. The @@ -403,9 +403,9 @@ def get_nsrdb_psm4_conus(latitude, longitude, api_key, email, year, Returns ------- data : pandas.DataFrame - timeseries data from NREL PSM4 + timeseries data from NLR PSM4 metadata : dict - metadata from NREL PSM4 about the record, see + metadata from NLR PSM4 about the record, see :func:`pvlib.iotools.read_nsrdb_psm4` for fields Raises @@ -418,8 +418,8 @@ def get_nsrdb_psm4_conus(latitude, longitude, api_key, email, year, Notes ----- - The required NREL developer key, `api_key`, is available for free by - registering at the `NREL Developer Network `_. + The required NLR developer key, `api_key`, is available for free by + registering at the `NLR Developer Network `_. .. warning:: The "DEMO_KEY" `api_key` is severely rate limited and may result in rejected requests. @@ -435,10 +435,10 @@ def get_nsrdb_psm4_conus(latitude, longitude, api_key, email, year, References ---------- - .. [1] `NREL National Solar Radiation Database (NSRDB) - `_ + .. [1] `NLR National Solar Radiation Database (NSRDB) + `_ .. [2] `NSRDB GOES Conus V4.0.0 - `_ + `_ """ # The well know text (WKT) representation of geometry notation is strict. # A POINT object is a string with longitude first, then the latitude, with @@ -465,7 +465,7 @@ def get_nsrdb_psm4_conus(latitude, longitude, api_key, email, year, 'utc': str(utc).lower(), 'interval': time_step } - # request CSV download from NREL PSM4 + # request CSV download from NLR PSM4 if url is None: url = PSM4_CON_URL @@ -504,9 +504,9 @@ def get_nsrdb_psm4_full_disc(latitude, longitude, api_key, email, longitude : float or int in decimal degrees, between -180 and 180, east is positive api_key : str - NREL Developer Network API key + NLR Developer Network API key email : str - NREL API uses this to automatically communicate messages back + NLR API uses this to automatically communicate messages back to the user only if necessary year : int or str PSM4 API parameter specifing year (e.g. ``2023``) to download. The @@ -542,9 +542,9 @@ def get_nsrdb_psm4_full_disc(latitude, longitude, api_key, email, Returns ------- data : pandas.DataFrame - timeseries data from NREL PSM4 + timeseries data from NLR PSM4 metadata : dict - metadata from NREL PSM4 about the record, see + metadata from NLR PSM4 about the record, see :func:`pvlib.iotools.read_nsrdb_psm4` for fields Raises @@ -557,8 +557,8 @@ def get_nsrdb_psm4_full_disc(latitude, longitude, api_key, email, Notes ----- - The required NREL developer key, `api_key`, is available for free by - registering at the `NREL Developer Network `_. + The required NLR developer key, `api_key`, is available for free by + registering at the `NLR Developer Network `_. .. warning:: The "DEMO_KEY" `api_key` is severely rate limited and may result in rejected requests. @@ -574,10 +574,10 @@ def get_nsrdb_psm4_full_disc(latitude, longitude, api_key, email, References ---------- - .. [1] `NREL National Solar Radiation Database (NSRDB) - `_ + .. [1] `NLR National Solar Radiation Database (NSRDB) + `_ .. [2] `NSRDB GOES Full Disc V4.0.0 - `_ + `_ """ # The well know text (WKT) representation of geometry notation is strict. # A POINT object is a string with longitude first, then the latitude, with @@ -604,7 +604,7 @@ def get_nsrdb_psm4_full_disc(latitude, longitude, api_key, email, 'utc': str(utc).lower(), 'interval': time_step } - # request CSV download from NREL PSM4 + # request CSV download from NLR PSM4 if url is None: url = PSM4_FUL_URL @@ -640,16 +640,16 @@ def read_nsrdb_psm4(filename, map_variables=True): Returns ------- data : pandas.DataFrame - timeseries data from NREL PSM4 + timeseries data from NLR PSM4 metadata : dict - metadata from NREL PSM4 about the record, see notes for fields + metadata from NLR PSM4 about the record, see notes for fields Notes ----- The return is a tuple with two items. The first item is a dataframe with the PSM4 timeseries data. - The second item is a dictionary with metadata from NREL PSM4 about the + The second item is a dictionary with metadata from NLR PSM4 about the record containing the following fields: * Source @@ -714,12 +714,11 @@ def read_nsrdb_psm4(filename, map_variables=True): pvlib.iotools.get_nsrdb_psm4_tmy pvlib.iotools.get_nsrdb_psm4_conus pvlib.iotools.get_nsrdb_psm4_full_disc - pvlib.iotools.read_psm3 References ---------- - .. [1] `NREL National Solar Radiation Database (NSRDB) - `_ + .. [1] `NLR National Solar Radiation Database (NSRDB) + `_ .. [2] `Standard Time Series Data File Format `_ """ diff --git a/pvlib/iotools/solaranywhere.py b/pvlib/iotools/solaranywhere.py index dfa7420ccc..2a6150559b 100644 --- a/pvlib/iotools/solaranywhere.py +++ b/pvlib/iotools/solaranywhere.py @@ -52,8 +52,7 @@ def get_solaranywhere(latitude, longitude, api_key, start=None, end=None, url=URL, map_variables=True, timeout=300): """Retrieve historical irradiance time series data from SolarAnywhere. - The SolarAnywhere API is described in [1]_ and [2]_. A detailed list of - API options can be found in [3]_. + The SolarAnywhere API is described in [1]_ and [2]_. Parameters ---------- @@ -74,7 +73,7 @@ def get_solaranywhere(latitude, longitude, api_key, start=None, end=None, 'SolarAnywhereTGYLatest' (TMY for GHI), 'SolarAnywhereTDYLatest' (TMY for DNI), or 'SolarAnywherePOELatest' for probability of exceedance. Specific dataset versions can also be specified, e.g., - 'SolarAnywhere3_2' (see [3]_ for a full list of options). + 'SolarAnywhere3_2' (see [2]_ for a full list of options). time_resolution: {60, 30, 15, 5}, default: 60 Time resolution in minutes. For TMY data, time resolution has to be 60 minutes (hourly). @@ -87,7 +86,7 @@ def get_solaranywhere(latitude, longitude, api_key, start=None, end=None, Probability of exceedance in the range of 1 to 99. Only relevant when requesting probability of exceedance (POE) time series. [%] variables: list-like, default: :const:`DEFAULT_VARIABLES` - Variables to retrieve (described in [4]_), must include + Variables to retrieve (described in [3]_), must include 'ObservationTime'. Available variables depend on whether historical or TMY data is requested. missing_data: {'Omit', 'FillAverage'}, default: 'FillAverage' @@ -129,10 +128,8 @@ def get_solaranywhere(latitude, longitude, api_key, start=None, end=None, .. [1] `SolarAnywhere API `_ .. [2] `SolarAnywhere irradiance and weather API requests - `_ - .. [3] `SolarAnywhere API options - `_ - .. [4] `SolarAnywhere variable definitions + `_ + .. [3] `SolarAnywhere variable definitions `_ """ # noqa: E501 headers = {'content-type': "application/json; charset=utf-8", diff --git a/pvlib/iotools/solrad.py b/pvlib/iotools/solrad.py index 90bf5b666e..f4d446fc61 100644 --- a/pvlib/iotools/solrad.py +++ b/pvlib/iotools/solrad.py @@ -195,7 +195,7 @@ def get_solrad(station, start, end, end = pd.to_datetime(end) # Generate list of filenames - dates = pd.date_range(start.floor('d'), end, freq='d') + dates = pd.date_range(start.floor('D'), end, freq='D') station = station.lower() filenames = [ f"{station}/{d.year}/{station}{d.strftime('%y')}{d.dayofyear:03}.dat" diff --git a/pvlib/iotools/tmy.py b/pvlib/iotools/tmy.py index 4b6edaeea4..28d20ffc2d 100644 --- a/pvlib/iotools/tmy.py +++ b/pvlib/iotools/tmy.py @@ -4,6 +4,8 @@ import re import pandas as pd +from pvlib.tools import _file_context_manager + # Dictionary mapping TMY3 names to pvlib names VARIABLE_MAP = { 'GHI (W/m^2)': 'ghi', @@ -35,7 +37,7 @@ def read_tmy3(filename, coerce_year=None, map_variables=True, encoding=None): Parameters ---------- - filename : str + filename : str, Path, or file-like object A relative file path or absolute file path. coerce_year : int, optional If supplied, the year of the index will be set to ``coerce_year``, except @@ -186,7 +188,7 @@ def read_tmy3(filename, coerce_year=None, map_variables=True, encoding=None): """ # noqa: E501 head = ['USAF', 'Name', 'State', 'TZ', 'latitude', 'longitude', 'altitude'] - with open(str(filename), 'r', encoding=encoding) as fbuf: + with _file_context_manager(filename, mode="r", encoding=encoding) as fbuf: # header information on the 1st line (0 indexing) firstline = fbuf.readline() # use pandas to read the csv file buffer diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index 2e1ed35cb5..5a4a76df25 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -16,7 +16,7 @@ from pvlib import atmosphere, solarposition, tools import pvlib # used to avoid dni name collision in complete_irradiance -from pvlib._deprecation import pvlibDeprecationWarning, renamed_kwarg_warning +from pvlib._deprecation import pvlibDeprecationWarning import warnings @@ -63,10 +63,11 @@ def get_extra_radiation(datetime_or_doy, solar_constant=1366.1, Returns ------- dni_extra : float, array, or Series - The extraterrestrial radiation present in watts per square meter - on a surface which is normal to the sun. Pandas Timestamp and - DatetimeIndex inputs will yield a Pandas TimeSeries. All other - inputs will yield a float or an array of floats. + The extraterrestrial radiation normal to the sun. + Pandas Timestamp and DatetimeIndex inputs for ``datetime_or_doy`` + will return ``dni_extra`` as a Pandas TimeSeries. All other input + data types will yield ``dni_extra`` as a float or an array of floats. + See :term:`dni_extra`. [Wm⁻²] References ---------- @@ -88,7 +89,7 @@ def get_extra_radiation(datetime_or_doy, solar_constant=1366.1, Engineers, 2005. :doi:`10.1061/9780784408056` .. [6] I. Reda, A. Andreas, "Solar position algorithm for solar - radiation applications" NREL Golden, USA. NREL/TP-560-34302, + radiation applications" NREL Golden, CO, USA. NREL/TP-560-34302, Revised 2008. :doi:`10.2172/15003974` """ @@ -171,18 +172,22 @@ def aoi_projection(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth): Parameters ---------- surface_tilt : numeric - Panel tilt from horizontal. + Panel tilt from horizontal. See :term:`surface_tilt`. [°] + surface_azimuth : numeric - Panel azimuth from north. + Panel azimuth. See :term:`surface_azimuth`. [°] + solar_zenith : numeric - Solar zenith angle. + Solar zenith angle. See :term:`solar_zenith`. [°] + solar_azimuth : numeric - Solar azimuth angle. + Solar azimuth angle. See :term:`solar_azimuth`. [°] Returns ------- projection : numeric Dot product of panel normal and solar angle. + See :term:`aoi_projection`. """ projection = ( @@ -211,18 +216,18 @@ def aoi(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth): Parameters ---------- surface_tilt : numeric - Panel tilt from horizontal. + Panel tilt from horizontal. See :term:`surface_tilt`. [°] surface_azimuth : numeric - Panel azimuth from north. + Panel azimuth. See :term:`surface_azimuth`. [°] solar_zenith : numeric - Solar zenith angle. + Solar zenith angle. See :term:`solar_zenith`. [°] solar_azimuth : numeric - Solar azimuth angle. + Solar azimuth angle. See :term:`solar_azimuth`. [°] Returns ------- aoi : numeric - Angle of incidence in degrees. + Angle of incidence, see :term:`aoi`. [°] """ projection = aoi_projection(surface_tilt, surface_azimuth, @@ -245,20 +250,20 @@ def beam_component(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, Parameters ---------- surface_tilt : numeric - Panel tilt from horizontal. + Panel tilt from horizontal. See :term:`surface_tilt`. [°] surface_azimuth : numeric - Panel azimuth from north. + Panel azimuth. See :term:`surface_azimuth`. [°] solar_zenith : numeric - Solar zenith angle. + Solar zenith angle. See :term:`solar_zenith`. [°] solar_azimuth : numeric - Solar azimuth angle. + Solar azimuth angle. See :term:`solar_azimuth`. [°] dni : numeric Direct normal irradiance, see :term:`dni`. [Wm⁻²] Returns ------- beam : numeric - Beam component + Beam component. [Wm⁻²] """ beam = dni * aoi_projection(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth) @@ -293,25 +298,27 @@ def get_total_irradiance(surface_tilt, surface_azimuth, Parameters ---------- surface_tilt : numeric - Panel tilt from horizontal. [degree] + Panel tilt from horizontal. See :term:`surface_tilt`. [°] surface_azimuth : numeric - Panel azimuth from north. [degree] + Panel azimuth. See :term:`surface_azimuth`. [°] solar_zenith : numeric - Solar zenith angle. [degree] + Solar zenith angle. See :term:`solar_zenith`. [°] solar_azimuth : numeric - Solar azimuth angle. [degree] + Solar azimuth angle. See :term:`solar_azimuth`. [°] dni : numeric - Direct Normal Irradiance. [W/m2] + Direct normal irradiance. See :term:`dni`. [Wm⁻²] ghi : numeric - Global horizontal irradiance. [W/m2] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] dhi : numeric - Diffuse horizontal irradiance. [W/m2] + Diffuse horizontal irradiance. See :term:`dhi`. [Wm⁻²] dni_extra : numeric, optional - Extraterrestrial direct normal irradiance. [W/m2] + Extraterrestrial direct normal irradiance. See :term:`dni_extra`. + [Wm⁻²] airmass : numeric, optional - Relative airmass (not adjusted for pressure). [unitless] + Relative airmass, not adjusted for pressure. + See :term:`airmass_relative`. [unitless] albedo : numeric, default 0.25 - Ground surface albedo. [unitless] + Ground surface albedo. See :term:`albedo`. [unitless] surface_type : str, optional Surface type. See :py:func:`~pvlib.irradiance.get_ground_diffuse` for the list of accepted values. @@ -326,7 +333,7 @@ def get_total_irradiance(surface_tilt, surface_azimuth, ------- total_irrad : OrderedDict or DataFrame Contains keys/columns ``'poa_global', 'poa_direct', 'poa_diffuse', - 'poa_sky_diffuse', 'poa_ground_diffuse'``. + 'poa_sky_diffuse', 'poa_ground_diffuse'``. [Wm⁻²] Notes ----- @@ -372,23 +379,25 @@ def get_sky_diffuse(surface_tilt, surface_azimuth, Parameters ---------- surface_tilt : numeric - Panel tilt from horizontal. [degree] + Panel tilt from horizontal. See :term:`surface_tilt`. [°] surface_azimuth : numeric - Panel azimuth from north. [degree] + Panel azimuth. See :term:`surface_azimuth`. [°] solar_zenith : numeric - Solar zenith angle. [degree] + Solar zenith angle. See :term:`solar_zenith`. [°] solar_azimuth : numeric - Solar azimuth angle. [degree] + Solar azimuth angle. See :term:`solar_azimuth`. [°] dni : numeric - Direct Normal Irradiance. [W/m2] + Direct normal irradiance. See :term:`dni`. [Wm⁻²] ghi : numeric - Global horizontal irradiance. [W/m2] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] dhi : numeric - Diffuse horizontal irradiance. [W/m2] + Diffuse horizontal irradiance. See :term:`dhi`. [Wm⁻²] dni_extra : numeric, optional - Extraterrestrial direct normal irradiance. [W/m2] + Extraterrestrial direct normal irradiance. See :term:`dni_extra`. + [Wm⁻²] airmass : numeric, optional - Relative airmass (not adjusted for pressure). [unitless] + Relative airmass, not adjusted for pressure. + See :term:`airmass_relative`. [unitless] model : str, default 'isotropic' Irradiance model. Can be one of ``'isotropic'``, ``'klucher'``, ``'haydavies'``, ``'reindl'``, ``'king'``, ``'perez'``, @@ -399,7 +408,7 @@ def get_sky_diffuse(surface_tilt, surface_azimuth, Returns ------- poa_sky_diffuse : numeric - Sky diffuse irradiance in the plane of array. [W/m2] + Sky diffuse irradiance in the plane of array. [Wm⁻²] Raises ------ @@ -469,36 +478,36 @@ def poa_components(aoi, dni, poa_sky_diffuse, poa_ground_diffuse): ---------- aoi : numeric Angle of incidence of solar rays with respect to the module - surface, from :func:`aoi`. + surface. See :term:`aoi`. [°] dni : numeric - Direct normal irradiance (Wm⁻²), as measured from a TMY file or - calculated with a clearsky model. + Direct normal irradiance, as measured from a TMY file or + calculated with a clearsky model. See :term:`dni`. [Wm⁻²] poa_sky_diffuse : numeric - Diffuse irradiance (Wm⁻²) in the plane of the modules, as - calculated by a diffuse irradiance translation function + Diffuse irradiance in the plane of the modules, as + calculated by a diffuse irradiance translation function. [Wm⁻²] poa_ground_diffuse : numeric - Ground reflected irradiance (Wm⁻²) in the plane of the modules, - as calculated by an albedo model (eg. :func:`grounddiffuse`) + Ground-reflected irradiance in the plane of the modules, + as calculated by an albedo model (eg., + :py:func:`~pvlib.irradiance.get_ground_diffuse`). [Wm⁻²] Returns ------- irrads : OrderedDict or DataFrame Contains the following keys: - * ``poa_global`` : Total in-plane irradiance (Wm⁻²) - * ``poa_direct`` : Total in-plane beam irradiance (Wm⁻²) - * ``poa_diffuse`` : Total in-plane diffuse irradiance (Wm⁻²) - * ``poa_sky_diffuse`` : In-plane diffuse irradiance from sky (Wm⁻²) - * ``poa_ground_diffuse`` : In-plane diffuse irradiance from ground - (Wm⁻²) + * ``poa_global`` : Total in-plane irradiance. [Wm⁻²] + * ``poa_direct`` : Total in-plane beam irradiance. [Wm⁻²] + * ``poa_diffuse`` : Total in-plane diffuse irradiance. [Wm⁻²] + * ``poa_sky_diffuse`` : In-plane diffuse irradiance from sky. [Wm⁻²] + * ``poa_ground_diffuse`` : In-plane diffuse irradiance from ground. + [Wm⁻²] Notes ------ - Negative beam irradiation due to aoi :math:`> 90^{\circ}` or AOI - :math:`< 0^{\circ}` is set to zero. + Negative beam irradiation due to AOI > 90° or AOI < 0° is set to zero. ''' poa_direct = np.maximum(dni * np.cos(np.radians(aoi)), 0) @@ -533,18 +542,17 @@ def get_ground_diffuse(surface_tilt, ghi, albedo=.25, surface_type=None): Parameters ---------- surface_tilt : numeric - Surface tilt angles in decimal degrees. Tilt must be >=0 and - <=180. The tilt angle is defined as degrees from horizontal - (e.g. surface facing up = 0, surface facing horizon = 90). + Panel tilt from horizontal. See :term:`surface_tilt`. [°] ghi : numeric - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] albedo : numeric, default 0.25 - Ground reflectance, typically 0.1-0.4 for surfaces on Earth - (land), may increase over snow, ice, etc. May also be known as + Ground surface albedo., typically 0.1-0.4 for bare or vegetated ground, + may increase over snow, ice, etc. May also be known as the reflection coefficient. Must be >=0 and <=1. Will be - overridden if ``surface_type`` is supplied. + overridden if ``surface_type`` is supplied. See :term:`albedo`. + [unitless] surface_type : string, optional If supplied, overrides ``albedo``. ``surface_type`` can be one of @@ -606,17 +614,15 @@ def isotropic(surface_tilt, dhi): Parameters ---------- surface_tilt : numeric - Surface tilt angle in decimal degrees. Tilt must be >=0 and - <=180. The tilt angle is defined as degrees from horizontal - (e.g. surface facing up = 0, surface facing horizon = 90) + Panel tilt from horizontal. See :term:`surface_tilt`. [°] dhi : numeric - Diffuse horizontal irradiance. [Wm⁻²] DHI must be >=0. + Diffuse horizontal irradiance, must be >=0. See :term:`dhi`. Returns ------- diffuse : numeric - The sky diffuse component of the solar radiation. + The sky diffuse component of the solar radiation. [Wm⁻²] References ---------- @@ -644,29 +650,22 @@ def klucher(surface_tilt, surface_azimuth, dhi, ghi, solar_zenith, Parameters ---------- surface_tilt : numeric - Surface tilt angles in decimal degrees. ``surface_tilt`` must be >=0 - and <=180. The tilt angle is defined as degrees from horizontal - (e.g. surface facing up = 0, surface facing horizon = 90) + Panel tilt from horizontal. See :term:`surface_tilt`. [°] surface_azimuth : numeric - Surface azimuth angles in decimal degrees. ``surface_azimuth`` must - be >=0 and <=360. The Azimuth convention is defined as degrees - east of north (e.g. North = 0, South=180 East = 90, West = 270). + Panel azimuth. See :term:`surface_azimuth`. [°] dhi : numeric - Diffuse horizontal irradiance, must be >=0. [Wm⁻²] + Diffuse horizontal irradiance, must be >=0. See :term:`dhi`. [Wm⁻²] ghi : numeric - Global horizontal irradiance, must be >=0. [Wm⁻²] + Global horizontal irradiance, must be >=0. See :term:`ghi`. [Wm⁻²] solar_zenith : numeric - Apparent (refraction-corrected) zenith angles in decimal - degrees. ``solar_zenith`` must be >=0 and <=180. + Apparent (refraction-corrected) zenith angles. [°] solar_azimuth : numeric - Sun azimuth angles in decimal degrees. ``solar_azimuth`` must be >=0 - and <=360. The Azimuth convention is defined as degrees east of - north (e.g. North = 0, East = 90, West = 270). + Sun azimuth angles. See :term:`solar_azimuth`. [°] Returns ------- @@ -749,32 +748,29 @@ def haydavies(surface_tilt, surface_azimuth, dhi, dni, dni_extra, Parameters ---------- surface_tilt : numeric - Panel tilt from the horizontal, in decimal degrees, see - :term:`surface_tilt`. + Panel tilt from the horizontal. See :term:`surface_tilt`. [°] surface_azimuth : numeric - Surface azimuth angles in decimal degrees. The azimuth - convention is defined as degrees east of north (e.g. North=0, - South=180, East=90, West=270). + Panel azimuth. See :term:`surface_azimuth`. [°] dhi : numeric - Diffuse horizontal irradiance. [Wm⁻²] + Diffuse horizontal irradiance, see :term:`dhi`. [Wm⁻²] dni : numeric Direct normal irradiance, see :term:`dni`. [Wm⁻²] dni_extra : numeric - Extraterrestrial normal irradiance. [Wm⁻²] + Extraterrestrial normal irradiance, see :term:`dni_extra`. [Wm⁻²] solar_zenith : numeric, optional - Solar apparent (refraction-corrected) zenith angles in decimal - degrees. Must supply ``solar_zenith`` and ``solar_azimuth`` or - supply ``projection_ratio``. + Solar apparent (refraction-corrected) zenith angles. Must supply + ``solar_zenith`` and ``solar_azimuth``, or supply + ``projection_ratio``. [°] solar_azimuth : numeric, optional - Solar azimuth angles in decimal degrees. Must supply - ``solar_zenith`` and ``solar_azimuth`` or supply - ``projection_ratio``. + Solar azimuth angles. Must supply ``solar_zenith`` and + ``solar_azimuth``, or supply ``projection_ratio``. See + :term:`solar_azimuth`. [°] projection_ratio : numeric, optional Ratio of angle of incidence projection to solar zenith angle @@ -794,14 +790,15 @@ def haydavies(surface_tilt, surface_azimuth, dhi, dni, dni_extra, sky_diffuse : numeric The sky diffuse component of the solar radiation on a tilted - surface. + surface. [Wm⁻²] diffuse_components : OrderedDict (array input) or DataFrame (Series input) Keys/columns are: - * sky_diffuse: Total sky diffuse - * isotropic - * circumsolar - * horizon + * poa_sky_diffuse: Total sky diffuse + * poa_isotropic + * poa_circumsolar + * poa_horizon (always zero, not accounted for by the + Hay-Davies model) Notes ------ @@ -860,13 +857,13 @@ def haydavies(surface_tilt, surface_azimuth, dhi, dni, dni_extra, if return_components: diffuse_components = OrderedDict() - diffuse_components['sky_diffuse'] = sky_diffuse + diffuse_components['poa_sky_diffuse'] = sky_diffuse # Calculate the individual components - diffuse_components['isotropic'] = poa_isotropic - diffuse_components['circumsolar'] = poa_circumsolar - diffuse_components['horizon'] = np.where( - np.isnan(diffuse_components['isotropic']), np.nan, 0.) + diffuse_components['poa_isotropic'] = poa_isotropic + diffuse_components['poa_circumsolar'] = poa_circumsolar + diffuse_components['poa_horizon'] = np.where( + np.isnan(diffuse_components['poa_isotropic']), np.nan, 0.) if isinstance(sky_diffuse, pd.Series): diffuse_components = pd.DataFrame(diffuse_components) @@ -891,39 +888,34 @@ def reindl(surface_tilt, surface_azimuth, dhi, dni, ghi, dni_extra, Parameters ---------- surface_tilt : numeric - Surface tilt angles in decimal degrees. The tilt angle is - defined as degrees from horizontal (e.g. surface facing up = 0, - surface facing horizon = 90) + Panel tilt from the horizontal. See :term:`surface_tilt`. [°] surface_azimuth : numeric - Surface azimuth angles in decimal degrees. The azimuth - convention is defined as degrees east of north (e.g. North = 0, - South=180 East = 90, West = 270). + Panel azimuth. See :term:`surface_azimuth`. [°] dhi : numeric - diffuse horizontal irradiance. [Wm⁻²] + Diffuse horizontal irradiance, see :term:`dhi`. [Wm⁻²] dni : numeric - direct normal irradiance. [Wm⁻²] + Direct normal irradiance, see :term:`dni`. [Wm⁻²] - ghi: numeric - Global horizontal irradiance. [Wm⁻²] + ghi : numeric + Global horizontal irradiance, see :term:`ghi`. [Wm⁻²] dni_extra : numeric - Extraterrestrial normal irradiance. [Wm⁻²] + Extraterrestrial normal irradiance, see :term:`dni_extra`. [Wm⁻²] solar_zenith : numeric - Apparent (refraction-corrected) zenith angles in decimal degrees. + Solar apparent (refraction-corrected) zenith angles + See :term:`solar_zenith`. [°] solar_azimuth : numeric - Sun azimuth angles in decimal degrees. The azimuth convention is - defined as degrees east of north (e.g. North = 0, East = 90, - West = 270). + Solar azimuth angles. See :term:`solar_azimuth`. [°] Returns ------- poa_sky_diffuse : numeric - The sky diffuse component of the solar radiation. + The sky diffuse component of the solar radiation. [Wm⁻²] Notes ----- @@ -1008,18 +1000,16 @@ def king(surface_tilt, dhi, ghi, solar_zenith): Parameters ---------- surface_tilt : numeric - Surface tilt angles in decimal degrees. The tilt angle is - defined as degrees from horizontal (e.g. surface facing up = 0, - surface facing horizon = 90) + Panel tilt from the horizontal. See :term:`surface_tilt`. [°] dhi : numeric - Diffuse horizontal irradiance. [Wm⁻²] + Diffuse horizontal irradiance. See :term:`dhi`. [Wm⁻²] ghi : numeric - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] solar_zenith : numeric - Apparent (refraction-corrected) zenith angles in decimal degrees. + Solar apparent (refraction-corrected) zenith angles. [°] Returns -------- @@ -1060,38 +1050,33 @@ def perez(surface_tilt, surface_azimuth, dhi, dni, dni_extra, Parameters ---------- surface_tilt : numeric - Surface tilt angles in decimal degrees. surface_tilt must be >=0 - and <=180. The tilt angle is defined as degrees from horizontal - (e.g. surface facing up = 0, surface facing horizon = 90) + Surface tilt angle. See :term:`surface_tilt`. + [°] surface_azimuth : numeric - Surface azimuth angles in decimal degrees. surface_azimuth must - be >=0 and <=360. The azimuth convention is defined as degrees - east of north (e.g. North = 0, South=180 East = 90, West = 270). + Surface azimuth angle. See :term:`surface_azimuth`. [°] dhi : numeric - Diffuse horizontal irradiance. [Wm⁻²] DHI must be >=0. + Diffuse horizontal irradiance, must be >=0. [Wm⁻²] dni : numeric - Direct normal irradiance. [Wm⁻²] DNI must be >=0. + Direct normal irradiance, must be >=0. [Wm⁻²] + dni_extra : numeric Extraterrestrial normal irradiance. [Wm⁻²] solar_zenith : numeric - apparent (refraction-corrected) zenith angles in decimal - degrees. solar_zenith must be >=0 and <=180. + apparent (refraction-corrected) zenith angle. [°] solar_azimuth : numeric - Sun azimuth angles in decimal degrees. solar_azimuth must be >=0 - and <=360. The azimuth convention is defined as degrees east of - north (e.g. North = 0, East = 90, West = 270). + Solar azimuth angle. See :term:`solar_azimuth`. [°] airmass : numeric Relative (not pressure-corrected) airmass values. If AM is a DataFrame it must be of the same size as all other DataFrame inputs. AM must be >=0 (careful using the 1/sec(z) model of AM - generation) + generation). [unitless] model : string, default 'allsitescomposite1990' A string which selects the desired set of Perez coefficients. If @@ -1128,10 +1113,10 @@ def perez(surface_tilt, surface_azimuth, dhi, dni, dni_extra, diffuse_components : OrderedDict (array input) or DataFrame (Series input) Keys/columns are: - * sky_diffuse: Total sky diffuse - * isotropic - * circumsolar - * horizon + * poa_sky_diffuse: Total sky diffuse + * poa_isotropic + * poa_circumsolar + * poa_horizon References @@ -1214,12 +1199,12 @@ def perez(surface_tilt, surface_azimuth, dhi, dni, dni_extra, if return_components: diffuse_components = OrderedDict() - diffuse_components['sky_diffuse'] = sky_diffuse + diffuse_components['poa_sky_diffuse'] = sky_diffuse # Calculate the different components - diffuse_components['isotropic'] = dhi * term1 - diffuse_components['circumsolar'] = dhi * term2 - diffuse_components['horizon'] = dhi * term3 + diffuse_components['poa_isotropic'] = dhi * term1 + diffuse_components['poa_circumsolar'] = dhi * term2 + diffuse_components['poa_horizon'] = dhi * term3 # Set values of components to 0 when sky_diffuse is 0 mask = sky_diffuse == 0 @@ -1326,39 +1311,32 @@ def perez_driesse(surface_tilt, surface_azimuth, dhi, dni, dni_extra, Parameters ---------- surface_tilt : numeric - Surface tilt angles in decimal degrees. surface_tilt must be >=0 - and <=180. The tilt angle is defined as degrees from horizontal - (e.g. surface facing up = 0, surface facing horizon = 90) + Surface tilt angle. See :term:`surface_tilt`. [°] surface_azimuth : numeric - Surface azimuth angles in decimal degrees. surface_azimuth must - be >=0 and <=360. The azimuth convention is defined as degrees - east of north (e.g. North = 0, South=180 East = 90, West = 270). + Surface azimuth angle. See :term:`surface_azimuth`. [°] dhi : numeric - Diffuse horizontal irradiance. [Wm⁻²] dhi must be >=0. + Diffuse horizontal irradiance, must be >=0. [Wm⁻²] dni : numeric - Direct normal irradiance. [Wm⁻²] dni must be >=0. + Direct normal irradiance, must be >=0. [Wm⁻²] + dni_extra : numeric Extraterrestrial normal irradiance. [Wm⁻²] solar_zenith : numeric - apparent (refraction-corrected) zenith angles in decimal - degrees. solar_zenith must be >=0 and <=180. + apparent (refraction-corrected) zenith angle. [°] solar_azimuth : numeric - Sun azimuth angles in decimal degrees. solar_azimuth must be >=0 - and <=360. The azimuth convention is defined as degrees east of - north (e.g. North = 0, East = 90, West = 270). + Solar azimuth angle. See :term:`solar_azimuth`. [°] airmass : numeric, optional Relative (not pressure-corrected) airmass values. If ``airmass`` is a DataFrame it must be of the same size as all other DataFrame - inputs. The kastenyoung1989 airmass calculation is used internally - and is also recommended when pre-calculating airmass because - it was used in the original model development. + inputs. AM must be >=0 (careful using the 1/sec(z) model of AM + generation). [unitless] return_components: bool (optional, default=False) Flag used to decide whether to return the calculated diffuse components @@ -1377,10 +1355,10 @@ def perez_driesse(surface_tilt, surface_azimuth, dhi, dni, dni_extra, diffuse_components : OrderedDict (array input) or DataFrame (Series input) Keys/columns are: - * sky_diffuse: Total sky diffuse - * isotropic - * circumsolar - * horizon + * poa_sky_diffuse: Total sky diffuse + * poa_isotropic + * poa_circumsolar + * poa_horizon Notes ----- @@ -1441,12 +1419,12 @@ def perez_driesse(surface_tilt, surface_azimuth, dhi, dni, dni_extra, if return_components: diffuse_components = OrderedDict() - diffuse_components['sky_diffuse'] = sky_diffuse + diffuse_components['poa_sky_diffuse'] = sky_diffuse # Calculate the different components - diffuse_components['isotropic'] = dhi * term1 - diffuse_components['circumsolar'] = dhi * term2 - diffuse_components['horizon'] = dhi * term3 + diffuse_components['poa_isotropic'] = dhi * term1 + diffuse_components['poa_circumsolar'] = dhi * term2 + diffuse_components['poa_horizon'] = dhi * term3 if isinstance(sky_diffuse, pd.Series): diffuse_components = pd.DataFrame(diffuse_components) @@ -1549,32 +1527,45 @@ def ghi_from_poa_driesse_2023(surface_tilt, surface_azimuth, Parameters ---------- surface_tilt : numeric - Panel tilt from horizontal. [degree] + Panel tilt from horizontal. See :term:`surface_tilt`. [°] + surface_azimuth : numeric - Panel azimuth from north. [degree] + Panel azimuth. See :term:`surface_azimuth`. [°] + solar_zenith : numeric - Solar zenith angle. [degree] + Solar zenith angle. See :term:`solar_zenith`. [°] + solar_azimuth : numeric - Solar azimuth angle. [degree] + Solar azimuth angle. See :term:`solar_azimuth`. [°] + poa_global : numeric - Plane-of-array global irradiance, aka global tilted irradiance. [Wm⁻²] + Plane-of-array global irradiance, aka global tilted irradiance. + See :term:`poa_global`. [Wm⁻²] + dni_extra : numeric - Extraterrestrial direct normal irradiance. [Wm⁻²] + Extraterrestrial direct normal irradiance. See :Term:`dni_extra`. + [Wm⁻²] + airmass : numeric, optional - Relative airmass (not adjusted for pressure). [unitless] + Relative airmass (not adjusted for pressure). See + :term:`airmass_relative`. [unitless] + albedo : numeric, default 0.25 - Ground surface albedo. [unitless] + Ground surface albedo. See :term:`albedo`. [unitless] + xtol : numeric, default 0.01 - Convergence criterion. The estimated GHI will be within xtol of the + Convergence criterion. The estimated GHI will be within ``xtol`` of the true value. Must be positive. [Wm⁻²] + full_output : boolean, default False - If full_output is False, only ghi is returned, otherwise the return - value is (ghi, converged, niter). (see Returns section for details). + If full_output is False, only ``ghi`` is returned, otherwise the return + value is (``ghi``, ``converged``, ``niter``). + (see Returns section for details). Returns ------- ghi : numeric - Estimated GHI. [Wm⁻²] + Estimated global horizontal irradiance. See :term:`ghi`. [Wm⁻²] converged : boolean, optional Present if full_output=True. Indicates which elements converged successfully. @@ -1623,11 +1614,6 @@ def ghi_from_poa_driesse_2023(surface_tilt, surface_azimuth, return ghi -@renamed_kwarg_warning( - since='0.11.2', - old_param_name='clearsky_ghi', - new_param_name='ghi_clear', - removal="0.14.0") def clearsky_index(ghi, ghi_clear, max_clearsky_index=2.0): """ Calculate the clearsky index. @@ -1638,10 +1624,10 @@ def clearsky_index(ghi, ghi_clear, max_clearsky_index=2.0): Parameters ---------- ghi : numeric - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] ghi_clear : numeric - Modeled clearsky GHI + Modeled clearsky GHI. See :term:`ghi_clear`. [Wm⁻²] .. versionchanged:: 0.11.2 Renamed from ``ghi_clearsky`` to ``ghi_clear``. @@ -1653,7 +1639,7 @@ def clearsky_index(ghi, ghi_clear, max_clearsky_index=2.0): Returns ------- clearsky_index : numeric - Clearsky index + Clearsky index. [unitless] """ clearsky_index = ghi / ghi_clear # set +inf, -inf, and nans to zero @@ -1684,28 +1670,28 @@ def clearness_index(ghi, solar_zenith, extra_radiation, min_cos_zenith=0.065, Parameters ---------- ghi : numeric - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] solar_zenith : numeric - True (not refraction-corrected) solar zenith angle in decimal - degrees. + True (not refraction-corrected) solar zenith angle. + See :term:`solar_zenith`. [°] extra_radiation : numeric - Irradiance incident at the top of the atmosphere + Irradiance incident at the top of the atmosphere. See + :term:`dni_extra`. [Wm⁻²] min_cos_zenith : numeric, default 0.065 Minimum value of cos(zenith) to allow when calculating global - clearness index `kt`. Equivalent to zenith = 86.273 degrees. + clearness index ``kt``. Equivalent to zenith = 86.273°. max_clearness_index : numeric, default 2.0 Maximum value of the clearness index. The default, 2.0, allows for over-irradiance events typically seen in sub-hourly data. - NREL's SRRL Fortran code used 0.82 for hourly data. Returns ------- kt : numeric - Clearness index + Clearness index. [unitless] References ---------- @@ -1737,20 +1723,20 @@ def clearness_index_zenith_independent(clearness_index, airmass, ---------- clearness_index : numeric Ratio of global to extraterrestrial irradiance on a horizontal - plane + plane. [unitless] airmass : numeric - Airmass + Airmass. See :term:`airmass_relative`. [unitless] max_clearness_index : numeric, default 2.0 Maximum value of the clearness index. The default, 2.0, allows for over-irradiance events typically seen in sub-hourly data. - NREL's SRRL Fortran code used 0.82 for hourly data. + NLR's SRRL Fortran code used 0.82 for hourly data. Returns ------- kt_prime : numeric - Zenith independent clearness index + Zenith-independent clearness index. [unitless] References ---------- @@ -1789,7 +1775,7 @@ def disc(ghi, solar_zenith, datetime_or_doy, pressure=101325, The original report describing the DISC model [1]_ uses the relative airmass rather than the absolute (pressure-corrected) - airmass. However, the NREL implementation of the DISC model [2]_ + airmass. However, the NLR implementation of the DISC model [2]_ uses absolute airmass. PVLib Matlab also uses the absolute airmass. pvlib python defaults to absolute airmass, but the relative airmass can be used by supplying `pressure=None`. @@ -1797,32 +1783,31 @@ def disc(ghi, solar_zenith, datetime_or_doy, pressure=101325, Parameters ---------- ghi : numeric - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] solar_zenith : numeric - True (not refraction-corrected) solar zenith angles in decimal - degrees. + True (not refraction-corrected) solar zenith angles. See + :term:`solar_zenith`. [°] datetime_or_doy : int, float, array, pd.DatetimeIndex Day of year or array of days of year e.g. pd.DatetimeIndex.dayofyear, or pd.DatetimeIndex. pressure : numeric or None, default 101325 - Site pressure in Pascal. Uses absolute (pressure-corrected) airmass - by default. Set to ``None`` to use relative airmass. + Site pressure. See :term:`pressure`. [Pa] min_cos_zenith : numeric, default 0.065 Minimum value of cos(zenith) to allow when calculating global - clearness index `kt`. Equivalent to zenith = 86.273 degrees. + clearness index :math:`k_t`. Equivalent to zenith = 86.273°. max_zenith : numeric, default 87 Maximum value of zenith to allow in DNI calculation. DNI will be - set to 0 for times with zenith values greater than `max_zenith`. + set to 0 for times with zenith values greater than `max_zenith`. [°] max_airmass : numeric, default 12 Maximum value of the airmass to allow in Kn calculation. Default value (12) comes from range over which Kn was fit - to airmass in the original paper. + to airmass in the original paper. [unitless] Returns ------- @@ -1830,11 +1815,11 @@ def disc(ghi, solar_zenith, datetime_or_doy, pressure=101325, Contains the following keys: * ``dni``: The modeled direct normal irradiance - in Wm⁻² provided by the - Direct Insolation Simulation Code (DISC) model. + provided by the Direct Insolation Simulation Code (DISC) model. + [Wm⁻²] * ``kt``: Ratio of global to extraterrestrial - irradiance on a horizontal plane. - * ``airmass``: Airmass + irradiance on a horizontal plane. [unitless] + * ``airmass``: Airmass. [unitless] References ---------- @@ -1844,7 +1829,7 @@ def disc(ghi, solar_zenith, datetime_or_doy, pressure=101325, Institute, 1987. .. [2] Maxwell, E. "DISC Model", Excel Worksheet. - https://www.nrel.gov/grid/solar-resource/disc.html + https://www.nlr.gov/grid/solar-resource/disc.html See Also -------- @@ -1943,17 +1928,16 @@ def dirint(ghi, solar_zenith, times, pressure=101325., use_delta_kt_prime=True, Parameters ---------- ghi : array-like - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] solar_zenith : array-like - True (not refraction-corrected) solar_zenith angles in decimal - degrees. + True (not refraction-corrected) solar zenith angles. See + :term:`solar_zenith`. [°] times : DatetimeIndex pressure : float or array-like, default 101325.0 - The site pressure in Pascal. Pressure may be measured or an - average pressure may be calculated from site altitude. + Air pressure. See :term:`pressure`. [Pa] use_delta_kt_prime : bool, default True If True, indicates that the stability index delta_kt_prime is @@ -1964,25 +1948,25 @@ def dirint(ghi, solar_zenith, times, pressure=101325., use_delta_kt_prime=True, input data must be Series. temp_dew : float, or array-like, optional - Surface dew point temperatures, in degrees C. Values of temp_dew + Surface dew point temperatures, in. Values of ``temp_dew`` may be numeric or NaN. Any single time period point with a temp_dew=NaN does not have dew point improvements applied. If - temp_dew is not provided, then dew point improvements are not - applied. + ``temp_dew`` is not provided, then dew point improvements are not + applied. See :term:`temp_dew`. [°C] min_cos_zenith : numeric, default 0.065 Minimum value of cos(zenith) to allow when calculating global - clearness index `kt`. Equivalent to zenith = 86.273 degrees. + clearness index Kt. Equivalent to zenith = 86.273°. [°] max_zenith : numeric, default 87 Maximum value of zenith to allow in DNI calculation. DNI will be - set to 0 for times with zenith values greater than `max_zenith`. + set to 0 for times with zenith values greater than ``max_zenith``. [°] Returns ------- dni : array-like - The modeled direct normal irradiance in Wm⁻² provided by the - DIRINT model. + The modeled direct normal irradiance, as provided by the + DIRINT model. [Wm⁻²] Notes ----- @@ -2082,9 +2066,9 @@ def _dirint_coeffs(times, kt_prime, solar_zenith, w, delta_kt_prime): Parameters ---------- times : pd.DatetimeIndex - kt_prime : Zenith-independent clearness index - solar_zenith : Solar zenith angle - w : precipitable water estimated from surface dew-point temperature + kt_prime : Zenith-independent clearness index. [unitless] + solar_zenith : Solar zenith angle. [°] + w : precipitable water estimated from surface dew-point temperature. [cm] delta_kt_prime : stability index Returns @@ -2168,16 +2152,6 @@ def _dirint_bins(times, kt_prime, zenith, w, delta_kt_prime): return kt_prime_bin, zenith_bin, w_bin, delta_kt_prime_bin -@renamed_kwarg_warning( - since='0.11.2', - old_param_name='ghi_clearsky', - new_param_name='ghi_clear', - removal="0.14.0") -@renamed_kwarg_warning( - since='0.11.2', - old_param_name='dni_clearsky', - new_param_name='dni_clear', - removal="0.14.0") def dirindex(ghi, ghi_clear, dni_clear, zenith, times, pressure=101325., use_delta_kt_prime=True, temp_dew=None, min_cos_zenith=0.065, max_zenith=87): @@ -2195,30 +2169,31 @@ def dirindex(ghi, ghi_clear, dni_clear, zenith, times, pressure=101325., Parameters ---------- ghi : array-like - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] ghi_clear : array-like - Global horizontal irradiance from clear sky model. [Wm⁻²] + Global horizontal irradiance from clear sky model. See + :term:`ghi_clear`. [Wm⁻²] .. versionchanged:: 0.11.2 Renamed from ``ghi_clearsky`` to ``ghi_clear``. dni_clear : array-like - Direct normal irradiance from clear sky model. [Wm⁻²] + Direct normal irradiance from clear sky model. See + :term:`dni_clear`. [Wm⁻²] .. versionchanged:: 0.11.2 Renamed from ``dni_clearsky`` to ``dni_clear``. zenith : array-like - True (not refraction-corrected) zenith angles in decimal - degrees. If Z is a vector it must be of the same size as all - other vector inputs. Z must be >=0 and <=180. + True (not refraction-corrected) zenith angles. + If ``zenith`` is a vector, it must be of the same size as all other + vector inputs. See :term`solar_zenith`. [°] times : DatetimeIndex pressure : float or array-like, default 101325.0 - The site pressure in Pascal. Pressure may be measured or an - average pressure may be calculated from site altitude. + Air pressure. See :term:`pressure`. [Pa] use_delta_kt_prime : bool, default True If True, indicates that the stability index delta_kt_prime is @@ -2246,7 +2221,7 @@ def dirindex(ghi, ghi_clear, dni_clear, zenith, times, pressure=101325., Returns ------- dni : array-like - The modeled direct normal irradiance. [Wm⁻²] + The modeled direct normal irradiance. See :term:`dni`. [Wm⁻²] Notes ----- @@ -2298,35 +2273,30 @@ def gti_dirint(poa_global, aoi, solar_zenith, solar_azimuth, times, Parameters ---------- poa_global : array-like - Plane of array global irradiance. [Wm⁻²] + Plane of array global irradiance. See :term:`poa_global`. [Wm⁻²] aoi : array-like Angle of incidence of solar rays with respect to the module - surface normal. + surface normal. See :term:`aoi`. [°] solar_zenith : array-like True (not refraction-corrected) solar zenith angles in decimal - degrees. + degrees. See :term:`solar_zenith`. [°] solar_azimuth : array-like - Solar azimuth angles in decimal degrees. + Solar azimuth angles. See :term:`solar_azimuth`. [°] times : DatetimeIndex Time indices for the input array-like data. surface_tilt : numeric - Surface tilt angles in decimal degrees. Tilt must be >=0 and - <=180. The tilt angle is defined as degrees from horizontal - (e.g. surface facing up = 0, surface facing horizon = 90). + Surface tilt angle, see :term:`surface_tilt`. [°] surface_azimuth : numeric - Surface azimuth angles in decimal degrees. surface_azimuth must - be >=0 and <=360. The Azimuth convention is defined as degrees - east of north (e.g. North = 0, South=180 East = 90, West = 270). + Surface azimuth angles, see :term:`surface_azimuth`. [°] pressure : numeric, default 101325.0 - The site pressure in Pascal. Pressure may be measured or an - average pressure may be calculated from site altitude. + Site air pressure. See :term:`pressure`. [Pa] use_delta_kt_prime : bool, default True If True, indicates that the stability index delta_kt_prime is @@ -2337,14 +2307,14 @@ def gti_dirint(poa_global, aoi, solar_zenith, solar_azimuth, times, input data must be Series. temp_dew : float, or array-like, optional - Surface dew point temperatures, in degrees C. Values of temp_dew + Surface dew point temperature. Values of ``temp_dew`` may be numeric or NaN. Any single time period point with a temp_dew=NaN does not have dew point improvements applied. If temp_dew is not provided, then dew point improvements are not - applied. + applied. See :term:`temp_dew`. [°C] albedo : numeric, default 0.25 - Ground surface albedo. [unitless] + Ground surface albedo. See :term:`albedo`. [unitless] model : String, default 'perez' Irradiance model. See :py:func:`get_sky_diffuse` for allowed values. @@ -2367,8 +2337,7 @@ def gti_dirint(poa_global, aoi, solar_zenith, solar_azimuth, times, * ``ghi``: the modeled global horizontal irradiance. [Wm⁻²] * ``dni``: the modeled direct normal irradiance. [Wm⁻²] - * ``dhi``: the modeled diffuse horizontal irradiance in - Wm⁻². + * ``dhi``: the modeled diffuse horizontal irradiance. [Wm⁻²] References ---------- @@ -2644,18 +2613,19 @@ def erbs(ghi, zenith, datetime_or_doy, min_cos_zenith=0.065, max_zenith=87): Parameters ---------- ghi: numeric - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] zenith: numeric - True (not refraction-corrected) zenith angles in decimal degrees. + True (not refraction-corrected) zenith angles. See + :term:`solar_zenith`. [°] datetime_or_doy : int, float, array, pd.DatetimeIndex Day of year or array of days of year e.g. pd.DatetimeIndex.dayofyear, or pd.DatetimeIndex. min_cos_zenith : numeric, default 0.065 Minimum value of cos(zenith) to allow when calculating global - clearness index `kt`. Equivalent to zenith = 86.273 degrees. + clearness index `kt`. Equivalent to zenith = 86.273°. max_zenith : numeric, default 87 Maximum value of zenith to allow in DNI calculation. DNI will be - set to 0 for times with zenith values greater than `max_zenith`. + set to 0 for times with zenith values greater than ``max_zenith``. [°] Returns ------- @@ -2663,10 +2633,9 @@ def erbs(ghi, zenith, datetime_or_doy, min_cos_zenith=0.065, max_zenith=87): Contains the following keys/columns: * ``dni``: the modeled direct normal irradiance. [Wm⁻²] - * ``dhi``: the modeled diffuse horizontal irradiance in - Wm⁻². + * ``dhi``: the modeled diffuse horizontal irradiance [Wm⁻²]. * ``kt``: Ratio of global to extraterrestrial irradiance - on a horizontal plane. + on a horizontal plane. [unitless] References ---------- @@ -2742,23 +2711,30 @@ def erbs_driesse(ghi, zenith, datetime_or_doy=None, dni_extra=None, Parameters ---------- ghi: numeric - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] + zenith: numeric - True (not refraction-corrected) zenith angles in decimal degrees. + True (not refraction-corrected) zenith angles. See + :term:`solar_zenith`. [°] + datetime_or_doy : int, float, array or pd.DatetimeIndex, optional Day of year or array of days of year e.g. pd.DatetimeIndex.dayofyear, or pd.DatetimeIndex. - Either datetime_or_doy or dni_extra must be provided. + Either ``datetime_or_doy`` or ``dni_extra`` must be provided. + dni_extra : numeric, optional Extraterrestrial normal irradiance. - dni_extra can be provided if available to avoid recalculating it + ``dni_extra`` can be provided if available to avoid recalculating it inside this function. In this case datetime_or_doy is not required. + See :term:`dni_extra`. [Wm⁻²] + min_cos_zenith : numeric, default 0.065 Minimum value of cos(zenith) to allow when calculating global - clearness index `kt`. Equivalent to zenith = 86.273 degrees. + clearness index Kt. Equivalent to zenith = 86.273°. + max_zenith : numeric, default 87 Maximum value of zenith to allow in DNI calculation. DNI will be - set to 0 for times with zenith values greater than `max_zenith`. + set to 0 for times with zenith values greater than ``max_zenith``. [°] Returns ------- @@ -2766,15 +2742,14 @@ def erbs_driesse(ghi, zenith, datetime_or_doy=None, dni_extra=None, Contains the following keys/columns: * ``dni``: the modeled direct normal irradiance. [Wm⁻²] - * ``dhi``: the modeled diffuse horizontal irradiance in - Wm⁻². + * ``dhi``: the modeled diffuse horizontal irradiance [Wm⁻²]. * ``kt``: Ratio of global to extraterrestrial irradiance - on a horizontal plane. + on a horizontal plane. [unitless] Raises ------ ValueError - If neither datetime_or_doy nor dni_extra is provided. + If neither ``datetime_or_doy`` nor ``dni_extra`` is provided. Notes ----- @@ -2863,20 +2838,30 @@ def orgill_hollands(ghi, zenith, datetime_or_doy, dni_extra=None, Parameters ---------- ghi: numeric - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] + zenith: numeric - True (not refraction-corrected) zenith angles in decimal degrees. - datetime_or_doy : int, float, array, pd.DatetimeIndex + True (not refraction-corrected) zenith angles. See + :term:`solar_zenith`. [°] + + datetime_or_doy : int, float, array or pd.DatetimeIndex, optional Day of year or array of days of year e.g. pd.DatetimeIndex.dayofyear, or pd.DatetimeIndex. + Either ``datetime_or_doy`` or ``dni_extra`` must be provided. + dni_extra : numeric, optional - Extraterrestrial direct normal irradiance. [W/m2] + Extraterrestrial normal irradiance. + ``dni_extra`` can be provided if available to avoid recalculating it + inside this function. In this case datetime_or_doy is not required. + See :term:`dni_extra`. [Wm⁻²] + min_cos_zenith : numeric, default 0.065 Minimum value of cos(zenith) to allow when calculating global - clearness index `kt`. Equivalent to zenith = 86.273 degrees. + clearness index Kt. Equivalent to zenith = 86.273°. + max_zenith : numeric, default 87 Maximum value of zenith to allow in DNI calculation. DNI will be - set to 0 for times with zenith values greater than `max_zenith`. + set to 0 for times with zenith values greater than ``max_zenith``. [°] Returns ------- @@ -2884,10 +2869,9 @@ def orgill_hollands(ghi, zenith, datetime_or_doy, dni_extra=None, Contains the following keys/columns: * ``dni``: the modeled direct normal irradiance. [Wm⁻²] - * ``dhi``: the modeled diffuse horizontal irradiance in - Wm⁻². + * ``dhi``: the modeled diffuse horizontal irradiance [Wm⁻²]. * ``kt``: Ratio of global to extraterrestrial irradiance - on a horizontal plane. + on a horizontal plane. [unitless] References ---------- @@ -2956,22 +2940,29 @@ def boland(ghi, solar_zenith, datetime_or_doy, a_coeff=8.645, b_coeff=0.613, Parameters ---------- ghi: numeric - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] + solar_zenith: numeric - True (not refraction-corrected) zenith angles in decimal degrees. - datetime_or_doy : numeric, pandas.DatetimeIndex - Day of year or array of days of year e.g. - pd.DatetimeIndex.dayofyear, or pd.DatetimeIndex. + True (not refraction-corrected) zenith angles. See + :term:`solar_zenith`. [°] + + datetime_or_doy : numeric or pd.DatetimeIndex. + Day of year or array of days of year e.g. pd.DatetimeIndex.dayofyear, + or pd.DatetimeIndex. + a_coeff : float, default 8.645 Logistic curve fit coefficient. + b_coeff : float, default 0.613 Logistic curve fit coefficient. + min_cos_zenith : numeric, default 0.065 Minimum value of cos(zenith) to allow when calculating global - clearness index :math:`k_t`. Equivalent to zenith = 86.273 degrees. + clearness index `kt`. Equivalent to zenith = 86.273°. + max_zenith : numeric, default 87 Maximum value of zenith to allow in DNI calculation. DNI will be - set to 0 for times with zenith values greater than `max_zenith`. + set to 0 for times with zenith values greater than ``max_zenith``. [°] Returns ------- @@ -2979,10 +2970,9 @@ def boland(ghi, solar_zenith, datetime_or_doy, a_coeff=8.645, b_coeff=0.613, Contains the following keys/columns: * ``dni``: the modeled direct normal irradiance. [Wm⁻²] - * ``dhi``: the modeled diffuse horizontal irradiance in - Wm⁻². + * ``dhi``: the modeled diffuse horizontal irradiance. [Wm⁻²] * ``kt``: Ratio of global to extraterrestrial irradiance - on a horizontal plane. + on a horizontal plane. [unitless] References ---------- @@ -3052,24 +3042,24 @@ def campbell_norman(zenith, transmittance, pressure=101325.0, Parameters ---------- zenith: pd.Series - True (not refraction-corrected) zenith angles in decimal - degrees. If Z is a vector it must be of the same size as all - other vector inputs. Z must be >=0 and <=180. + True (not refraction-corrected) zenith angles. If ``zenith`` is a + vector, it must be of the same size as all other vector inputs. [°] transmittance: float Atmospheric transmittance between 0 and 1. pressure: float, default 101325.0 - Air pressure + Air pressure. See :term:`pressure`. [Pa] dni_extra: float, default 1367.0 Direct irradiance incident at the top of the atmosphere. + See :term:`dni_extra`. [Wm⁻²] Returns ------- irradiance: DataFrame Modeled direct normal irradiance, direct horizontal irradiance, - and global horizontal irradiance in Wm⁻² + and global horizontal irradiance. [Wm⁻²] References ---------- @@ -3111,7 +3101,7 @@ def _liujordan(zenith, transmittance, airmass, dni_extra=1367.0): zenith: pd.Series True (not refraction-corrected) zenith angles in decimal degrees. If Z is a vector it must be of the same size as all - other vector inputs. Z must be >=0 and <=180. + other vector inputs. [°] transmittance: float Atmospheric transmittance between 0 and 1. @@ -3658,11 +3648,6 @@ def _get_dirint_coeffs(): return coeffs[1:, 1:, :, :] -@renamed_kwarg_warning( - since='0.11.2', - old_param_name='clearsky_dni', - new_param_name='dni_clear', - removal="0.14.0") def dni(ghi, dhi, zenith, dni_clear=None, clearsky_tolerance=1.1, zenith_threshold_for_zero_dni=88.0, zenith_threshold_for_clearsky_limit=80.0): @@ -3678,17 +3663,17 @@ def dni(ghi, dhi, zenith, dni_clear=None, clearsky_tolerance=1.1, Parameters ---------- ghi : Series - Global horizontal irradiance. + Global horizontal irradiance. See :term:`ghi`. [Wm⁻²] dhi : Series - Diffuse horizontal irradiance. + Diffuse horizontal irradiance. See :term:`dhi`. [Wm⁻²] zenith : Series - True (not refraction-corrected) zenith angles in decimal - degrees. Angles must be >=0 and <=180. + True (not refraction-corrected) zenith angles. + See :term:`solar_zenith`. [°] dni_clear : Series, optional - Clearsky direct normal irradiance. [Wm⁻²] + Clearsky direct normal irradiance. See :term:`dni_clear`. [Wm⁻²] .. versionchanged:: 0.11.2 Renamed from ``clearsky_dni`` to ``dni_clear``. @@ -3712,7 +3697,7 @@ def dni(ghi, dhi, zenith, dni_clear=None, clearsky_tolerance=1.1, Returns ------- dni : Series - The modeled direct normal irradiance. + The modeled direct normal irradiance. See :Term:`dni`. [Wm⁻²] """ # calculate DNI @@ -3757,29 +3742,37 @@ def complete_irradiance(solar_zenith, Parameters ---------- - solar_zenith : Series - Zenith angles in decimal degrees, with datetime index. - Angles must be >=0 and <=180. Must have the same datetime index - as ghi, dhi, and dni series, when available. + solar_zenith : series + Solar zenith angle, with datetime index. + Must have the same datetime index as ``ghi``, ``dhi``, and ``dni``, + when available. See :term:`solar_zenith`. [°] + ghi : Series, optional - Pandas series of dni data, with datetime index. Must have the same - datetime index as dni, dhi, and zenith series, when available. + Pandas series of dni data [Wm⁻²], with datetime index. Must have the + same datetime index as ``dni``, ``dhi``, and ``zenith``, when + available. See :term:`ghi`. [Wm⁻²] + dhi : Series, optional - Pandas series of dni data, with datetime index. Must have the same - datetime index as ghi, dni, and zenith series, when available. + Diffuse horizontal irradiance, with datetime index. Must have the + same datetime index as ``ghi``, ``dhi``, and ``solar_zenith``, when + available. See :term:`dhi`. [Wm⁻²] + dni : Series, optional - Pandas series of dni data, with datetime index. Must have the same - datetime index as ghi, dhi, and zenith series, when available. + Pandas series of dni data [Wm⁻²], with datetime index. Must have the + same datetime index as ``ghi``, ``dhi``, and ``zenith``, when + available. See :term:`dni`. [Wm⁻²] + dni_clear : Series, optional Pandas series of clearsky dni data [Wm⁻²]. Must have the same datetime index as ghi, dhi, dni, and zenith series, when available. See - :py:func:`dni` for details. + :py:func:`dni` for details. [Wm⁻²] Returns ------- component_sum_df : Dataframe Pandas series of 'ghi', 'dhi', and 'dni' columns with datetime index """ + if ghi is not None and dhi is not None and dni is None: dni = pvlib.irradiance.dni(ghi, dhi, solar_zenith, dni_clear=dni_clear, @@ -3808,11 +3801,11 @@ def louche(ghi, solar_zenith, datetime_or_doy, max_zenith=90): Parameters ---------- ghi : numeric - Global horizontal irradiance. [Wm⁻²] + Global horizontal irradiance, see :term:`ghi`. [Wm⁻²] solar_zenith : numeric - True (not refraction-corrected) zenith angles in decimal - degrees. Angles must be >=0 and <=90. + True (not refraction-corrected) zenith angle. + See :term:`solar_zenith`. [°] datetime_or_doy : numeric, pandas.DatetimeIndex Day of year or array of days of year e.g. @@ -3823,11 +3816,12 @@ def louche(ghi, solar_zenith, datetime_or_doy, max_zenith=90): data: OrderedDict or DataFrame Contains the following keys/columns: - * ``dni``: the modeled direct normal irradiance. [Wm⁻²] - * ``dhi``: the modeled diffuse horizontal irradiance in - Wm⁻². - * ``kt``: Ratio of global to extraterrestrial irradiance - on a horizontal plane. + * ``dni``: the modeled direct normal irradiance, see :term:`dni`. + [Wm⁻²] + * ``dhi``: the modeled diffuse horizontal irradiance, see :term:`dhi`. + [Wm⁻²] + * ``kt``: Clearness index. Ratio of global to + extraterrestrial irradiance on a horizontal plane. [unitless] References ------- @@ -3872,22 +3866,22 @@ def diffuse_par_spitters(daily_solar_zenith, global_diffuse_fraction): .. note:: The diffuse fraction is defined as the ratio of - diffuse to global daily insolation, in J m⁻² day⁻¹ or equivalent. + diffuse to global daily insolation, in Jm⁻² day⁻¹ or equivalent. Parameters ---------- daily_solar_zenith : numeric - Average daily solar zenith angle. In degrees [°]. + Average daily solar zenith angle. See :term:`solar_zenith`. [°] global_diffuse_fraction : numeric - Fraction of daily global broadband insolation that is diffuse. - Unitless [0, 1]. + Fraction of daily global broadband insolation that is diffuse, between + 0 and 1. [unitless] Returns ------- par_diffuse_fraction : numeric Fraction of daily photosynthetically active radiation (PAR) that is - diffuse. Unitless [0, 1]. + diffuse, between 0 and 1. [unitless] Notes ----- diff --git a/pvlib/ivtools/sdm/__init__.py b/pvlib/ivtools/sdm/__init__.py index 8535f1f5e6..2bb8b9876b 100644 --- a/pvlib/ivtools/sdm/__init__.py +++ b/pvlib/ivtools/sdm/__init__.py @@ -10,7 +10,8 @@ from pvlib.ivtools.sdm.desoto import ( # noqa: F401 fit_desoto, - fit_desoto_sandia + fit_desoto_batzelis, + fit_desoto_sandia, ) from pvlib.ivtools.sdm.pvsyst import ( # noqa: F401 diff --git a/pvlib/ivtools/sdm/_fit_desoto_pvsyst_sandia.py b/pvlib/ivtools/sdm/_fit_desoto_pvsyst_sandia.py index 8002d105a9..b0b7c6fa3e 100644 --- a/pvlib/ivtools/sdm/_fit_desoto_pvsyst_sandia.py +++ b/pvlib/ivtools/sdm/_fit_desoto_pvsyst_sandia.py @@ -5,10 +5,9 @@ import numpy as np from scipy import optimize -from scipy.special import lambertw from pvlib.pvsystem import singlediode, v_from_i -from pvlib.ivtools.utils import rectify_iv_curve, _numdiff +from pvlib.ivtools.utils import rectify_iv_curve, _numdiff, _lambertw_pvlib from pvlib.pvsystem import _pvsyst_Rsh @@ -535,7 +534,7 @@ def _calc_theta_phi_exact(vmp, imp, iph, io, rs, rsh, nnsvth): nnsvth == 0, np.nan, rsh * io / nnsvth * np.exp(rsh * (iph + io - imp) / nnsvth)) - phi = np.where(argw > 0, lambertw(argw).real, np.nan) + phi = np.where(argw > 0, _lambertw_pvlib(argw), np.nan) # NaN where argw overflows. Switch to log space to evaluate u = np.isinf(argw) @@ -561,7 +560,7 @@ def _calc_theta_phi_exact(vmp, imp, iph, io, rs, rsh, nnsvth): np.nan, rsh / (rsh + rs) * rs * io / nnsvth * np.exp( rsh / (rsh + rs) * (rs * (iph + io) + vmp) / nnsvth)) - theta = np.where(argw > 0, lambertw(argw).real, np.nan) + theta = np.where(argw > 0, _lambertw_pvlib(argw), np.nan) # NaN where argw overflows. Switch to log space to evaluate u = np.isinf(argw) diff --git a/pvlib/ivtools/sdm/cec.py b/pvlib/ivtools/sdm/cec.py index 9d9bfd8344..881e51ebdb 100644 --- a/pvlib/ivtools/sdm/cec.py +++ b/pvlib/ivtools/sdm/cec.py @@ -27,7 +27,7 @@ def fit_cec_sam(celltype, v_mp, i_mp, v_oc, i_sc, alpha_sc, beta_voc, cells_in_series : int Number of cells in series temp_ref : float, default 25 - Reference temperature condition [C] + Reference temperature condition, [°C] Returns ------- diff --git a/pvlib/ivtools/sdm/desoto.py b/pvlib/ivtools/sdm/desoto.py index 05580c241d..a97db7d702 100644 --- a/pvlib/ivtools/sdm/desoto.py +++ b/pvlib/ivtools/sdm/desoto.py @@ -3,7 +3,7 @@ from scipy import constants from scipy import optimize -from pvlib.ivtools.utils import rectify_iv_curve +from pvlib.ivtools.utils import rectify_iv_curve, _lambertw_pvlib from pvlib.ivtools.sde import _fit_sandia_cocontent from pvlib.ivtools.sdm._fit_desoto_pvsyst_sandia import ( @@ -58,7 +58,7 @@ def fit_desoto(v_mp, i_mp, v_oc, i_sc, alpha_sc, beta_voc, cells_in_series, dEgdT: float, default -0.0002677 - value for silicon Variation of bandgap according to temperature. [1/K] temp_ref: float, default 25 - Reference temperature condition. [C] + Reference temperature condition. [°C] irrad_ref: float, default 1000 Reference irradiance condition. [Wm⁻²] init_guess: dict, optional @@ -93,7 +93,7 @@ def fit_desoto(v_mp, i_mp, v_oc, i_sc, alpha_sc, beta_voc, cells_in_series, irrad_ref: float Reference irradiance condition. [Wm⁻²] temp_ref: float - Reference temperature condition. [C] + Reference temperature condition. [°C] scipy.optimize.OptimizeResult Optimization result of scipy.optimize.root(). @@ -110,7 +110,7 @@ def fit_desoto(v_mp, i_mp, v_oc, i_sc, alpha_sc, beta_voc, cells_in_series, """ # Constants - k = constants.value('Boltzmann constant in eV/K') # in eV/K + k = constants.value('Boltzmann constant in eV/K') Tref = temp_ref + 273.15 # [K] # initial guesses of variables for computing convergence: @@ -234,7 +234,7 @@ def fit_desoto_sandia(ivcurves, specs, const=None, maxiter=5, eps1=1.e-3): effective irradiance for each IV curve, i.e., POA broadband irradiance adjusted by solar spectrum modifier [W / m^2] tc : array - cell temperature for each IV curve [C] + cell temperature for each IV curve. [°C] i_sc : array short circuit current for each IV curve [A] v_oc : array @@ -254,9 +254,9 @@ def fit_desoto_sandia(ivcurves, specs, const=None, maxiter=5, eps1=1.e-3): const : dict E0 : float - effective irradiance at STC, default 1000 [W/m^2] + effective irradiance at STC, default 1000 [Wm⁻²] T0 : float - cell temperature at STC, default 25 [C] + cell temperature at STC, default 25°C. [°C] k : float Boltzmann's constant [J/K] q : float @@ -399,3 +399,74 @@ def _fit_desoto_sandia_diode(ee, voc, vth, tc, specs, const): new_x = sm.add_constant(x) res = sm.RLM(y, new_x).fit() return np.array(res.params)[1] + + +def fit_desoto_batzelis(v_mp, i_mp, v_oc, i_sc, alpha_sc, beta_voc): + """ + Determine De Soto single-diode model parameters from datasheet values + using Batzelis's method. + + This method is described in Section II.C of [1]_ and fully documented + in [2]_. + + Parameters + ---------- + v_mp : float + Maximum power point voltage at STC. [V] + i_mp : float + Maximum power point current at STC. [A] + v_oc : float + Open-circuit voltage at STC. [V] + i_sc : float + Short-circuit current at STC. [A] + alpha_sc : float + Short-circuit current temperature coefficient at STC. [A/K] + beta_voc : float + Open-circuit voltage temperature coefficient at STC. [V/K] + + Returns + ------- + dict + The returned dict contains the keys: + + * ``alpha_sc`` [A/K] + * ``a_ref`` [V] + * ``I_L_ref`` [A] + * ``I_o_ref`` [A] + * ``R_sh_ref`` [Ohm] + * ``R_s`` [Ohm] + + References + ---------- + .. [1] E. I. Batzelis, "Simple PV Performance Equations Theoretically Well + Founded on the Single-Diode Model," Journal of Photovoltaics vol. 7, + no. 5, pp. 1400-1409, Sep 2017, :doi:`10.1109/JPHOTOV.2017.2711431` + .. [2] E. I. Batzelis and S. A. Papathanassiou, "A method for the + analytical extraction of the single-diode PV model parameters," + IEEE Trans. Sustain. Energy, vol. 7, no. 2, pp. 504-512, Apr 2016. + :doi:`10.1109/TSTE.2015.2503435` + """ + # convert temp coeffs from A/K and V/K to 1/K + alpha_sc = alpha_sc / i_sc + beta_voc = beta_voc / v_oc + + # Equation numbers refer to [1] + t0 = 298.15 # K + del0 = (1 - beta_voc * t0) / (50.1 - alpha_sc * t0) # Eq 9 + w0 = _lambertw_pvlib(np.exp(1/del0 + 1)) + + # Eqs 11-15 + a0 = del0 * v_oc + Rs0 = (a0 * (w0 - 1) - v_mp) / i_mp + Rsh0 = a0 * (w0 - 1) / (i_sc * (1 - 1/w0) - i_mp) + Iph0 = (1 + Rs0 / Rsh0) * i_sc + Isat0 = Iph0 * np.exp(-1/del0) + + return { + 'alpha_sc': alpha_sc * i_sc, # convert 1/K to A/K + 'a_ref': a0, + 'I_L_ref': Iph0, + 'I_o_ref': Isat0, + 'R_sh_ref': Rsh0, + 'R_s': Rs0, + } diff --git a/pvlib/ivtools/sdm/pvsyst.py b/pvlib/ivtools/sdm/pvsyst.py index 7e1f8c71ab..343d278fe9 100644 --- a/pvlib/ivtools/sdm/pvsyst.py +++ b/pvlib/ivtools/sdm/pvsyst.py @@ -38,7 +38,7 @@ def fit_pvsyst_sandia(ivcurves, specs, const=None, maxiter=5, eps1=1.e-3): effective irradiance for each IV curve, i.e., POA broadband irradiance adjusted by solar spectrum modifier [W / m^2] tc : array - cell temperature for each IV curve [C] + cell temperature for each IV curve [°C] i_sc : array short circuit current for each IV curve [A] v_oc : array @@ -56,9 +56,9 @@ def fit_pvsyst_sandia(ivcurves, specs, const=None, maxiter=5, eps1=1.e-3): const : dict E0 : float - effective irradiance at STC, default 1000 [W/m^2] + effective irradiance at STC, default 1000 [Wm⁻²] T0 : float - cell temperature at STC, default 25 [C] + cell temperature at STC, default 25°C. [°C] k : float Boltzmann's constant [J/K] q : float @@ -272,10 +272,10 @@ def pvsyst_temperature_coeff(alpha_sc, gamma_ref, mu_gamma, I_L_ref, I_o_ref, Default of 1.121 eV is for crystalline silicon. Must be positive. [eV] irrad_ref : float, default 1000 - Reference irradiance. [W/m^2]. + Reference irradiance. [Wm⁻²]. temp_ref : float, default 25 - Reference cell temperature. [C] + Reference cell temperature. [°C] Returns @@ -327,7 +327,7 @@ def fit_pvsyst_iec61853_sandia_2025(effective_irradiance, temp_cell, effective_irradiance : array Effective irradiance for each test condition [W/m²] temp_cell : array - Cell temperature for each test condition [C] + Cell temperature for each test condition. [°C] i_sc : array Short circuit current for each test condition [A] v_oc : array @@ -366,7 +366,7 @@ def fit_pvsyst_iec61853_sandia_2025(effective_irradiance, temp_cell, temperature_tolerance : float, default 1 Tolerance for temperature variation around the STC value. The default value corresponds to a +/- 1 degree interval around the STC - value of 25 degrees. [C] + value of 25°C. [°C] Returns ------- diff --git a/pvlib/ivtools/utils.py b/pvlib/ivtools/utils.py index cde50655dc..395678d49c 100644 --- a/pvlib/ivtools/utils.py +++ b/pvlib/ivtools/utils.py @@ -470,7 +470,7 @@ def astm_e1036(v, i, imax_limits=(0.75, 1.15), vmax_limits=(0.75, 1.15), ASTM E1036-15(2019), :doi:`10.1520/E1036-15R19` ''' - # Adapted from https://github.com/NREL/iv_params + # Adapted from https://github.com/NatLabRockies/iv_params # Copyright (c) 2022, Alliance for Sustainable Energy, LLC # All rights reserved. @@ -544,3 +544,63 @@ def astm_e1036(v, i, imax_limits=(0.75, 1.15), vmax_limits=(0.75, 1.15), result['mp_fit'] = mp_fit return result + + +def _log_lambertw(logx): + r'''Computes W(x) starting from log(x). + + Parameters + ---------- + logx : numeric + + Returns + ------- + numeric + Lambert's W(x) + + ''' + # handles overflow cases, but results in nan for x <= 1 + w = logx - np.log(logx) # initial guess, w = log(x) - log(log(x)) + + for _ in range(0, 3): + # Newton's. Halley's is not substantially faster or more accurate + # because f''(w) = -1 / (w**2) is small for large w + w = w * (1. - np.log(w) + logx) / (1. + w) + return w + + +def _lambertw_pvlib(x): + r'''Lambert's W function principal branch, :math:`W_0(x)`, for + :math:`x>=0`. + + Parameters + ---------- + x : float or np.array + Must be real numbers. + + Returns + ------- + float or np.array + + ''' + localx = np.asarray(x, float) + w = np.full_like(localx, np.nan) + small = localx <= 100 + # for large x, solve 0 = f(w) = w + log(w) - log(x) using Newton's + # w will contain nan for these numbers due to log(w) = log(log(x)) + w[~small] = _log_lambertw(np.log(localx[~small])) + + # for small x, solve 0 = g(w) = w * exp(w) - x using Halley's method + if np.any(small): + z = localx[small] + temp = np.log1p(localx[small]) + g = temp - np.log1p(temp) + for _ in range(0, 3): + expg = np.exp(g) + g_expg_z = g*expg - z + g_p1 = g + 1 + g = g - g_expg_z * g_p1 / \ + (expg * g_p1**2 - 0.5*(g + 2)*g_expg_z) + w[small] = g + + return w[0] if w.shape == 1 else w diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 09e4434e84..45374bd6fa 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -1713,6 +1713,75 @@ def run_model_from_poa(self, data): of Arrays in the PVSystem. ValueError If the DataFrames in `data` have different indexes. + Examples + -------- + Single-array system: + + >>> import pandas as pd + >>> from pvlib.pvsystem import PVSystem, Array, FixedMount + >>> from pvlib.location import Location + >>> from pvlib.modelchain import ModelChain + >>> location = Location(35, -110) + >>> mount = FixedMount(surface_tilt=30, surface_azimuth=180) + >>> array = Array( + ... mount=mount, + ... module_parameters={'pdc0': 300, 'gamma_pdc': -0.004}, + ... temperature_model_parameters={'u0': 25.0, 'u1': 6.84} + ... ) + >>> system = PVSystem( + ... arrays=[array], + ... inverter_parameters={'pdc0': 300} + ... ) + >>> mc = ModelChain( + ... system, location, + ... dc_model="pvwatts", ac_model="pvwatts", + ... aoi_model="no_loss", spectral_model="no_loss", + ... temperature_model="faiman" + ... ) + >>> poa = pd.DataFrame({ + ... 'poa_global': [900, 850], + ... 'poa_direct': [600, 560], + ... 'poa_diffuse': [300, 290],}, + ... index=pd.date_range("2021-06-01", periods=2, freq="h")) + >>> _ = mc.run_model_from_poa(poa) + + Multi-array system: + + >>> mount1 = FixedMount(surface_tilt=30, surface_azimuth=180) + >>> mount2 = FixedMount(surface_tilt=10, surface_azimuth=90) + >>> array1 = Array( + ... mount=mount1, + ... module_parameters={'pdc0': 300, 'gamma_pdc': -0.004}, + ... temperature_model_parameters={'u0': 25.0, 'u1': 6.84} + ... ) + >>> array2 = Array( + ... mount=mount2, + ... module_parameters={'pdc0': 200, 'gamma_pdc': -0.004}, + ... temperature_model_parameters={'u0': 25.0, 'u1': 6.84} + ... ) + >>> system = PVSystem( + ... arrays=[array1, array2], + ... inverter_parameters={'pdc0': 500} + ... ) + >>> mc = ModelChain( + ... system, location, + ... dc_model="pvwatts", ac_model="pvwatts", + ... aoi_model="no_loss", spectral_model="no_loss", + ... temperature_model="faiman" + ... ) + >>> poa1 = pd.DataFrame({ + ... 'poa_global': [900, 880], + ... 'poa_direct': [600, 580], + ... 'poa_diffuse': [300, 300],}, + ... index=pd.date_range("2021-06-01", periods=2, freq="h")) + >>> poa2 = pd.DataFrame({ + ... 'poa_global': [700, 720], + ... 'poa_direct': [400, 420], + ... 'poa_diffuse': [300, 300],}, + ... index=poa1.index) + >>> _ = mc.run_model_from_poa( + ... [poa1, poa2] + ... ) Notes ----- @@ -1798,6 +1867,75 @@ def run_model_from_effective_irradiance(self, data): of Arrays in the PVSystem. ValueError If the DataFrames in `data` have different indexes. + Examples + -------- + Single-array system: + + >>> import pandas as pd + >>> from pvlib.pvsystem import PVSystem, Array, FixedMount + >>> from pvlib.location import Location + >>> from pvlib.modelchain import ModelChain + >>> location = Location(35, -110) + >>> mount = FixedMount(surface_tilt=30, surface_azimuth=180) + >>> array = Array( + ... mount=mount, + ... module_parameters={'pdc0': 300, 'gamma_pdc': -0.004}, + ... temperature_model_parameters={'u0': 25.0, 'u1': 6.84} + ... ) + >>> system = PVSystem( + ... arrays=[array], + ... inverter_parameters={'pdc0': 300} + ... ) + >>> mc = ModelChain( + ... system, location, + ... dc_model="pvwatts", ac_model="pvwatts", + ... aoi_model="no_loss", spectral_model="no_loss", + ... temperature_model="faiman" + ... ) + >>> eff = pd.DataFrame({ + ... 'effective_irradiance': [900, 920], + ... 'temp_air': [25, 24], + ... 'wind_speed': [2.0, 1.5],}, + ... index=pd.date_range("2021-06-01", periods=2, freq="h")) + >>> _ = mc.run_model_from_effective_irradiance(eff) + + Multi-array system: + + >>> mount1 = FixedMount(surface_tilt=30, surface_azimuth=180) + >>> mount2 = FixedMount(surface_tilt=10, surface_azimuth=90) + >>> array1 = Array( + ... mount=mount1, + ... module_parameters={'pdc0': 300, 'gamma_pdc': -0.004}, + ... temperature_model_parameters={'u0': 25.0, 'u1': 6.84} + ... ) + >>> array2 = Array( + ... mount=mount2, + ... module_parameters={'pdc0': 200, 'gamma_pdc': -0.004}, + ... temperature_model_parameters={'u0': 25.0, 'u1': 6.84} + ... ) + >>> system = PVSystem( + ... arrays=[array1, array2], + ... inverter_parameters={'pdc0': 500} + ... ) + >>> mc = ModelChain( + ... system, location, + ... dc_model="pvwatts", ac_model="pvwatts", + ... aoi_model="no_loss", spectral_model="no_loss", + ... temperature_model="faiman" + ... ) + >>> eff1 = pd.DataFrame({ + ... 'effective_irradiance': [900, 920], + ... 'temp_air': [25, 24], + ... 'wind_speed': [2.0, 1.5],}, + ... index=pd.date_range("2021-06-01", periods=2, freq="h")) + >>> eff2 = pd.DataFrame({ + ... 'effective_irradiance': [600, 630], + ... 'temp_air': [26, 25], + ... 'wind_speed': [1.8, 1.2],}, + ... index=eff1.index) + >>> _ = mc.run_model_from_effective_irradiance( + ... [eff1, eff2] + ... ) Notes ----- diff --git a/pvlib/pvarray.py b/pvlib/pvarray.py index ab7530f3e5..9fd5d0efd2 100644 --- a/pvlib/pvarray.py +++ b/pvlib/pvarray.py @@ -9,8 +9,10 @@ """ import numpy as np +import pandas as pd from scipy.optimize import curve_fit from scipy.special import exp10 +from pvlib.ivtools.utils import _lambertw_pvlib def pvefficiency_adr(effective_irradiance, temp_cell, @@ -37,7 +39,7 @@ def pvefficiency_adr(effective_irradiance, temp_cell, the reference conditions. [unitless] k_d : numeric, negative - “Dark irradiance” or diode coefficient which influences the voltage + "Dark irradiance" or diode coefficient which influences the voltage increase with irradiance. [unitless] tc_d : numeric @@ -225,24 +227,55 @@ def adr_wrapper(xdata, *params): return popt -def _infer_k_huld(cell_type, pdc0): +def _infer_k_huld(cell_type, pdc0, k_version): + r""" + Get the EU JRC updated coefficients for the Huld model. + + Parameters + ---------- + cell_type : str + Must be one of 'csi', 'cis', or 'cdte' + pdc0 : numeric + Power of the modules at reference conditions [W] + k_version : str + Either 'pvgis5' or 'pvgis6'. + + Returns + ------- + tuple + The six coefficients (k1-k6) for the Huld model, scaled by pdc0 + """ # from PVGIS documentation, "PVGIS data sources & calculation methods", # Section 5.2.3, accessed 12/22/2023 # The parameters in PVGIS' documentation are for a version of Huld's # equation that has factored Pdc0 out of the polynomial: # P = G/1000 * Pdc0 * (1 + k1 log(Geff) + ...) so these parameters are # multiplied by pdc0 - huld_params = {'csi': (-0.017237, -0.040465, -0.004702, 0.000149, - 0.000170, 0.000005), - 'cis': (-0.005554, -0.038724, -0.003723, -0.000905, - -0.001256, 0.000001), - 'cdte': (-0.046689, -0.072844, -0.002262, 0.000276, - 0.000159, -0.000006)} + if k_version.lower() == 'pvgis5': + # coefficients from PVGIS webpage + huld_params = {'csi': (-0.017237, -0.040465, -0.004702, 0.000149, + 0.000170, 0.000005), + 'cis': (-0.005554, -0.038724, -0.003723, -0.000905, + -0.001256, 0.000001), + 'cdte': (-0.046689, -0.072844, -0.002262, 0.000276, + 0.000159, -0.000006)} + elif k_version.lower() == 'pvgis6': + # Coefficients from EU JRC paper + huld_params = {'csi': (-0.0067560, -0.016444, -0.003015, -0.000045, + -0.000043, 0.0), + 'cis': (-0.011001, -0.029734, -0.002887, 0.000217, + -0.000163, 0.0), + 'cdte': (-0.020644, -0.035136, -0.003406, 0.000073, + -0.000141, 0.000002)} + else: + raise ValueError(f'Invalid k_version={k_version}: must be either ' + '"pvgis5" or "pvgis6"') k = tuple([x*pdc0 for x in huld_params[cell_type.lower()]]) return k -def huld(effective_irradiance, temp_mod, pdc0, k=None, cell_type=None): +def huld(effective_irradiance, temp_mod, pdc0, k=None, cell_type=None, + k_version='pvgis5'): r""" Power (DC) using the Huld model. @@ -274,6 +307,11 @@ def huld(effective_irradiance, temp_mod, pdc0, k=None, cell_type=None): cell_type : str, optional If provided, must be one of ``'cSi'``, ``'CIS'``, or ``'CdTe'``. Used to look up default values for ``k`` if ``k`` is not specified. + k_version : str, optional + Either ``'pvgis5'`` (default) or ``'pvgis6'``. Selects values + for ``k`` if ``k`` is not specified. If ``'pvgis5'``, values are + from PVGIS documentation and are labeled in [2]_ as "current". + If ``'pvgis6'`` values are from [2]_ labeled as "updated". Returns ------- @@ -328,14 +366,19 @@ def huld(effective_irradiance, temp_mod, pdc0, k=None, cell_type=None): References ---------- - .. [1] T. Huld, G. Friesen, A. Skoczek, R. Kenny, T. Sample, M. Field, - E. Dunlop. A power-rating model for crystalline silicon PV modules. - Solar Energy Materials and Solar Cells 95, (2011), pp. 3359-3369. - :doi:`10.1016/j.solmat.2011.07.026`. + .. [1] T. Huld, G. Friesen, A. Skoczek, R. Kenny, T. Sample, M. Field, and + E. Dunlop, "A power-rating model for crystalline silicon PV + modules," Solar Energy Materials and Solar Cells 95, (2011), + pp. 3359-3369. :doi:`10.1016/j.solmat.2011.07.026`. + .. [2] A. Chatzipanagi, N. Taylor, I. Suarez, A. Martinez, T. Lyubenova, + and E. Dunlop, "An Updated Simplified Energy Yield Model for Recent + Photovoltaic Module Technologies," + Progress in Photovoltaics: Research and Applications 33, + no. 8 (2025): 905–917, :doi:`10.1002/pip.3926`. """ if k is None: if cell_type is not None: - k = _infer_k_huld(cell_type, pdc0) + k = _infer_k_huld(cell_type, pdc0, k_version) else: raise ValueError('Either k or cell_type must be specified') @@ -346,7 +389,138 @@ def huld(effective_irradiance, temp_mod, pdc0, k=None, cell_type=None): logGprime = np.log(gprime, out=np.zeros_like(gprime), where=np.array(gprime > 0)) # Eq. 1 in [1] - pdc = gprime * (pdc0 + k[0] * logGprime + k[1] * logGprime**2 + - k[2] * tprime + k[3] * tprime * logGprime + - k[4] * tprime * logGprime**2 + k[5] * tprime**2) + pdc = gprime * ( + pdc0 + k[0] * logGprime + k[1] * logGprime**2 + + k[2] * tprime + k[3] * tprime * logGprime + + k[4] * tprime * logGprime**2 + + k[5] * tprime**2 + ) return pdc + + +def batzelis(effective_irradiance, temp_cell, + v_mp, i_mp, v_oc, i_sc, alpha_sc, beta_voc): + """ + Compute maximum power point, open circuit, and short circuit + values using Batzelis's method. + + Batzelis's method (described in Section III of [1]_) is a fast method + of computing the maximum power current and voltage. The calculations + are rooted in the De Soto single-diode model, but require only typical + datasheet information. + + Parameters + ---------- + effective_irradiance : numeric, non-negative + Effective irradiance incident on the PV module. [Wm⁻²] + temp_cell : numeric + PV module operating temperature. [°C] + v_mp : float + Maximum power point voltage at STC. [V] + i_mp : float + Maximum power point current at STC. [A] + v_oc : float + Open-circuit voltage at STC. [V] + i_sc : float + Short-circuit current at STC. [A] + alpha_sc : float + Short-circuit current temperature coefficient at STC. [A/K] + beta_voc : float + Open-circuit voltage temperature coefficient at STC. [V/K] + + Returns + ------- + dict + The returned dict-like object contains the keys/columns: + + * ``p_mp`` - power at maximum power point. [W] + * ``i_mp`` - current at maximum power point. [A] + * ``v_mp`` - voltage at maximum power point. [V] + * ``i_sc`` - short circuit current. [A] + * ``v_oc`` - open circuit voltage. [V] + + Notes + ----- + This method is the combination of three sub-methods for: + + 1. estimating single-diode model parameters from datasheet information + 2. translating SDM parameters from STC to operating conditions + (taken from the De Soto model) + 3. estimating the MPP, OC, and SC points on the resulting I-V curve. + + At extremely low irradiance (e.g. 1e-10 Wm⁻²), this model can produce + negative voltages. This function clips any negative voltages to zero. + + References + ---------- + .. [1] E. I. Batzelis, "Simple PV Performance Equations Theoretically Well + Founded on the Single-Diode Model," Journal of Photovoltaics vol. 7, + no. 5, pp. 1400-1409, Sep 2017, :doi:`10.1109/JPHOTOV.2017.2711431` + + Examples + -------- + >>> params = {'i_sc': 15.98, 'v_oc': 50.26, 'i_mp': 15.27, 'v_mp': 42.57, + ... 'alpha_sc': 0.007351, 'beta_voc': -0.120624} + >>> batzelis(np.array([1000, 800]), np.array([25, 30]), **params) + {'p_mp': array([650.0439 , 512.99199048]), + 'i_mp': array([15.27 , 12.23049303]), + 'v_mp': array([42.57 , 41.94368856]), + 'i_sc': array([15.98 , 12.813404]), + 'v_oc': array([50.26 , 49.26532902])} + """ + # convert temp coeffs from A/K and V/K to 1/K + alpha_sc = alpha_sc / i_sc + beta_voc = beta_voc / v_oc + + t0 = 298.15 + delT = temp_cell - (t0 - 273.15) + lamT = (temp_cell + 273.15) / t0 + g = effective_irradiance / 1000 + # for zero/negative irradiance, use lnG=large negative number so that + # computed voltages are negative and then clipped to zero + with np.errstate(divide='ignore'): # needed for pandas for some reason + lnG = np.log(g, out=np.full_like(g, -9e9), where=(g > 0)) + lnG = np.where(np.isfinite(g), lnG, np.nan) # also preserve nans + + # Eq 9-10 + del0 = (1 - beta_voc * t0) / (50.1 - alpha_sc * t0) + w0 = _lambertw_pvlib(np.exp(1/del0 + 1)) + + # Eqs 27-28 + alpha_imp = alpha_sc + (beta_voc - 1/t0) / (w0 - 1) + beta_vmp = (v_oc / v_mp) * ( + beta_voc / (1 + del0) + + (del0 * (w0 - 1) - 1/(1 + del0)) / t0 + ) + + # Eq 26 + eps0 = (del0 / (1 + del0)) * (v_oc / v_mp) + eps1 = del0 * (w0 - 1) * (v_oc / v_mp) - 1 + + # Eqs 22-25 + isc = g * i_sc * (1 + alpha_sc * delT) + voc = v_oc * (1 + del0 * lamT * lnG + beta_voc * delT) + imp = g * i_mp * (1 + alpha_imp * delT) + vmp = v_mp * (1 + eps0 * lamT * lnG + eps1 * (1 - g) + beta_vmp * delT) + + # handle negative voltages from zero and extremely small irradiance + vmp = np.clip(vmp, a_min=0, a_max=None) + voc = np.clip(voc, a_min=0, a_max=None) + + out = { + 'p_mp': vmp * imp, + 'i_mp': imp, + 'v_mp': vmp, + 'i_sc': isc, + 'v_oc': voc, + } + + # if pandas in, ensure pandas out + pandas_inputs = [ + x for x in [effective_irradiance, temp_cell] + if isinstance(x, pd.Series) + ] + if pandas_inputs: + out = pd.DataFrame(out, index=pandas_inputs[0].index) + + return out diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 23ca1a934a..90e5193115 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -140,7 +140,7 @@ class PVSystem: module : string, optional The model name of the modules. - module_type : string, default 'glass_polymer' + module_type : string, optional Describes the module's construction. Valid strings are 'glass_polymer' and 'glass_glass'. Used for cell and module temperature calculations. @@ -414,7 +414,7 @@ def get_iam(self, aoi, iam_model='physical'): @_unwrap_single_value def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, - effective_irradiance=None): + effective_irradiance=None, longwave_down=None): """ Determine cell temperature using the method specified by ``model``. @@ -431,12 +431,17 @@ def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, model : str Supported models include ``'sapm'``, ``'pvsyst'``, - ``'faiman'``, ``'fuentes'``, and ``'noct_sam'`` + ``'faiman'``, ``'faiman_rad'``, ``'fuentes'``, ``'noct_sam'``, + and ``'ross'`` effective_irradiance : numeric or tuple of numeric, optional The irradiance that is converted to photocurrent in W/m^2. Only used for some models. + longwave_down: numeric or tuple of numeric, optional + Downwelling long-wave radiation from the sky, measured on a + horizontal surface in W/m^2. Only used in ``'faiman_rad'`` model. + Returns ------- numeric or tuple of numeric @@ -459,14 +464,18 @@ def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, # Not used for all models, but Array.get_cell_temperature handles it effective_irradiance = self._validate_per_array(effective_irradiance, system_wide=True) + longwave_down = self._validate_per_array(longwave_down, + system_wide=True) return tuple( array.get_cell_temperature(poa_global, temp_air, wind_speed, - model, effective_irradiance) - for array, poa_global, temp_air, wind_speed, effective_irradiance + model, effective_irradiance, + longwave_down) + for array, poa_global, temp_air, wind_speed, effective_irradiance, + longwave_down in zip( self.arrays, poa_global, temp_air, wind_speed, - effective_irradiance + effective_irradiance, longwave_down ) ) @@ -850,7 +859,10 @@ def pvwatts_dc(self, effective_irradiance, temp_cell): """ Calculates DC power according to the PVWatts model using :py:func:`pvlib.pvsystem.pvwatts_dc`, `self.module_parameters['pdc0']`, - and `self.module_parameters['gamma_pdc']`. + `self.module_parameters['gamma_pdc']`, + `self.module_parameters['temp_ref']`, and optionally, + `self.module_parameters['k']` and + `self.module_parameters['cap_adjustment']`. See :py:func:`pvlib.pvsystem.pvwatts_dc` for details. """ @@ -860,7 +872,8 @@ def pvwatts_dc(self, effective_irradiance, temp_cell): pvwatts_dc(effective_irradiance, temp_cell, array.module_parameters['pdc0'], array.module_parameters['gamma_pdc'], - **_build_kwargs(['temp_ref'], array.module_parameters)) + **_build_kwargs(['temp_ref', 'k', 'cap_adjustment'], + array.module_parameters)) for array, effective_irradiance, temp_cell in zip(self.arrays, effective_irradiance, temp_cell) ) @@ -1204,7 +1217,7 @@ def get_iam(self, aoi, iam_model='physical'): raise ValueError(model + ' is not a valid IAM model') def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, - effective_irradiance=None): + effective_irradiance=None, longwave_down=None): """ Determine cell temperature using the method specified by ``model``. @@ -1218,15 +1231,21 @@ def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, wind_speed : numeric Wind speed [m/s] + When ``model='ross'``, this input is ignored model : str Supported models include ``'sapm'``, ``'pvsyst'``, - ``'faiman'``, ``'fuentes'``, and ``'noct_sam'`` + ``'faiman'``, ``'faiman_rad'``, ``'fuentes'``, ``'noct_sam'``, + and ``'ross'`` effective_irradiance : numeric, optional The irradiance that is converted to photocurrent in W/m^2. Only used for some models. + longwave_down: numeric, optional + Downwelling long-wave radiation from the sky, measured on a + horizontal surface in W/m^2. Only used in ``'faiman_rad'`` model. + Returns ------- numeric @@ -1235,8 +1254,9 @@ def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, See Also -------- pvlib.temperature.sapm_cell, pvlib.temperature.pvsyst_cell, - pvlib.temperature.faiman, pvlib.temperature.fuentes, - pvlib.temperature.noct_sam + pvlib.temperature.faiman, pvlib.temperature.faiman_rad, + pvlib.temperature.fuentes, pvlib.temperature.noct_sam, + pvlib.temperature.ross Notes ----- @@ -1267,6 +1287,13 @@ def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, required = tuple() optional = _build_kwargs(['u0', 'u1'], self.temperature_model_parameters) + elif model == 'faiman_rad': + func = functools.partial(temperature.faiman_rad, + ir_down=longwave_down) + required = () + optional = _build_kwargs(['u0', 'u1', + 'sky_view', 'emissivity'], + self.temperature_model_parameters) elif model == 'fuentes': func = temperature.fuentes required = _build_tcell_args(['noct_installed']) @@ -1283,11 +1310,21 @@ def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, optional = _build_kwargs(['transmittance_absorptance', 'array_height', 'mount_standoff'], self.temperature_model_parameters) + elif model == 'ross': + func = temperature.ross + required = () + # either noct or k must be defined + optional = _build_kwargs(['noct', 'k'], + self.temperature_model_parameters) else: raise ValueError(f'{model} is not a valid cell temperature model') - temperature_cell = func(poa_global, temp_air, wind_speed, - *required, **optional) + if model == 'ross': + temperature_cell = func(poa_global, temp_air, + *required, **optional) + else: + temperature_cell = func(poa_global, temp_air, wind_speed, + *required, **optional) return temperature_cell def dc_ohms_from_percent(self): @@ -1561,7 +1598,7 @@ def calcparams_desoto(effective_irradiance, temp_cell, The energy bandgap at reference temperature in units of eV. 1.121 eV for crystalline silicon. EgRef must be >0. For parameters from the SAM CEC module database, EgRef=1.121 is implicit for all - cell types in the parameter estimation algorithm used by NREL. + cell types in the parameter estimation algorithm used by NLR. dEgdT : float The temperature dependence of the energy bandgap at reference @@ -1569,7 +1606,7 @@ def calcparams_desoto(effective_irradiance, temp_cell, (e.g. -0.0002677 as in [1]_) or a DataFrame (this may be useful if dEgdT is a modeled as a function of temperature). For parameters from the SAM CEC module database, dEgdT=-0.0002677 is implicit for all cell - types in the parameter estimation algorithm used by NREL. + types in the parameter estimation algorithm used by NLR. irrad_ref : float, default 1000 Reference irradiance in W/m^2. @@ -1604,7 +1641,7 @@ def calcparams_desoto(effective_irradiance, temp_cell, photovoltaic array performance", Solar Energy, vol 80, pp. 78-88, 2006. - .. [2] System Advisor Model web page. https://sam.nrel.gov. + .. [2] System Advisor Model web page. https://sam.nlr.gov. .. [3] A. Dobos, "An Improved Coefficient Calculator for the California Energy Commission 6 Parameter Photovoltaic Module Model", Journal of @@ -1779,7 +1816,7 @@ def calcparams_cec(effective_irradiance, temp_cell, The energy bandgap at reference temperature in units of eV. 1.121 eV for crystalline silicon. EgRef must be >0. For parameters from the SAM CEC module database, EgRef=1.121 is implicit for all - cell types in the parameter estimation algorithm used by NREL. + cell types in the parameter estimation algorithm used by NLR. dEgdT : float The temperature dependence of the energy bandgap at reference @@ -1787,7 +1824,7 @@ def calcparams_cec(effective_irradiance, temp_cell, (e.g. -0.0002677 as in [3]) or a DataFrame (this may be useful if dEgdT is a modeled as a function of temperature). For parameters from the SAM CEC module database, dEgdT=-0.0002677 is implicit for all cell - types in the parameter estimation algorithm used by NREL. + types in the parameter estimation algorithm used by NLR. irrad_ref : float, default 1000 Reference irradiance in W/m^2. @@ -1822,7 +1859,7 @@ def calcparams_cec(effective_irradiance, temp_cell, Energy Commission 6 Parameter Photovoltaic Module Model", Journal of Solar Energy Engineering, vol 134, 2012. - .. [2] System Advisor Model web page. https://sam.nrel.gov. + .. [2] System Advisor Model web page. https://sam.nlr.gov. .. [3] W. De Soto et al., "Improvement and validation of a model for photovoltaic array performance", Solar Energy, vol 80, pp. 78-88, @@ -2084,7 +2121,7 @@ def retrieve_sam(name=None, path=None): Notes ----- Files available at - https://github.com/NREL/SAM/tree/develop/deploy/libraries + https://github.com/NatLabRockies/SAM/tree/develop/deploy/libraries Examples -------- @@ -2498,7 +2535,11 @@ def singlediode(photocurrent, saturation_current, resistance_series, method : str, default 'lambertw' Determines the method used to calculate points on the IV curve. The - options are ``'lambertw'``, ``'newton'``, or ``'brentq'``. + options are ``'lambertw'``, ``'newton'``, ``'brentq'``, or + ``'chandrupatla'``. + + .. note:: + ``'chandrupatla'`` requires scipy 1.15 or greater. Returns ------- @@ -2530,28 +2571,35 @@ def singlediode(photocurrent, saturation_current, resistance_series, explicit function of :math:`V=f(I)` and :math:`I=f(V)` as shown in [2]_. If the method is ``'newton'`` then the root-finding Newton-Raphson method - is used. It should be safe for well behaved IV-curves, but the ``'brentq'`` - method is recommended for reliability. + is used. It should be safe for well-behaved IV curves, otherwise the + ``'chandrupatla``` or ``'brentq'`` methods are recommended for reliability. If the method is ``'brentq'`` then Brent's bisection search method is used that guarantees convergence by bounding the voltage between zero and - open-circuit. + open-circuit. ``'brentq'`` is generally slower than the other options. + + If the method is ``'chandrupatla'`` then Chandrupatla's method is used + that guarantees convergence. References ---------- - .. [1] S.R. Wenham, M.A. Green, M.E. Watt, "Applied Photovoltaics" ISBN - 0 86758 909 4 + .. [1] S. R. Wenham, M. A. Green, M. E. Watt, "Applied Photovoltaics", + Centre for Photovoltaic Devices and Systems, 1995. ISBN + 0867589094 .. [2] A. Jain, A. Kapoor, "Exact analytical solutions of the parameters of real solar cells using Lambert W-function", Solar - Energy Materials and Solar Cells, 81 (2004) 269-277. + Energy Materials and Solar Cells, vol. 81 no. 2, pp. 269-277, Feb. 2004. + :doi:`10.1016/j.solmat.2003.11.018`. - .. [3] D. King et al, "Sandia Photovoltaic Array Performance Model", - SAND2004-3535, Sandia National Laboratories, Albuquerque, NM + .. [3] D. L. King, E. E. Boyson and J. A. Kratochvil "Photovoltaic Array + Performance Model", Sandia National Laboratories, Albuquerque, NM, USA. + Report SAND2004-3535, 2004. - .. [4] "Computer simulation of the effects of electrical mismatches in - photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988) - https://doi.org/10.1016/0379-6787(88)90059-2 + .. [4] J.W. Bishop, "Computer simulation of the effects of electrical + mismatches in photovoltaic cell interconnection circuits" Solar Cells, + vol. 25 no. 1, pp. 73-89, Oct. 1988. + :doi:`doi.org/10.1016/0379-6787(88)90059-2` """ args = (photocurrent, saturation_current, resistance_series, resistance_shunt, nNsVth) # collect args @@ -2561,8 +2609,9 @@ def singlediode(photocurrent, saturation_current, resistance_series, out = _singlediode._lambertw(*args) points = out[:7] else: - # Calculate points on the IV curve using either 'newton' or 'brentq' - # methods. Voltages are determined by first solving the single diode + # Calculate points on the IV curve using Bishop's algorithm and solving + # with 'newton', 'brentq' or 'chandrupatla' method. + # Voltages are determined by first solving the single diode # equation for the diode voltage V_d then backing out voltage v_oc = _singlediode.bishop88_v_from_i( 0.0, *args, method=method.lower() @@ -2630,7 +2679,11 @@ def max_power_point(photocurrent, saturation_current, resistance_series, cells ``Ns`` and the builtin voltage ``Vbi`` of the intrinsic layer. [V]. method : str - either ``'newton'`` or ``'brentq'`` + either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``. + + .. note:: + ``'chandrupatla'`` requires scipy 1.15 or greater. + Returns ------- @@ -2713,8 +2766,13 @@ def v_from_i(current, photocurrent, saturation_current, resistance_series, 0 < nNsVth method : str - Method to use: ``'lambertw'``, ``'newton'``, or ``'brentq'``. *Note*: - ``'brentq'`` is limited to 1st quadrant only. + Method to use: ``'lambertw'``, ``'newton'``, ``'brentq'``, or + ``'chandrupatla'``. *Note*: ``'brentq'`` is limited to + non-negative current. + + .. note:: + ``'chandrupatla'`` requires scipy 1.15 or greater. + Returns ------- @@ -2795,8 +2853,13 @@ def i_from_v(voltage, photocurrent, saturation_current, resistance_series, 0 < nNsVth method : str - Method to use: ``'lambertw'``, ``'newton'``, or ``'brentq'``. *Note*: - ``'brentq'`` is limited to 1st quadrant only. + Method to use: ``'lambertw'``, ``'newton'``, ``'brentq'``, or + ``'chandrupatla'``. *Note*: ``'brentq'`` is limited to + non-negative current. + + .. note:: + ``'chandrupatla'`` requires scipy 1.15 or greater. + Returns ------- @@ -2860,53 +2923,117 @@ def scale_voltage_current_power(data, voltage=1, current=1): @renamed_kwarg_warning( "0.13.0", "g_poa_effective", "effective_irradiance") -def pvwatts_dc(effective_irradiance, temp_cell, pdc0, gamma_pdc, temp_ref=25.): +def pvwatts_dc(effective_irradiance, temp_cell, pdc0, gamma_pdc, temp_ref=25., + k=None, cap_adjustment=False): r""" - Implements NREL's PVWatts DC power model. The PVWatts DC model [1]_ is: - - .. math:: - - P_{dc} = \frac{G_{poa eff}}{1000} P_{dc0} ( 1 + \gamma_{pdc} (T_{cell} - T_{ref})) - - Note that ``pdc0`` is also used as a symbol in - :py:func:`pvlib.inverter.pvwatts`. ``pdc0`` in this function refers to the DC - power of the modules at reference conditions. ``pdc0`` in - :py:func:`pvlib.inverter.pvwatts` refers to the DC power input limit of - the inverter. + Implement NLR's PVWatts (Version 5) DC power model. Parameters ---------- effective_irradiance: numeric - Irradiance transmitted to the PV cells. To be - fully consistent with PVWatts, the user must have already - applied angle of incidence losses, but not soiling, spectral, - etc. [W/m^2] + Irradiance transmitted to the PV cells. To be fully consistent with + PVWatts, the user must have already applied angle of incidence losses, + but not soiling, spectral, etc. [Wm⁻²] temp_cell: numeric Cell temperature [C]. pdc0: numeric - Power of the modules at 1000 W/m^2 and cell reference temperature. [W] + Power of the modules at 1000 Wm⁻² and cell reference temperature. [W] gamma_pdc: numeric - The temperature coefficient of power. Typically -0.002 to - -0.005 per degree C. [1/C] + The temperature coefficient of power. Typically -0.002 to -0.005 per + degree C. [1/°C] temp_ref: numeric, default 25.0 - Cell reference temperature. PVWatts defines it to be 25 C and - is included here for flexibility. [C] + Cell reference temperature. PVWatts defines it to be 25 °C and is + included here for flexibility. [°C] + k: numeric, optional + Irradiance correction factor, defined in [2]_. Typically positive. + [unitless] + cap_adjustment: Boolean, default False + If True, only apply the optional adjustment at and below 1000 Wm⁻² Returns ------- pdc: numeric DC power. [W] + Notes + ----- + The PVWatts Version 5 DC model [1]_ is: + + .. math:: + + P_{dc} = \frac{G_{poa eff}}{1000} P_{dc0} ( 1 + \gamma_{pdc} (T_{cell} - T_{ref})) + + This model has also been referred to as the power temperature coefficient + model. + + An optional adjustment can be applied to :math:`P_{dc}` as described in + [2]_. The adjustment accounts for the variation in module efficiency with + irradiance. The piece-wise adjustment to power is parameterized by `k`, + where `k` is the reduction in actual power at 200 Wm⁻² relative to power + calculated at 200 Wm⁻² as 0.2*`pdc0`. For example, a module that is rated + at 500 W at STC but produces 95 W at 200 Wm⁻² (a 5% relative reduction in + efficiency) would have a value of `k` = 0.01. + + .. math:: + + k=\frac{0.2P_{dc0}-P_{200}}{P_{dc0}} + + For positive `k` values, and `k` is typically positive, this adjustment + would also increase relative efficiency when irradiance is above 1000 Wm⁻². + This may not be desired, as modules with nonlinear irradiance response + often have peak efficiency near 1000 Wm⁻², and it is either flat or + declining at higher irradiance. An optional parameter, `cap_adjustment`, + can address this by modifying the adjustment from [2]_ to only apply below + 1000 Wm⁻². + + Note that ``pdc0`` is also used as a symbol in + :py:func:`pvlib.inverter.pvwatts`. ``pdc0`` in this function refers to the + DC power of the modules at reference conditions. ``pdc0`` in + :py:func:`pvlib.inverter.pvwatts` refers to the DC power input limit of + the inverter. + References ---------- - .. [1] A. P. Dobos, "PVWatts Version 5 Manual" - http://pvwatts.nrel.gov/downloads/pvwattsv5.pdf - (2014). + .. [1] A. P. Dobos, "PVWatts Version 5 Manual", NREL, Golden, CO, USA, + Technical Report NREL/TP-6A20-62641, 2014, :doi:`10.2172/1158421`. + .. [2] B. Marion, "Comparison of Predictive Models for Photovoltaic + Module Performance," In Proc. 33rd IEEE Photovoltaic Specialists + Conference (PVSC), San Diego, CA, USA, 2008, pp. 1-6, + :doi:`10.1109/PVSC.2008.4922586`. + Pre-print: :doi:`10.1109/PVSC.2008.4922586` """ # noqa: E501 pdc = (effective_irradiance * 0.001 * pdc0 * (1 + gamma_pdc * (temp_cell - temp_ref))) + # apply Marion's correction if k is provided + if k is not None: + + # preserve input types + index = pdc.index if isinstance(pdc, pd.Series) else None + is_scalar = np.isscalar(pdc) + + # calculate error adjustments + err_1 = k * (1 - (1 - effective_irradiance / 200)**4) + err_2 = k * (1000 - effective_irradiance) / (1000 - 200) + err = np.where(effective_irradiance <= 200, err_1, err_2) + + # cap adjustment, if needed + if cap_adjustment: + err = np.where(effective_irradiance >= 1000, 0, err) + + # make error adjustment + pdc = pdc - pdc0 * err + + # set negative power to zero + pdc = np.where(pdc < 0, 0, pdc) + + # preserve input types + if index is not None: + pdc = pd.Series(pdc, index=index) + elif is_scalar: + pdc = float(pdc) + return pdc @@ -2914,7 +3041,7 @@ def pvwatts_losses(soiling=2, shading=3, snow=0, mismatch=2, wiring=2, connections=0.5, lid=1.5, nameplate_rating=1, age=0, availability=3): r""" - Implements NREL's PVWatts system loss model. + Implements NLR's PVWatts system loss model. The PVWatts loss model [1]_ is: .. math:: @@ -2945,9 +3072,8 @@ def pvwatts_losses(soiling=2, shading=3, snow=0, mismatch=2, wiring=2, References ---------- - .. [1] A. P. Dobos, "PVWatts Version 5 Manual" - http://pvwatts.nrel.gov/downloads/pvwattsv5.pdf - (2014). + .. [1] A. P. Dobos, "PVWatts Version 5 Manual", NREL, Golden, CO, USA, + Technical Report NREL/TP-6A20-62641, 2014, :doi:`10.2172/1158421`. """ params = [soiling, shading, snow, mismatch, wiring, connections, lid, diff --git a/pvlib/scaling.py b/pvlib/scaling.py index 2f9e0df594..693d9f6bff 100644 --- a/pvlib/scaling.py +++ b/pvlib/scaling.py @@ -196,7 +196,7 @@ def latlon_to_xy(coordinates): m_per_deg_lon = r_earth * np.cos(np.pi/180 * meanlat) * np.pi/180 # Conversion - pos = coordinates * np.array(m_per_deg_lat, m_per_deg_lon) + pos = coordinates * np.array([m_per_deg_lat, m_per_deg_lon]) # reshape as (x,y) pairs to return try: diff --git a/pvlib/shading.py b/pvlib/shading.py index 42aef892f7..8fb50e5d3d 100644 --- a/pvlib/shading.py +++ b/pvlib/shading.py @@ -88,7 +88,7 @@ def masking_angle(surface_tilt, gcr, slant_height): :doi:`10.1016/0379-6787(84)90017-6` .. [2] Gilman, P. et al., (2018). "SAM Photovoltaic Model Technical Reference Update", NREL Technical Report NREL/TP-6A20-67399. - Available at https://www.nrel.gov/docs/fy18osti/67399.pdf + :doi:`10.2172/1429291` """ # The original equation (8 in [1]) requires pitch and collector width, # but it's easy to non-dimensionalize it to make it a function of GCR @@ -229,7 +229,7 @@ def sky_diffuse_passias(masking_angle): :doi:`10.1016/0379-6787(84)90017-6` .. [2] Gilman, P. et al., (2018). "SAM Photovoltaic Model Technical Reference Update", NREL Technical Report NREL/TP-6A20-67399. - Available at https://www.nrel.gov/docs/fy18osti/67399.pdf + :doi:`10.2172/1429291` """ return 1 - cosd(masking_angle/2)**2 diff --git a/pvlib/singlediode.py b/pvlib/singlediode.py index ff3b9497a6..974d59dff8 100644 --- a/pvlib/singlediode.py +++ b/pvlib/singlediode.py @@ -3,10 +3,12 @@ """ import numpy as np +import pandas as pd from pvlib.tools import _golden_sect_DataFrame +from pvlib.ivtools.utils import _lambertw_pvlib, _log_lambertw from scipy.optimize import brentq, newton -from scipy.special import lambertw + # newton method default parameters for this module NEWTON_DEFAULT_PARAMS = { @@ -109,13 +111,13 @@ def bishop88(diode_voltage, photocurrent, saturation_current, (a-Si) modules that is the product of the PV module number of series cells :math:`N_{s}` and the builtin voltage :math:`V_{bi}` of the intrinsic layer. [V]. - breakdown_factor : float, default 0 + breakdown_factor : numeric, default 0 fraction of ohmic current involved in avalanche breakdown :math:`a`. Default of 0 excludes the reverse bias term from the model. [unitless] - breakdown_voltage : float, default -5.5 + breakdown_voltage : numeric, default -5.5 reverse breakdown voltage of the photovoltaic junction :math:`V_{br}` [V] - breakdown_exp : float, default 3.28 + breakdown_exp : numeric, default 3.28 avalanche breakdown exponent :math:`m` [unitless] gradients : bool False returns only I, V, and P. True also returns gradients @@ -141,18 +143,20 @@ def bishop88(diode_voltage, photocurrent, saturation_current, References ---------- - .. [1] "Computer simulation of the effects of electrical mismatches in - photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988) - :doi:`10.1016/0379-6787(88)90059-2` - - .. [2] "Improved equivalent circuit and Analytical Model for Amorphous - Silicon Solar Cells and Modules." J. Mertens, et al., IEEE Transactions - on Electron Devices, Vol 45, No 2, Feb 1998. + .. [1] J.W. Bishop, "Computer simulation of the effects of electrical + mismatches in photovoltaic cell interconnection circuits" Solar Cells, + vol. 25 no. 1, pp. 73-89, Oct. 1988. + :doi:`doi.org/10.1016/0379-6787(88)90059-2` + + .. [2] J. Merten, J. M. Asensi, C. Voz, A. V. Shah, R. Platz and J. Andreu, + "Improved equivalent circuit and Analytical Model for Amorphous + Silicon Solar Cells and Modules." , IEEE Transactions + on Electron Devices, vol. 45, no. 2, pp. 423-429, Feb 1998. :doi:`10.1109/16.658676` - .. [3] "Performance assessment of a simulation model for PV modules of any - available technology", André Mermoud and Thibault Lejeune, 25th EUPVSEC, - 2010 + .. [3] A. Mermoud and T. Lejeune, "Performance assessment of a simulation + model for PV modules of any available technology", In Proc. of the 25th + European PVSEC, Valencia, ES, 2010. :doi:`10.4229/25thEUPVSEC2010-4BV.1.114` """ # calculate recombination loss current where d2mutau > 0 @@ -162,12 +166,11 @@ def bishop88(diode_voltage, photocurrent, saturation_current, # calculate temporary values to simplify calculations v_star = diode_voltage / nNsVth # non-dimensional diode voltage g_sh = 1.0 / resistance_shunt # conductance - if breakdown_factor > 0: # reverse bias is considered - brk_term = 1 - diode_voltage / breakdown_voltage - brk_pwr = np.power(brk_term, -breakdown_exp) - i_breakdown = breakdown_factor * diode_voltage * g_sh * brk_pwr - else: - i_breakdown = 0. + + brk_term = 1 - diode_voltage / breakdown_voltage + brk_pwr = np.power(brk_term, -breakdown_exp) + i_breakdown = breakdown_factor * diode_voltage * g_sh * brk_pwr + i = (photocurrent - saturation_current * np.expm1(v_star) # noqa: W503 - diode_voltage * g_sh - i_recomb - i_breakdown) # noqa: W503 v = diode_voltage - i * resistance_series @@ -177,18 +180,14 @@ def bishop88(diode_voltage, photocurrent, saturation_current, grad_i_recomb = np.where(is_recomb, i_recomb / v_recomb, 0) grad_2i_recomb = np.where(is_recomb, 2 * grad_i_recomb / v_recomb, 0) g_diode = saturation_current * np.exp(v_star) / nNsVth # conductance - if breakdown_factor > 0: # reverse bias is considered - brk_pwr_1 = np.power(brk_term, -breakdown_exp - 1) - brk_pwr_2 = np.power(brk_term, -breakdown_exp - 2) - brk_fctr = breakdown_factor * g_sh - grad_i_brk = brk_fctr * (brk_pwr + diode_voltage * - -breakdown_exp * brk_pwr_1) - grad2i_brk = (brk_fctr * -breakdown_exp # noqa: W503 - * (2 * brk_pwr_1 + diode_voltage # noqa: W503 - * (-breakdown_exp - 1) * brk_pwr_2)) # noqa: W503 - else: - grad_i_brk = 0. - grad2i_brk = 0. + brk_pwr_1 = np.power(brk_term, -breakdown_exp - 1) + brk_pwr_2 = np.power(brk_term, -breakdown_exp - 2) + brk_fctr = breakdown_factor * g_sh + grad_i_brk = brk_fctr * (brk_pwr + diode_voltage * + -breakdown_exp * brk_pwr_1) + grad2i_brk = (brk_fctr * -breakdown_exp # noqa: W503 + * (2 * brk_pwr_1 + diode_voltage # noqa: W503 + * (-breakdown_exp - 1) * brk_pwr_2)) # noqa: W503 grad_i = -g_diode - g_sh - grad_i_recomb - grad_i_brk # di/dvd grad_v = 1.0 - grad_i * resistance_series # dv/dvd # dp/dv = d(iv)/dv = v * di/dv + i @@ -247,12 +246,19 @@ def bishop88_i_from_v(voltage, photocurrent, saturation_current, breakdown_exp : float, default 3.28 avalanche breakdown exponent :math:`m` [unitless] method : str, default 'newton' - Either ``'newton'`` or ``'brentq'``. ''method'' must be ``'newton'`` - if ``breakdown_factor`` is not 0. + Either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``. + ''method'' must be ``'newton'`` if ``breakdown_factor`` is not 0. + + .. note:: + ``'chandrupatla'`` requires scipy 1.15 or greater. + method_kwargs : dict, optional - Keyword arguments passed to root finder method. See - :py:func:`scipy:scipy.optimize.brentq` and - :py:func:`scipy:scipy.optimize.newton` parameters. + Keyword arguments passed to the root finder. For options, see: + + * ``method='brentq'``: :py:func:`scipy:scipy.optimize.brentq` + * ``method='newton'``: :py:func:`scipy:scipy.optimize.newton` + * ``method='chandrupatla'``: :py:func:`scipy:scipy.optimize.elementwise.find_root` + ``'full_output': True`` is allowed, and ``optimizer_output`` would be returned. See examples section. @@ -291,7 +297,7 @@ def bishop88_i_from_v(voltage, photocurrent, saturation_current, .. [1] "Computer simulation of the effects of electrical mismatches in photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988) :doi:`10.1016/0379-6787(88)90059-2` - """ + """ # noqa: E501 # collect args args = (photocurrent, saturation_current, resistance_series, resistance_shunt, nNsVth, d2mutau, NsVbi, @@ -333,6 +339,30 @@ def vd_from_brent(voc, v, iph, isat, rs, rsh, gamma, d2mutau, NsVbi, vd = newton(func=lambda x, *a: fv(x, voltage, *a), x0=x0, fprime=lambda x, *a: bishop88(x, *a, gradients=True)[4], args=args, **method_kwargs) + elif method == 'chandrupatla': + try: + from scipy.optimize.elementwise import find_root + except ModuleNotFoundError as e: + # TODO remove this when our minimum scipy version is >=1.15 + msg = ( + "method='chandrupatla' requires scipy v1.15 or greater " + "(available for Python 3.10+). " + "Select another method, or update your version of scipy." + ) + raise ImportError(msg) from e + + voc_est = estimate_voc(photocurrent, saturation_current, nNsVth) + shape = _shape_of_max_size(voltage, voc_est) + vlo = np.zeros(shape) + vhi = np.full(shape, voc_est) + bounds = (vlo, vhi) + kwargs_trimmed = method_kwargs.copy() + kwargs_trimmed.pop("full_output", None) # not valid for find_root + + result = find_root(fv, bounds, args=(voltage, *args), **kwargs_trimmed) + vd = result.x + if method_kwargs.get('full_output'): + vd = (vd, result) # mimic the other methods else: raise NotImplementedError("Method '%s' isn't implemented" % method) @@ -388,12 +418,19 @@ def bishop88_v_from_i(current, photocurrent, saturation_current, breakdown_exp : float, default 3.28 avalanche breakdown exponent :math:`m` [unitless] method : str, default 'newton' - Either ``'newton'`` or ``'brentq'``. ''method'' must be ``'newton'`` - if ``breakdown_factor`` is not 0. + Either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``. + ''method'' must be ``'newton'`` if ``breakdown_factor`` is not 0. + + .. note:: + ``'chandrupatla'`` requires scipy 1.15 or greater. + method_kwargs : dict, optional - Keyword arguments passed to root finder method. See - :py:func:`scipy:scipy.optimize.brentq` and - :py:func:`scipy:scipy.optimize.newton` parameters. + Keyword arguments passed to the root finder. For options, see: + + * ``method='brentq'``: :py:func:`scipy:scipy.optimize.brentq` + * ``method='newton'``: :py:func:`scipy:scipy.optimize.newton` + * ``method='chandrupatla'``: :py:func:`scipy:scipy.optimize.elementwise.find_root` + ``'full_output': True`` is allowed, and ``optimizer_output`` would be returned. See examples section. @@ -432,7 +469,7 @@ def bishop88_v_from_i(current, photocurrent, saturation_current, .. [1] "Computer simulation of the effects of electrical mismatches in photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988) :doi:`10.1016/0379-6787(88)90059-2` - """ + """ # noqa: E501 # collect args args = (photocurrent, saturation_current, resistance_series, resistance_shunt, nNsVth, d2mutau, NsVbi, @@ -474,6 +511,29 @@ def vd_from_brent(voc, i, iph, isat, rs, rsh, gamma, d2mutau, NsVbi, vd = newton(func=lambda x, *a: fi(x, current, *a), x0=x0, fprime=lambda x, *a: bishop88(x, *a, gradients=True)[3], args=args, **method_kwargs) + elif method == 'chandrupatla': + try: + from scipy.optimize.elementwise import find_root + except ModuleNotFoundError as e: + # TODO remove this when our minimum scipy version is >=1.15 + msg = ( + "method='chandrupatla' requires scipy v1.15 or greater " + "(available for Python 3.10+). " + "Select another method, or update your version of scipy." + ) + raise ImportError(msg) from e + + shape = _shape_of_max_size(current, voc_est) + vlo = np.zeros(shape) + vhi = np.full(shape, voc_est) + bounds = (vlo, vhi) + kwargs_trimmed = method_kwargs.copy() + kwargs_trimmed.pop("full_output", None) # not valid for find_root + + result = find_root(fi, bounds, args=(current, *args), **kwargs_trimmed) + vd = result.x + if method_kwargs.get('full_output'): + vd = (vd, result) # mimic the other methods else: raise NotImplementedError("Method '%s' isn't implemented" % method) @@ -526,12 +586,19 @@ def bishop88_mpp(photocurrent, saturation_current, resistance_series, breakdown_exp : numeric, default 3.28 avalanche breakdown exponent :math:`m` [unitless] method : str, default 'newton' - Either ``'newton'`` or ``'brentq'``. ''method'' must be ``'newton'`` - if ``breakdown_factor`` is not 0. + Either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``. + ''method'' must be ``'newton'`` if ``breakdown_factor`` is not 0. + + .. note:: + ``'chandrupatla'`` requires scipy 1.15 or greater. + method_kwargs : dict, optional - Keyword arguments passed to root finder method. See - :py:func:`scipy:scipy.optimize.brentq` and - :py:func:`scipy:scipy.optimize.newton` parameters. + Keyword arguments passed to the root finder. For options, see: + + * ``method='brentq'``: :py:func:`scipy:scipy.optimize.brentq` + * ``method='newton'``: :py:func:`scipy:scipy.optimize.newton` + * ``method='chandrupatla'``: :py:func:`scipy:scipy.optimize.elementwise.find_root` + ``'full_output': True`` is allowed, and ``optimizer_output`` would be returned. See examples section. @@ -571,7 +638,7 @@ def bishop88_mpp(photocurrent, saturation_current, resistance_series, .. [1] "Computer simulation of the effects of electrical mismatches in photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988) :doi:`10.1016/0379-6787(88)90059-2` - """ + """ # noqa: E501 # collect args args = (photocurrent, saturation_current, resistance_series, resistance_shunt, nNsVth, d2mutau, NsVbi, @@ -611,6 +678,31 @@ def fmpp(x, *a): vd = newton(func=fmpp, x0=x0, fprime=lambda x, *a: bishop88(x, *a, gradients=True)[7], args=args, **method_kwargs) + elif method == 'chandrupatla': + try: + from scipy.optimize.elementwise import find_root + except ModuleNotFoundError as e: + # TODO remove this when our minimum scipy version is >=1.15 + msg = ( + "method='chandrupatla' requires scipy v1.15 or greater " + "(available for Python 3.10+). " + "Select another method, or update your version of scipy." + ) + raise ImportError(msg) from e + + vlo = np.zeros_like(photocurrent) + vhi = np.full_like(photocurrent, voc_est) + kwargs_trimmed = method_kwargs.copy() + kwargs_trimmed.pop("full_output", None) # not valid for find_root + + result = find_root(fmpp, + (vlo, vhi), + args=args, + **kwargs_trimmed) + vd = result.x + if method_kwargs.get('full_output'): + vd = (vd, result) # mimic the other methods + else: raise NotImplementedError("Method '%s' isn't implemented" % method) @@ -710,12 +802,12 @@ def _lambertw_v_from_i(current, photocurrent, saturation_current, with np.errstate(over='ignore'): argW = I0 / (Gsh * a) * np.exp((-I + IL + I0) / (Gsh * a)) - # lambertw typically returns complex value with zero imaginary part - # may overflow to np.inf - lambertwterm = lambertw(argW).real + lambertwterm = np.zeros_like(argW) + + # Record indices where lambertw input overflowed + idx_inf = np.isinf(argW) - # Record indices where lambertw input overflowed output - idx_inf = np.isinf(lambertwterm) + lambertwterm[~idx_inf] = _lambertw_pvlib(argW[~idx_inf]) # Only re-compute LambertW if it overflowed if np.any(idx_inf): @@ -724,15 +816,7 @@ def _lambertw_v_from_i(current, photocurrent, saturation_current, np.log(a[idx_inf]) + (-I[idx_inf] + IL[idx_inf] + I0[idx_inf]) / (Gsh[idx_inf] * a[idx_inf])) - - # Three iterations of Newton-Raphson method to solve - # w+log(w)=logargW. The initial guess is w=logargW. Where direct - # evaluation (above) results in NaN from overflow, 3 iterations - # of Newton's method gives approximately 8 digits of precision. - w = logargW - for _ in range(0, 3): - w = w * (1. - np.log(w) + logargW) / (1. + w) - lambertwterm[idx_inf] = w + lambertwterm[idx_inf] = _log_lambertw(logargW) # Eqn. 3 in Jain and Kapoor, 2004 # V = -I*(Rs + Rsh) + IL*Rsh - a*lambertwterm + I0*Rsh @@ -775,27 +859,25 @@ def _lambertw_i_from_v(voltage, photocurrent, saturation_current, # Explicit solutions where Rs=0 if np.any(idx_z): I[idx_z] = IL[idx_z] - I0[idx_z] * np.expm1(V[idx_z] / a[idx_z]) - \ - Gsh[idx_z] * V[idx_z] + Gsh[idx_z] * V[idx_z] # Only compute using LambertW if there are cases with Rs>0 # Does NOT handle possibility of overflow, github issue 298 if np.any(idx_p): # LambertW argument, cannot be float128, may overflow to np.inf argW = Rs[idx_p] * I0[idx_p] / ( - a[idx_p] * (Rs[idx_p] * Gsh[idx_p] + 1.)) * \ - np.exp((Rs[idx_p] * (IL[idx_p] + I0[idx_p]) + V[idx_p]) / - (a[idx_p] * (Rs[idx_p] * Gsh[idx_p] + 1.))) + a[idx_p] * (Rs[idx_p] * Gsh[idx_p] + 1.)) * ( + np.exp((Rs[idx_p] * (IL[idx_p] + I0[idx_p]) + V[idx_p]) + / (a[idx_p] * (Rs[idx_p] * Gsh[idx_p] + 1.)))) - # lambertw typically returns complex value with zero imaginary part - # may overflow to np.inf - lambertwterm = lambertw(argW).real + lambertwterm = _lambertw_pvlib(argW) # Eqn. 2 in Jain and Kapoor, 2004 # I = -V/(Rs + Rsh) - (a/Rs)*lambertwterm + Rsh*(IL + I0)/(Rs + Rsh) # Recast in terms of Gsh=1/Rsh for better numerical stability. I[idx_p] = (IL[idx_p] + I0[idx_p] - V[idx_p] * Gsh[idx_p]) / \ - (Rs[idx_p] * Gsh[idx_p] + 1.) - ( - a[idx_p] / Rs[idx_p]) * lambertwterm + (Rs[idx_p] * Gsh[idx_p] + 1.) \ + - (a[idx_p] / Rs[idx_p]) * lambertwterm if output_is_scalar: return I.item() @@ -825,10 +907,25 @@ def _lambertw(photocurrent, saturation_current, resistance_series, v_oc = 0. # Find the voltage, v_mp, where the power is maximized. - # Start the golden section search at v_oc * 1.14 - p_mp, v_mp = _golden_sect_DataFrame(params, 0., v_oc * 1.14, _pwr_optfcn) + # use scipy.elementwise if available + # remove try/except when scipy>=1.15, and golden mean is retired + try: + from scipy.optimize.elementwise import find_minimum + # left negative to insure strict inequality + init = (-1., 0.8*v_oc, v_oc) + res = find_minimum(_vmp_opt, init, + args=(params['photocurrent'], + params['saturation_current'], + params['resistance_series'], + params['resistance_shunt'], + params['nNsVth'],)) + v_mp = res.x + p_mp = -1.*res.f_x + except ModuleNotFoundError: + # switch to old golden section method + p_mp, v_mp = _golden_sect_DataFrame(params, 0., v_oc * 1.14, + _pwr_optfcn) - # Find Imp using Lambert W i_mp = _lambertw_i_from_v(v_mp, **params) # Find Ix and Ixx using Lambert W @@ -850,6 +947,15 @@ def _lambertw(photocurrent, saturation_current, resistance_series, return out +def _vmp_opt(v, iph, io, rs, rsh, nNsVth): + ''' + Function to find negative of power from ``i_from_v``. + ''' + current = _lambertw_i_from_v(v, iph, io, rs, rsh, nNsVth) + + return -v * current + + def _pwr_optfcn(df, loc): ''' Function to find power from ``i_from_v``. @@ -861,3 +967,85 @@ def _pwr_optfcn(df, loc): df['resistance_shunt'], df['nNsVth']) return current * df[loc] + + +def batzelis(photocurrent, saturation_current, resistance_series, + resistance_shunt, nNsVth): + """ + Estimate maximum power, open-circuit, and short-circuit points from + single-diode equation parameters using Batzelis's method. + + This method is described in Section II.B of [1]_. + + Parameters + ---------- + photocurrent : numeric + Light-generated current. [A] + saturation_current : numeric + Diode saturation current. [A] + resistance_series : numeric + Series resistance. [Ohm] + resistance_shunt : numeric + Shunt resistance. [Ohm] + nNsVth : numeric + The product of the usual diode ideality factor (n, unitless), + number of cells in series (Ns), and cell thermal voltage at + specified effective irradiance and cell temperature. [V] + + Returns + ------- + dict + The returned dict-like object contains the keys/columns: + + * ``p_mp`` - power at maximum power point. [W] + * ``i_mp`` - current at maximum power point. [A] + * ``v_mp`` - voltage at maximum power point. [V] + * ``i_sc`` - short circuit current. [A] + * ``v_oc`` - open circuit voltage. [V] + + References + ---------- + .. [1] E. I. Batzelis, "Simple PV Performance Equations Theoretically Well + Founded on the Single-Diode Model," Journal of Photovoltaics vol. 7, + no. 5, pp. 1400-1409, Sep 2017, :doi:`10.1109/JPHOTOV.2017.2711431` + """ + # convenience variables + Iph = photocurrent + Is = saturation_current + Rsh = resistance_shunt + Rs = resistance_series + a = nNsVth + + # Eqs 3-4 + isc = Iph / (Rs / Rsh + 1) # manipulated to handle Rsh=np.inf correctly + with np.errstate(divide='ignore'): # zero Iph + voc = a * np.log(Iph / Is) + + # Eqs 5-8 + w = _lambertw_pvlib(np.e * Iph / Is) + # vmp = (1 + Rs/Rsh) * a * (w - 1) - Rs * Iph * (1 - 1/w) # not needed + with np.errstate(divide='ignore', invalid='ignore'): # zero Iph -> zero w + imp = Iph * (1 - 1/w) - a * (w - 1) / Rsh + + vmp = a * (w - 1) - Rs * imp + + vmp = np.where(Iph > 0, vmp, 0) + voc = np.where(Iph > 0, voc, 0) + imp = np.where(Iph > 0, imp, 0) + isc = np.where(Iph > 0, isc, 0) + + out = { + 'p_mp': imp * vmp, + 'i_mp': imp, + 'v_mp': vmp, + 'i_sc': isc, + 'v_oc': voc, + } + + # if pandas in, ensure pandas out + pandas_inputs = [ + x for x in [Iph, Is, Rsh, Rs, a] if isinstance(x, pd.Series)] + if pandas_inputs: + out = pd.DataFrame(out, index=pandas_inputs[0].index) + + return out diff --git a/pvlib/snow.py b/pvlib/snow.py index f2c8fca148..ec1fa0dc51 100644 --- a/pvlib/snow.py +++ b/pvlib/snow.py @@ -159,7 +159,7 @@ def coverage_nrel(snowfall, poa_irradiance, temp_air, surface_tilt, # All slides off if snow on the ground is less than threshold_depth. # Described in [2] to avoid non-sliding snow for low-tilt systems. # Default threshold_depth of 1cm is from [2[ and SAM's implementation. - # https://github.com/NREL/ssc/issues/1265 + # https://github.com/NatLabRockies/ssc/issues/1265 slide_amt[snow_depth < threshold_depth] = 1. # build time series of cumulative slide amounts @@ -211,7 +211,7 @@ def dc_loss_nrel(snow_coverage, num_strings): ---------- .. [1] Gilman, P. et al., (2018). "SAM Photovoltaic Model Technical Reference Update", NREL Technical Report NREL/TP-6A20-67399. - Available at https://www.nrel.gov/docs/fy18osti/67399.pdf + Available at https://www.nlr.gov/docs/fy18osti/67399.pdf ''' return np.ceil(snow_coverage * num_strings) / num_strings @@ -320,7 +320,7 @@ def loss_townsend(snow_total, snow_events, surface_tilt, relative_humidity, Uses of the Townsend Snow Model. In "Photovoltaic Reliability Workshop (PVRW) 2023 Proceedings: Posters.", ed. Silverman, T. J. Dec. 2023. NREL/CP-5900-87918. - Available at: https://www.nrel.gov/docs/fy25osti/90585.pdf + Available at: https://www.osti.gov/biblio/2229734 .. [3] Townsend, T. (2013). Predicting PV Energy Loss Caused by Snow. Solar Power International, Chicago IL. :doi:`10.13140/RG.2.2.14299.68647` diff --git a/pvlib/soiling.py b/pvlib/soiling.py index 6f81d76f0e..b7582dbedf 100644 --- a/pvlib/soiling.py +++ b/pvlib/soiling.py @@ -16,9 +16,11 @@ def hsu(rainfall, cleaning_threshold, surface_tilt, pm2_5, pm10, Calculates soiling ratio given particulate and rain data using the Fixed Velocity model from Humboldt State University (HSU). - The HSU soiling model [1]_ returns the soiling ratio, a value between zero - and one which is equivalent to (1 - transmission loss). Therefore a soiling - ratio of 1.0 is equivalent to zero transmission loss. + The HSU soiling model [1]_ returns the soiling ratio, a value between + zero and one which is equivalent to (1 - transmission loss). + Therefore a soiling ratio of 1.0 is equivalent to zero transmission loss. + Due to the mathematical form of the HSU model, the soiling ratio has a + minimum of approximately 0.6563. See ``Notes`` for details. Parameters ---------- @@ -54,6 +56,17 @@ def hsu(rainfall, cleaning_threshold, surface_tilt, pm2_5, pm10, soiling_ratio : Series Values between 0 and 1. Equal to 1 - transmission loss. + Notes + ------- + Due to the mathematical form of the HSU model + (``SR = 1 - 0.3437 * erf(0.17 * ω^0.8473)``), + the soiling ratio has a minimum value of approximately 0.6563 + (i.e., maximum transmission loss of ~34.37%), regardless of + the accumulated particulate mass. The HSU model is developed + (validated) for accumulated mass densities up to 10 g/m², + corresponding to a soiling ratio of approximately 0.6875. + See [1]_ for details. + References ----------- .. [1] M. Coello and L. Boyle, "Simple Model For Predicting Time Series diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index ba861c9d82..501daa1c21 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -67,7 +67,7 @@ def get_solarposition(time, latitude, longitude, 'ephemeris' uses the pvlib ephemeris code: :py:func:`ephemeris` - 'nrel_c' uses the NREL SPA C code [3]: :py:func:`spa_c` + 'nrel_c' uses the NLR SPA C code [3]: :py:func:`spa_c` temperature : float, default 12 Degrees C. @@ -85,7 +85,7 @@ def get_solarposition(time, latitude, longitude, solar radiation applications. Solar Energy, vol. 81, no. 6, p. 838, 2007. - .. [3] NREL SPA code: https://midcdmz.nrel.gov/spa/ + .. [3] NLR SPA code: https://midcdmz.nlr.gov/spa/ """ if altitude is None and pressure is None: @@ -129,8 +129,8 @@ def spa_c(time, latitude, longitude, pressure=101325., altitude=0., temperature=12., delta_t=67.0, raw_spa_output=False): r""" - Calculate the solar position using the C implementation of the NREL - SPA code. + Calculate the solar position using the NLR C implementation of the + NREL SPA. The source files for this code are located in './spa_c_files/', along with a README file which describes how the C code is wrapped in Python. @@ -173,7 +173,7 @@ def spa_c(time, latitude, longitude, pressure=101325., altitude=0., References ---------- - .. [1] NREL SPA reference: https://midcdmz.nrel.gov/spa/ + .. [1] NLR SPA reference: https://midcdmz.nlr.gov/spa/ Note: The ``timezone`` field in the SPA C files is replaced with ``time_zone`` to avoid a nameclash with the function ``__timezone`` that is @@ -314,7 +314,7 @@ def spa_python(time, latitude, longitude, using time.year and time.month from pandas.DatetimeIndex. For most simulations the default delta_t is sufficient. The USNO has historical and forecasted delta_t [3]_. - atmos_refrac : float, optional + atmos_refract : float, optional The approximate atmospheric refraction (in degrees) at sunrise and sunset. how : str, optional, default 'numpy' @@ -432,8 +432,8 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', References ---------- .. [1] Reda, I., Andreas, A., 2003. Solar position algorithm for solar - radiation applications. Technical report: NREL/TP-560- 34302. Golden, - USA, http://www.nrel.gov. + radiation applications. Technical report: NREL/TP-560-34302. Golden, + USA, http://www.nlr.gov. """ # Added by Tony Lorenzo (@alorenzo175), University of Arizona, 2015 @@ -826,34 +826,43 @@ def ephemeris(time, latitude, longitude, pressure=101325.0, temperature=12.0): # Calculate refraction correction Elevation = SunEl - TanEl = pd.Series(np.tan(np.radians(Elevation)), index=time_utc) - Refract = pd.Series(0., index=time_utc) - - Refract[(Elevation > 5) & (Elevation <= 85)] = ( - 58.1/TanEl - 0.07/(TanEl**3) + 8.6e-05/(TanEl**5)) - - Refract[(Elevation > -0.575) & (Elevation <= 5)] = ( - Elevation * - (-518.2 + Elevation*(103.4 + Elevation*(-12.79 + Elevation*0.711))) + - 1735) + TanEl = np.tan(np.radians(Elevation)) + Refract = np.zeros(len(time_utc)) + + mask = (Elevation > 5) & (Elevation <= 85) + Refract[mask] = ( + 58.1/TanEl[mask] - 0.07/(TanEl[mask]**3) + 8.6e-05/(TanEl[mask]**5)) + + mask = (Elevation > -0.575) & (Elevation <= 5) + Refract[mask] = ( + Elevation[mask] * ( + -518.2 + Elevation[mask]*( + 103.4 + Elevation[mask]*(-12.79 + Elevation[mask]*0.711) + ) + ) + 1735 + ) - Refract[(Elevation > -1) & (Elevation <= -0.575)] = -20.774 / TanEl + mask = (Elevation > -1) & (Elevation <= -0.575) + Refract[mask] = -20.774 / TanEl[mask] Refract *= (283/(273. + temperature)) * (pressure/101325.) / 3600. ApparentSunEl = SunEl + Refract # make output DataFrame - DFOut = pd.DataFrame(index=time_utc) - DFOut['apparent_elevation'] = ApparentSunEl - DFOut['elevation'] = SunEl - DFOut['azimuth'] = SunAz - DFOut['apparent_zenith'] = 90 - ApparentSunEl - DFOut['zenith'] = 90 - SunEl - DFOut['solar_time'] = SolarTime - DFOut.index = time + result = pd.DataFrame( + { + "apparent_elevation": ApparentSunEl, + "elevation": SunEl, + "azimuth": SunAz, + "apparent_zenith": 90 - ApparentSunEl, + "zenith": 90 - SunEl, + "solar_time": SolarTime, + }, + index=time + ) - return DFOut + return result def calc_time(lower_bound, upper_bound, latitude, longitude, attribute, value, @@ -987,8 +996,8 @@ def nrel_earthsun_distance(time, how='numpy', delta_t=67.0, numthreads=4): References ---------- .. [1] Reda, I., Andreas, A., 2003. Solar position algorithm for solar - radiation applications. Technical report: NREL/TP-560- 34302. Golden, - USA, http://www.nrel.gov. + radiation applications. Technical report: NREL/TP-560-34302. Golden, + USA, http://www.nlr.gov. """ if not isinstance(time, pd.DatetimeIndex): diff --git a/pvlib/spa.py b/pvlib/spa.py index d4181aaa49..fe072b2cc9 100644 --- a/pvlib/spa.py +++ b/pvlib/spa.py @@ -1057,7 +1057,7 @@ def solar_position(unixtime, lat, lon, elev, pressure, temp, delta_t, degrees C; used for atmospheric correction delta_t : float or array Difference between terrestrial time and UT1. - atmos_refrac : float + atmos_refract : float The approximate atmospheric refraction (in degrees) at sunrise and sunset. numthreads: int, optional, default 8 @@ -1235,8 +1235,8 @@ def earthsun_distance(unixtime, delta_t, numthreads): References ---------- [1] Reda, I., Andreas, A., 2003. Solar position algorithm for solar - radiation applications. Technical report: NREL/TP-560- 34302. Golden, - USA, http://www.nrel.gov. + radiation applications. Technical report: NREL/TP-560-34302. Golden, + USA, http://www.nlr.gov. """ R = solar_position(unixtime, 0, 0, 0, 0, 0, delta_t, diff --git a/pvlib/spa_c_files/README.md b/pvlib/spa_c_files/README.md index aaef8bd571..69a9c28d4d 100644 --- a/pvlib/spa_c_files/README.md +++ b/pvlib/spa_c_files/README.md @@ -1,18 +1,18 @@ README ------ -NREL provides a C implementation of the solar position algorithm described in -[Reda, I.; Andreas, A. (2003). Solar Position Algorithm for Solar Radiation Applications. 55 pp.; NREL Report No. TP-560-34302](http://www.nrel.gov/docs/fy08osti/34302.pdf). +NLR provides a C implementation of the solar position algorithm described in +Reda, I.; Andreas, A. (2003). Solar Position Algorithm for Solar Radiation Applications. 55 pp.; NREL Report No. TP-560-34302. :doi:`10.2172/15003974` This folder contains the files required to make SPA C code accessible -to the `pvlib-python` package. We use the Cython package to wrap the NREL SPA +to the `pvlib-python` package. We use the Cython package to wrap the NLR SPA implementation. ** Due to licensing issues, the SPA C files can _not_ be distributed with `pvlib-python`. You must download the SPA C files from the -[NREL website](https://midcdmz.nrel.gov/spa/). ** +[NLR website](https://midcdmz.nlr.gov/spa/). ** -Download the `spa.c` and `spa.h` files from NREL, and copy them into the +Download the `spa.c` and `spa.h` files from NLR, and copy them into the `pvlib/spa_c_files` directory. When the extension is built, the ``timezone`` field in the SPA C files is replaced with `time_zone` to avoid a nameclash with the function `__timezone` that is redefined by Python>=3.5. This issue @@ -20,7 +20,7 @@ is [Python bug 24643](https://bugs.python.org/issue24643). There are a total of 5 files needed to compile the C code, described below: -* `spa.c`: original C code from NREL +* `spa.c`: original C code from NLR * `spa.h`: header file for spa.c * `cspa_py.pxd`: a cython header file which essentially tells cython which parts of the main header file to pay attention to diff --git a/pvlib/spectrum/__init__.py b/pvlib/spectrum/__init__.py index 59f9db9582..1e551c83fa 100644 --- a/pvlib/spectrum/__init__.py +++ b/pvlib/spectrum/__init__.py @@ -3,9 +3,10 @@ calc_spectral_mismatch_field, spectral_factor_caballero, spectral_factor_firstsolar, - spectral_factor_sapm, - spectral_factor_pvspec, spectral_factor_jrc, + spectral_factor_polo, + spectral_factor_pvspec, + spectral_factor_sapm, ) from pvlib.spectrum.irradiance import ( # noqa: F401 get_reference_spectra, diff --git a/pvlib/spectrum/irradiance.py b/pvlib/spectrum/irradiance.py index fc3440cd19..4c8e70b693 100644 --- a/pvlib/spectrum/irradiance.py +++ b/pvlib/spectrum/irradiance.py @@ -47,8 +47,8 @@ def get_reference_spectra(wavelengths=None, standard="ASTM G173-03"): For global spectra, it is about 1000.37 W/m². The values of the ASTM G173-03 provided with pvlib-python are copied from - an Excel file distributed by NREL, which is found here [2]_: - https://www.nrel.gov/grid/solar-resource/assets/data/astmg173.xls + an Excel file distributed by NLR, which is found here [2]_: + https://www.nlr.gov/grid/solar-resource/assets/data/astmg173.xls Examples -------- @@ -78,8 +78,8 @@ def get_reference_spectra(wavelengths=None, standard="ASTM G173-03"): ---------- .. [1] ASTM "G173-03 Standard Tables for Reference Solar Spectral Irradiances: Direct Normal and Hemispherical on 37° Tilted Surface." - .. [2] “Reference Air Mass 1.5 Spectra,” www.nrel.gov. - https://www.nrel.gov/grid/solar-resource/spectra-am1.5.html + .. [2] “Reference Air Mass 1.5 Spectra.” NLR. + https://www.nlr.gov/grid/solar-resource/spectra-am1.5.html """ # Contributed by Echedey Luis, inspired by Anton Driesse (get_am15g) SPECTRA_FILES = { "ASTM G173-03": "ASTMG173.csv", diff --git a/pvlib/spectrum/mismatch.py b/pvlib/spectrum/mismatch.py index 9f68d77c83..830c0be171 100644 --- a/pvlib/spectrum/mismatch.py +++ b/pvlib/spectrum/mismatch.py @@ -231,7 +231,7 @@ def spectral_factor_firstsolar(precipitable_water, airmass_absolute, Water." IEEE Photovoltaic Specialists Conference, Portland, 2016 .. [3] Marion, William F., et al. User's Manual for Data for Validating Models for PV Module Performance. National Renewable Energy - Laboratory, 2014. http://www.nrel.gov/docs/fy14osti/61610.pdf + Laboratory, 2014. :doi:`10.2172/1130632` .. [4] Schweiger, M. and Hermann, W, Influence of Spectral Effects on Energy Yield of Different PV Modules: Comparison of Pwat and MMF Approach, TUV Rheinland Energy GmbH report 21237296.003, @@ -698,3 +698,104 @@ def spectral_factor_jrc(airmass, clearsky_index, module_type=None, + coeff[2] * (airmass - 1.5) ) return mismatch + + +def spectral_factor_polo(precipitable_water, airmass_absolute, aod500, aoi, + pressure, module_type=None, coefficients=None, + albedo=0.2): + """ + Estimate the spectral mismatch for BIPV application in vertical facades. + + The model's authors note that this model could also be applied to + vertical bifacial ground-mount systems [1]_, although it has not been + validated in that context. + + Parameters + ---------- + precipitable_water : numeric + Atmospheric precipitable water. [cm] + airmass_absolute : numeric + Absolute (pressure-adjusted) airmass. See :term:`airmass_absolute`. + [unitless] + aod500 : numeric + Atmospheric aerosol optical depth at 500 nm. [unitless] + aoi : numeric + Angle of incidence on the vertical surface. See :term:`aoi`. + [degrees] + pressure : numeric + Atmospheric pressure. See :term:`pressure`. [Pa] + module_type : str, optional + One of the following PV technology strings from [1]_: + + * ``'cdte'`` - anonymous CdTe module. + * ``'monosi'`` - anonymous monocrystalline silicon module. + * ``'cigs'`` - anonymous copper indium gallium selenide module. + * ``'asi'`` - anonymous amorphous silicon module. + coefficients : array-like, optional + User-defined coefficients, if not using one of the coefficient + sets via the ``module_type`` parameter. Must have nine elements. + The first six elements correspond to the [p1, p2, p3, p4, b, c] + parameters of the SMM model. The last three elements corresponds + to the [c1, c2, c3] parameters of the albedo correction factor. + albedo : numeric, default 0.2 + Ground albedo. See :term:`albedo`. [unitless] + + Returns + ------- + modifier: numeric + spectral mismatch factor (unitless) which is multiplied + with broadband irradiance reaching a module's cells to estimate + effective irradiance, i.e., the irradiance that is converted to + electrical current. + + Notes + ----- + The Polo model was developed using only SMM values computed for scenarios + when the sun is visible from the module's surface (i.e., for ``aoi<90``), + and no provision was made in [1]_ for the case of ``aoi>90``. This would + create issues in the air mass calculation internal to the model. + Following discussion with the model's author, the pvlib implementation + handles ``aoi>90`` by truncating the input ``aoi`` to a maximum of + 90 degrees. + + References + ---------- + .. [1] J. Polo and C. Sanz-Saiz, 'Development of spectral mismatch models + for BIPV applications in building façades', Renewable Energy, vol. 245, + p. 122820, Jun. 2025, :doi:`10.1016/j.renene.2025.122820` + """ + if module_type is None and coefficients is None: + raise ValueError('Must provide either `module_type` or `coefficients`') + if module_type is not None and coefficients is not None: + raise ValueError('Only one of `module_type` and `coefficients` should ' + 'be provided') + # prevent nan for aoi greater than 90; see docstring Notes + aoi = np.clip(aoi, a_min=None, a_max=90) + f_aoi_rel = pvlib.atmosphere.get_relative_airmass(aoi, + model='kastenyoung1989') + f_aoi = pvlib.atmosphere.get_absolute_airmass(f_aoi_rel, pressure) + Ram = f_aoi / airmass_absolute + _coefficients = { + 'cdte': (-0.0009, 46.80, 49.20, -0.87, 0.00041, 0.053), + 'monosi': (0.0027, 10.34, 9.48, 0.31, 0.00077, 0.006), + 'cigs': (0.0017, 2.33, 1.30, 0.11, 0.00098, -0.018), + 'asi': (0.0024, 7.32, 7.09, -0.72, -0.0013, 0.089), + } + c = { + 'asi': (0.0056, -0.020, 1.014), + 'cigs': (-0.0009, -0.0003, 1), + 'cdte': (0.0021, -0.01, 1.01), + 'monosi': (0, -0.003, 1.0), + } + if module_type is not None: + coeff = _coefficients[module_type] + c_albedo = c[module_type] + else: + coeff = coefficients[:6] + c_albedo = coefficients[6:] + smm = coeff[0] * Ram + coeff[1] / (coeff[2] + Ram**coeff[3]) \ + + coeff[4] / aod500 + coeff[5]*np.sqrt(precipitable_water) + # Ground albedo correction + g = c_albedo[0] * (albedo/0.2)**2 \ + + c_albedo[1] * (albedo/0.2) + c_albedo[2] + return g*smm diff --git a/pvlib/spectrum/spectrl2.py b/pvlib/spectrum/spectrl2.py index 38739efff3..af531ff0a8 100644 --- a/pvlib/spectrum/spectrl2.py +++ b/pvlib/spectrum/spectrl2.py @@ -203,7 +203,7 @@ def spectrl2(apparent_zenith, aoi, surface_tilt, ground_albedo, Surface pressure. [Pa] relative_airmass : numeric Relative airmass. The airmass model used in [1]_ is the `'kasten1966'` - model, while a later implementation by NREL uses the + model, while a later implementation by NREL used the `'kastenyoung1989'` model. [unitless] precipitable_water : numeric Atmospheric water vapor content. [cm] @@ -245,7 +245,7 @@ def spectrl2(apparent_zenith, aoi, surface_tilt, ground_albedo, Notes ----- - NREL's C implementation ``spectrl2_2.c`` [2]_ of the model differs in + NLR's C implementation ``spectrl2_2.c`` [2]_ of the model differs in several ways from the original report [1]_. The report itself also has a few differences between the in-text equations and the code appendix. The list of known differences is shown below. Note that this @@ -273,7 +273,7 @@ def spectrl2(apparent_zenith, aoi, surface_tilt, ground_albedo, earth's surface for cloudless atmospheres", NREL Technical Report TR-215-2436 :doi:`10.2172/5986936`. .. [2] Bird Simple Spectral Model: spectrl2_2.c. - https://www.nrel.gov/grid/solar-resource/spectral.html + https://www.nlr.gov/grid/solar-resource/spectral.html """ # values need to be np arrays for broadcasting, so unwrap Series if needed: is_pandas = isinstance(apparent_zenith, pd.Series) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 6c274d79b7..55222c0fb1 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -6,7 +6,6 @@ import numpy as np import pandas as pd from pvlib.tools import sind -from pvlib._deprecation import warn_deprecated from pvlib.tools import _get_sample_intervals import scipy import scipy.constants @@ -456,14 +455,14 @@ def faiman(poa_global, temp_air, wind_speed=1.0, u0=25.0, u1=6.84): speed at module height used to determine NOCT. [m/s] u0 : numeric, default 25.0 - Combined heat loss factor coefficient. The default value is one - determined by Faiman for 7 silicon modules + Combined heat loss factor coefficient. The default value is for module + temperature determined by Faiman for 7 silicon modules in the Negev desert on an open rack at 30.9° tilt. :math:`\left[\frac{\text{W}/{\text{m}^2}}{\text{C}}\right]` u1 : numeric, default 6.84 - Combined heat loss factor influenced by wind. The default value is one - determined by Faiman for 7 silicon modules + Combined heat loss factor influenced by wind. The default value is + for module temperature determined by Faiman for 7 silicon modules in the Negev desert on an open rack at 30.9° tilt. :math:`\left[ \frac{\text{W}/\text{m}^2}{\text{C}\ \left( \text{m/s} \right)} \right]` @@ -539,14 +538,14 @@ def faiman_rad(poa_global, temp_air, wind_speed=1.0, ir_down=None, surface. [W/m^2] u0 : numeric, default 25.0 - Combined heat loss factor coefficient. The default value is one - determined by Faiman for 7 silicon modules + Combined heat loss factor coefficient. The default value is for module + temperature determined by Faiman for 7 silicon modules in the Negev desert on an open rack at 30.9° tilt. :math:`\left[\frac{\text{W}/{\text{m}^2}}{\text{C}}\right]` u1 : numeric, default 6.84 - Combined heat loss factor influenced by wind. The default value is one - determined by Faiman for 7 silicon modules + Combined heat loss factor influenced by wind. The default value is for + module temperature determined by Faiman for 7 silicon modules in the Negev desert on an open rack at 30.9° tilt. :math:`\left[ \frac{\text{W}/\text{m}^2}{\text{C}\ \left( \text{m/s} \right)} \right]` @@ -618,7 +617,7 @@ def faiman_rad(poa_global, temp_air, wind_speed=1.0, ir_down=None, return temp_air + temp_difference -def ross(poa_global, temp_air, noct): +def ross(poa_global, temp_air, noct=None, k=None): r''' Calculate cell temperature using the Ross model. @@ -630,14 +629,19 @@ def ross(poa_global, temp_air, noct): Parameters ---------- poa_global : numeric - Total incident irradiance. [W/m^2] + Total incident irradiance. [W/m⁻²] temp_air : numeric Ambient dry bulb temperature. [C] - noct : numeric + noct : numeric, optional Nominal operating cell temperature [C], determined at conditions of - 800 W/m^2 irradiance, 20 C ambient air temperature and 1 m/s wind. + 800 W/m⁻² irradiance, 20 C ambient air temperature and 1 m/s wind. + If ``noct`` is not provided, ``k`` is required. + k: numeric, optional + Ross coefficient [Km²W⁻¹], which is an alternative to employing + NOCT in Ross's equation. If ``k`` is not provided, ``noct`` is + required. Returns ------- @@ -650,23 +654,65 @@ def ross(poa_global, temp_air, noct): .. math:: - T_{C} = T_{a} + \frac{NOCT - 20}{80} S - - where :math:`S` is the plane of array irradiance in :math:`mW/{cm}^2`. - This function expects irradiance in :math:`W/m^2`. + T_{C} = T_{a} + \frac{NOCT - 20}{80} S = T_{a} + k × S + + where :math:`S` is the plane of array irradiance in mWcm⁻². + This function expects irradiance in Wm⁻². + + Representative values for k are provided in [2]_, covering different types + of mounting and degrees of back ventialtion. The naming designations, + however, are adapted from [3]_ to enhance clarity and usability. + + +--------------------------------------+-----------+ + | Mounting | :math:`k` | + +======================================+===========+ + | Sloped roof, well ventilated | 0.02 | + +--------------------------------------+-----------+ + | Free-standing system | 0.0208 | + +--------------------------------------+-----------+ + | Flat roof, well ventilated | 0.026 | + +--------------------------------------+-----------+ + | Sloped roof, poorly ventilated | 0.0342 | + +--------------------------------------+-----------+ + | Facade integrated, semi-ventilated | 0.0455 | + +--------------------------------------+-----------+ + | Facade integrated, poorly ventilated | 0.0538 | + +--------------------------------------+-----------+ + | Sloped roof, non-ventilated | 0.0563 | + +--------------------------------------+-----------+ + + It is also worth noting that the semi-ventilated facade case refers to + partly transparent compound glass insulation modules, while the non- + ventilated case corresponds to opaque, insulated PV-cladding elements. + However, the emphasis in [3]_ appears to be on ventilation conditions + rather than module construction. References ---------- .. [1] Ross, R. G. Jr., (1981). "Design Techniques for Flat-Plate Photovoltaic Arrays". 15th IEEE Photovoltaic Specialist Conference, Orlando, FL. + .. [2] E. Skoplaki and J. A. Palyvos, "Operating temperature of + photovoltaic modules: A survey of pertinent correlations," Renewable + Energy, vol. 34, no. 1, pp. 23–29, Jan. 2009, + :doi:`10.1016/j.renene.2008.04.009` + .. [3] T. Nordmann and L. Clavadetscher, "Understanding temperature + effects on PV system performance," Proceedings of 3rd World Conference + on Photovoltaic Energy Conversion, May 2003. ''' - # factor of 0.1 converts irradiance from W/m2 to mW/cm2 - return temp_air + (noct - 20.) / 80. * poa_global * 0.1 - - -def _fuentes_hconv(tave, windmod, tinoct, temp_delta, xlen, tilt, - check_reynold): + if (noct is None) & (k is None): + raise ValueError("Either noct or k is required.") + elif (noct is not None) & (k is not None): + raise ValueError("Provide only one of noct or k, not both.") + elif k is None: + # factor of 0.1 converts irradiance from W/m2 to mW/cm2 + return temp_air + (noct - 20.) / 80. * poa_global * 0.1 + elif noct is None: + # k assumes irradiance in W.m-2, dismissing 0.1 factor + return temp_air + k * poa_global + + +def _fuentes_hconv(tave, windmod, temp_delta, xlen, tilt, check_reynold): # Calculate the convective coefficient as in Fuentes 1987 -- a mixture of # free, laminar, and turbulent convection. densair = 0.003484 * 101325.0 / tave # density @@ -788,7 +834,7 @@ def fuentes(poa_global, temp_air, wind_speed, noct_installed, module_height=5, # convective coefficient of top surface of module at NOCT windmod = 1.0 tave = (tinoct + 293.15) / 2 - hconv = _fuentes_hconv(tave, windmod, tinoct, tinoct - 293.15, xlen, + hconv = _fuentes_hconv(tave, windmod, tinoct - 293.15, xlen, surface_tilt, False) # determine the ground temperature ratio and the ratio of the total @@ -836,7 +882,7 @@ def fuentes(poa_global, temp_air, wind_speed, noct_installed, module_height=5, windmod_array = wind_speed * (module_height/wind_height)**0.2 + 1e-4 tmod0 = 293.15 - tmod_array = np.zeros_like(poa_global) + tmod_array = np.zeros_like(poa_global, dtype=float) iterator = zip(tamb_array, sun_array, windmod_array, tsky_array, timedelta_hours) @@ -848,7 +894,7 @@ def fuentes(poa_global, temp_air, wind_speed, noct_installed, module_height=5, for j in range(10): # overall convective coefficient tave = (tmod + tamb) / 2 - hconv = convrat * _fuentes_hconv(tave, windmod, tinoct, + hconv = convrat * _fuentes_hconv(tave, windmod, abs(tmod-tamb), xlen, surface_tilt, True) # sky radiation coefficient (Equation 3) diff --git a/pvlib/tools.py b/pvlib/tools.py index 63406f4e0d..6cb631f852 100644 --- a/pvlib/tools.py +++ b/pvlib/tools.py @@ -471,14 +471,14 @@ def _degrees_to_index(degrees, coordinate): inputmax = 180 outputmax = 4320 else: - raise IndexError("coordinate must be 'latitude' or 'longitude'.") + raise ValueError("coordinate must be 'latitude' or 'longitude'.") inputrange = inputmax - inputmin scale = outputmax/inputrange # number of indices per degree center = inputmin + 1 / scale / 2 # shift to center of index outputmax -= 1 # shift index to zero indexing index = (degrees - center) * scale - err = IndexError('Input, %g, is out of range (%g, %g).' % + err = ValueError('Input, %g, is out of range (%g, %g).' % (degrees, inputmin, inputmax)) # If the index is still out of bounds after rounding, raise an error. @@ -562,7 +562,7 @@ def normalize_max2one(a): return res -def _file_context_manager(filename_or_object, mode='r'): +def _file_context_manager(filename_or_object, mode='r', encoding=None): """ Open a filename/path for reading, or pass a file-like object through unchanged. @@ -584,5 +584,5 @@ def _file_context_manager(filename_or_object, mode='r'): context = contextlib.nullcontext(filename_or_object) else: # otherwise, assume a filename or path - context = open(str(filename_or_object), mode=mode) + context = open(str(filename_or_object), mode=mode, encoding=encoding) return context diff --git a/pvlib/tracking.py b/pvlib/tracking.py index ae50a5bc3f..69c679ef79 100644 --- a/pvlib/tracking.py +++ b/pvlib/tracking.py @@ -43,8 +43,10 @@ def singleaxis(apparent_zenith, solar_azimuth, axis_tilt : float, default 0 The tilt of the axis of rotation (i.e, the y-axis defined by - ``axis_azimuth``) with respect to horizontal. - ``axis_tilt`` must be >= 0 and <= 90. [degrees] + ``axis_azimuth``) with respect to horizontal (degrees). Positive + ``axis_tilt`` is *downward* in the direction of ``axis_azimuth``. For + example, for a tracker with ``axis_azimuth=180`` and ``axis_tilt=10``, + the north end is higher than the south end of the axis. axis_azimuth : float, default 0 A value denoting the compass direction along which the axis of @@ -62,9 +64,7 @@ def singleaxis(apparent_zenith, solar_azimuth, y-axis of the tracker coordinate system. For example, for a tracker with ``axis_azimuth`` oriented to the south, a rotation to ``max_angle`` is towards the west, and a rotation toward ``-max_angle`` - is in the opposite direction, toward the east. Hence, a ``max_angle`` - of 180 degrees (equivalent to max_angle = (-180, 180)) allows the - tracker to achieve its full rotation capability. + is in the opposite direction, toward the east. backtrack : bool, default True Controls whether the tracker has the capability to "backtrack" @@ -84,7 +84,7 @@ def singleaxis(apparent_zenith, solar_azimuth, intersection between the slope containing the tracker axes and a plane perpendicular to the tracker axes. The cross-axis tilt should be specified using a right-handed convention. For example, trackers with - axis azimuth of 180 degrees (heading south) will have a negative + ``axis_azimuth`` of 180 degrees (heading south) will have a negative cross-axis tilt if the tracker axes plane slopes down to the east and positive cross-axis tilt if the tracker axes plane slopes down to the west. Use :func:`~pvlib.tracking.calc_cross_axis_tilt` to calculate @@ -100,9 +100,10 @@ def singleaxis(apparent_zenith, solar_azimuth, rotated panel surface. [degrees] * `surface_tilt`: The angle between the panel surface and the earth surface, accounting for panel rotation. [degrees] - * `surface_azimuth`: The azimuth of the rotated panel, determined by - projecting the vector normal to the panel's surface to the earth's - surface. [degrees] + * `surface_azimuth`: The azimuth of the rotated panel (degrees), + determined by projecting the vector normal to the panel's surface to + the earth's surface. Where ``surface_tilt``=0, ``surface_azimuth`` + is set equal to ``axis_azimuth`` - 90. See also -------- @@ -114,7 +115,7 @@ def singleaxis(apparent_zenith, solar_azimuth, ---------- .. [1] Anderson, K., and Mikofski, M., "Slope-Aware Backtracking for Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020. - https://www.nrel.gov/docs/fy20osti/76626.pdf + :doi:`10.2172/1660126` .. [2] Lorenzo, E., Narvarte, L., and Muñoz, J. (2011). Tracking and back-tracking 19(6), 747–753. :doi:`10.1002/pip.1085` """ @@ -210,6 +211,49 @@ def singleaxis(apparent_zenith, solar_azimuth, return out +def _unit_normal(axis_azimuth, axis_tilt, theta): + """ + Unit normal to rotated tracker surface, in global E-N-Up coordinates, + given by R*(0, 0, 1).T, where: + + R = Rz(-axis_azimuth) Rx(-axis_tilt) Ry(theta) + + Rz is a rotation by -axis_azimuth about the z-axis (axis_azimuth + is negated to convert from an azimuth angle to a rotation angle). Rx is a + rotation by -axis_tilt about the x-axis, where axis_tilt is negated + because pvlib's convention is that the positive y-axis is tilted + downwards. Ry is a rotation by theta about the y-axis. + + Parameters + ---------- + axis_azimuth : scalar + axis_tilt : scalar + theta : scalar or array-like + + Returns + ------- + ndarray + Shape ``theta.shape + (3,)``, with a minimum rank of 2. For 1-D + ``theta`` of length N this is ``(N, 3)``. + """ + + theta = np.atleast_1d(np.asarray(theta)) + + cA, sA = cosd(-axis_azimuth), sind(-axis_azimuth) + cT, sT = cosd(-axis_tilt), sind(-axis_tilt) + + cTh = cosd(theta) + sTh = sind(theta) + + x = sA * sT * cTh + cA * sTh + y = sA * sTh - cA * sT * cTh + z = cT * cTh + + result = np.stack((x, y, z), axis=-1) + + return result + + def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0): """ Calculate the surface tilt and azimuth angles for a given tracker rotation. @@ -223,8 +267,7 @@ def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0): results in ``surface_azimuth`` to the West while ``tracker_theta < 0`` results in ``surface_azimuth`` to the East. [degree] axis_tilt : float, default 0 - The tilt of the axis of rotation with respect to horizontal. - ``axis_tilt`` must be >= 0 and <= 90. [degree] + The tilt of the axis of rotation with respect to horizontal. [degree] axis_azimuth : float, default 0 A value denoting the compass direction along which the axis of rotation lies. Measured east of north. [degree] @@ -234,7 +277,9 @@ def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0): dict or DataFrame Contains keys ``'surface_tilt'`` and ``'surface_azimuth'`` representing the module orientation accounting for tracker rotation and axis - orientation. [degree] + orientation (degree). + Where ``surface_tilt``=0, ``surface_azimuth`` is set equal to + ``axis_azimuth`` - 90. References ---------- @@ -242,19 +287,22 @@ def calc_surface_orientation(tracker_theta, axis_tilt=0, axis_azimuth=0): Tracking of One-Axis Trackers", Technical Report NREL/TP-6A20-58891, July 2013. :doi:`10.2172/1089596` """ + # from [1], Eq. 1 with np.errstate(invalid='ignore', divide='ignore'): surface_tilt = acosd(cosd(tracker_theta) * cosd(axis_tilt)) - # clip(..., -1, +1) to prevent arcsin(1 + epsilon) issues: - azimuth_delta = asind(np.clip(sind(tracker_theta) / sind(surface_tilt), - a_min=-1, a_max=1)) - # Combine Eqs 2, 3, and 4: - azimuth_delta = np.where(abs(tracker_theta) < 90, - azimuth_delta, - -azimuth_delta + np.sign(tracker_theta) * 180) - # handle surface_tilt=0 case: - azimuth_delta = np.where(sind(surface_tilt) != 0, azimuth_delta, 90) - surface_azimuth = (axis_azimuth + azimuth_delta) % 360 + # for surface azimuth deviate from [1] to allow for negative tilt. + # unit normal to rotated tracker surface + unit_normal = _unit_normal(axis_azimuth, axis_tilt, tracker_theta) + + # project unit_normal to x-y plane to calculate azimuth + surface_azimuth = np.degrees( + np.arctan2(unit_normal[..., 0], unit_normal[..., 1])) + + surface_azimuth = np.where(surface_tilt == 0., axis_azimuth - 90., + surface_azimuth) + # constrain angles to [0, 360) + surface_azimuth = np.mod(surface_azimuth, 360.0) out = { 'surface_tilt': surface_tilt, @@ -299,7 +347,7 @@ def calc_axis_tilt(slope_azimuth, slope_tilt, axis_azimuth): ---------- .. [1] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020. - https://www.nrel.gov/docs/fy20osti/76626.pdf + :doi:`10.2172/1660126` """ delta_gamma = axis_azimuth - slope_azimuth # equations 18-19 @@ -378,15 +426,14 @@ def calc_cross_axis_tilt( ---------- slope_azimuth : float direction of the normal to the slope containing the tracker axes, when - projected on the horizontal [degrees] + projected on the horizontal. [degrees] slope_tilt : float - angle of the slope containing the tracker axes, relative to horizontal + angle of the slope containing the tracker axes, relative to horizontal. [degrees] axis_azimuth : float - direction of tracker axes projected on the horizontal [degrees] + direction of tracker axes projected on the horizontal. [degrees] axis_tilt : float - tilt of trackers relative to horizontal. ``axis_tilt`` must be >= 0 - and <= 90. [degree] + tilt of trackers relative to horizontal. [degree] Returns ------- @@ -408,7 +455,7 @@ def calc_cross_axis_tilt( ---------- .. [1] Kevin Anderson and Mark Mikofski, "Slope-Aware Backtracking for Single-Axis Trackers", Technical Report NREL/TP-5K00-76626, July 2020. - https://www.nrel.gov/docs/fy20osti/76626.pdf + :doi:`10.2172/1660126` """ # delta-gamma, difference between axis and slope azimuths delta_gamma = axis_azimuth - slope_azimuth diff --git a/pvlib/transformer.py b/pvlib/transformer.py index 3b66b0beb3..6b5b6208e3 100644 --- a/pvlib/transformer.py +++ b/pvlib/transformer.py @@ -111,7 +111,10 @@ def simple_efficiency( b = 1 c = no_load_loss - input_power_normalized - output_power_normalized = (-b + (b**2 - 4*a*c)**0.5) / (2 * a) + # alternative form of the quadratic equation to avoid + # divide-by-zero when a == 0 + disc = (b*b - 4*a*c)**0.5 + output_power_normalized = 2*c / (-b - disc) output_power = output_power_normalized * transformer_rating return output_power diff --git a/pyproject.toml b/pyproject.toml index f5f3d3c363..f359cc846d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,13 +9,13 @@ description = "A set of functions and classes for simulating the performance of authors = [ { name = "pvlib python Developers", email = "pvlib-admin@googlegroups.com" }, ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ - 'numpy >= 1.19.3', - 'pandas >= 1.3.0', + 'numpy >= 1.21.2', + 'pandas >= 1.3.3', 'pytz', 'requests', - 'scipy >= 1.6.0', + 'scipy >= 1.7.2', 'h5py', ] license = "BSD-3-Clause" @@ -50,7 +50,7 @@ optional = [ 'ephem', 'nrel-pysam', 'numba >= 0.17.0', - 'solarfactors', + 'solarfactors >= 1.6.1', 'statsmodels', ] doc = [ @@ -64,7 +64,7 @@ doc = [ 'pillow', 'sphinx-toggleprompt == 0.5.2', 'sphinx-favicon', - 'solarfactors', + 'solarfactors >= 1.6.1', 'sphinx-hoverxref ~= 1.4.2', # when updating, check that _static/tooltipster_color_theming.css still works ] test = [ @@ -99,7 +99,9 @@ pvlib = ["data/*"] [tool.pytest] junit_family = "xunit2" -testpaths = "tests" +testpaths = [ + "tests" +] # warning messages to suppress from pytest output. useful in cases # where a dependency hasn't addressed a deprecation yet, and there's # nothing we can do to fix it ourselves. @@ -108,8 +110,4 @@ testpaths = "tests" # https://docs.python.org/3/library/warnings.html#the-warnings-filter filterwarnings = [ "ignore:Using or importing the ABCs:DeprecationWarning:.*patsy:", - # deprecation warnings from numpy 1.20 - "ignore:`np.long` is a deprecated alias:DeprecationWarning:.*numba:", - "ignore:`np.int` is a deprecated alias:DeprecationWarning:.*(numba|scipy):", - "ignore:`np.bool` is a deprecated alias:DeprecationWarning:.*numba:", ] diff --git a/tests/conftest.py b/tests/conftest.py index 28ae973390..8a1c1180d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import warnings import pandas as pd +import scipy import os from packaging.version import Version import pytest @@ -85,20 +86,22 @@ def assert_frame_equal(left, right, **kwargs): @pytest.fixture(scope="module") -def nrel_api_key(): - """Supplies pvlib-python's NREL Developer Network API key. - - pvlib's CI utilizes a secret variable set to NREL_API_KEY - to mitigate failures associated with using the default key of - "DEMO_KEY". A user is capable of using their own key this way if - desired however the default key should suffice for testing purposes. +def nlr_api_key(): + """Supplies pvlib-python's NLR Developer Network API key. + + pvlib's CI utilizes a secret variable set to NLR_API_KEY + (formerly NREL_API_KEY) to mitigate failures associated with + using the default key of "DEMO_KEY". A user is capable of using + their own key this way if desired however the default key should + suffice for testing purposes. """ try: - demo_key = os.environ["NREL_API_KEY"] + demo_key = os.environ["NLR_API_KEY"] except KeyError: warnings.warn( - "WARNING: NREL API KEY environment variable not set! " - "Using DEMO_KEY instead. Unexpected failures may occur." + "WARNING: NLR_API_KEY (formerly NREL_API_KEY) environment " + "variable not set! Using DEMO_KEY instead. " + "Unexpected failures may occur." ) demo_key = 'DEMO_KEY' return demo_key @@ -129,6 +132,33 @@ def nrel_api_key(): reason='requires solaranywhere credentials') +try: + # Attempt to load ECMWF API key used for testing + # pvlib.iotools.get_era5 + ecwmf_api_key = os.environ["ECMWF_API_KEY"] + has_ecmwf_credentials = True +except KeyError: + has_ecmwf_credentials = False + +requires_ecmwf_credentials = pytest.mark.skipif( + not has_ecmwf_credentials, + reason='requires ECMWF credentials') + + +try: + # Attempt to load NASA EarthData credentials used for testing + # pvlib.iotools.get_merra2 + earthdata_username = os.environ["EARTHDATA_USERNAME"] + earthdata_password = os.environ["EARTHDATA_PASSWORD"] + has_earthdata_credentials = True +except KeyError: + has_earthdata_credentials = False + +requires_earthdata_credentials = pytest.mark.skipif( + not has_earthdata_credentials, + reason='requires EarthData credentials') + + try: import statsmodels # noqa: F401 has_statsmodels = True @@ -194,6 +224,17 @@ def has_spa_c(): reason="requires pandas>=2.0.0") +# single-diode equation functions have method=='chandrupatla', which relies +# on scipy.optimize.elementwise.find_root, which is only available in +# scipy>=1.15. +# TODO remove this when our minimum scipy is >=1.15 +chandrupatla_available = Version(scipy.__version__) >= Version("1.15.0") +chandrupatla = pytest.param( + "chandrupatla", marks=pytest.mark.skipif(not chandrupatla_available, + reason="needs scipy 1.15") +) + + @pytest.fixture() def golden(): return Location(39.742476, -105.1786, 'America/Denver', 1830.14) diff --git a/tests/iotools/test_crn.py b/tests/iotools/test_crn.py index 882e553d71..55c9d6440e 100644 --- a/tests/iotools/test_crn.py +++ b/tests/iotools/test_crn.py @@ -33,9 +33,11 @@ def columns_unmapped(): @pytest.fixture def dtypes(): + # None indicates string, which is dtype("O") for pandas 2 and StringDtype + # for pandas 3 return [ dtype('int64'), dtype('int64'), dtype('int64'), dtype('int64'), - dtype('int64'), dtype('O'), dtype('float64'), dtype('float64'), + dtype('int64'), None, dtype('float64'), dtype('float64'), dtype('float64'), dtype('float64'), dtype('float64'), dtype('int64'), dtype('float64'), dtype('O'), dtype('int64'), dtype('float64'), dtype('int64'), dtype('float64'), @@ -70,7 +72,10 @@ def test_read_crn(testfile, columns_mapped, dtypes): 0.0, 393.0, 0, 4.8, 'C', 0, 81.0, 0, nan, nan, 1223, 0, 0.64, 0]]) expected = pd.DataFrame(values, columns=columns_mapped, index=index) for (col, _dtype) in zip(expected.columns, dtypes): - expected[col] = expected[col].astype(_dtype) + # use inferred types for strings, to cover both pandas 2 and 3 + if _dtype is not None: + expected[col] = expected[col].astype(_dtype) + out = crn.read_crn(testfile) assert_frame_equal(out, expected) @@ -94,6 +99,8 @@ def test_read_crn_problems(testfile_problems, columns_mapped, dtypes): 1.64, 0]]) expected = pd.DataFrame(values, columns=columns_mapped, index=index) for (col, _dtype) in zip(expected.columns, dtypes): - expected[col] = expected[col].astype(_dtype) + # use inferred types for strings, to cover both pandas 2 and 3 + if _dtype is not None: + expected[col] = expected[col].astype(_dtype) out = crn.read_crn(testfile_problems) assert_frame_equal(out, expected) diff --git a/tests/iotools/test_era5.py b/tests/iotools/test_era5.py new file mode 100644 index 0000000000..452097c83c --- /dev/null +++ b/tests/iotools/test_era5.py @@ -0,0 +1,99 @@ +""" +tests for pvlib/iotools/era5.py +""" + +import pandas as pd +import pytest +import pvlib +import requests +import os +from tests.conftest import RERUNS, RERUNS_DELAY, requires_ecmwf_credentials + + +@pytest.fixture +def params(): + api_key = os.environ["ECMWF_API_KEY"] + + return { + 'latitude': 40.01, 'longitude': -80.01, + 'start': '2020-06-01', 'end': '2020-06-01', + 'variables': ['ghi', 'temp_air'], + 'api_key': api_key, + } + + +@pytest.fixture +def expected(): + index = pd.date_range("2020-06-01 00:00", "2020-06-01 23:59", freq="h", + tz="UTC") + index.name = 'valid_time' + temp_air = [16.6, 15.2, 13.5, 11.2, 10.8, 9.1, 7.3, 6.8, 7.6, 7.4, 8.5, + 8.1, 9.8, 11.5, 14.1, 17.4, 18.3, 20., 20.7, 20.9, 21.5, + 21.6, 21., 20.7] + ghi = [153., 18.4, 0., 0., 0., 0., 0., 0., 0., 0., 0., 60., 229.5, + 427.8, 620.1, 785.5, 910.1, 984.2, 1005.9, 962.4, 844.1, 685.2, + 526.9, 331.4] + df = pd.DataFrame({'temp_air': temp_air, 'ghi': ghi}, index=index) + return df + + +@requires_ecmwf_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_era5(params, expected): + df, meta = pvlib.iotools.get_era5(**params) + pd.testing.assert_frame_equal(df, expected, check_freq=False, atol=0.1) + assert meta['longitude'] == -80.0 + assert meta['latitude'] == 40.0 + assert isinstance(meta['jobID'], str) + + +@requires_ecmwf_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_era5_timezone(params, expected): + params['start'] = pd.to_datetime(params['start']).tz_localize('Etc/GMT+8') + params['end'] = pd.to_datetime(params['end']).tz_localize('Etc/GMT+8') + df, meta = pvlib.iotools.get_era5(**params) + pd.testing.assert_frame_equal(df, expected, check_freq=False, atol=0.1) + assert meta['longitude'] == -80.0 + assert meta['latitude'] == 40.0 + assert isinstance(meta['jobID'], str) + + +@requires_ecmwf_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_era5_map_variables(params, expected): + df, meta = pvlib.iotools.get_era5(**params, map_variables=False) + expected = expected.rename(columns={'temp_air': 't2m', 'ghi': 'ssrd'}) + df['t2m'] -= 273.15 # apply unit conversions manually + df['ssrd'] /= 3600 + pd.testing.assert_frame_equal(df, expected, check_freq=False, atol=0.1) + assert meta['longitude'] == -80.0 + assert meta['latitude'] == 40.0 + assert isinstance(meta['jobID'], str) + + +@requires_ecmwf_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_era5_error(params): + params['variables'] = ['nonexistent'] + match = 'Request failed. Please check the ECMWF website' + with pytest.raises(Exception, match=match): + df, meta = pvlib.iotools.get_era5(**params) + + +@requires_ecmwf_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_era5_timeout(params): + match = 'Request timed out. Try increasing' + with pytest.raises(requests.exceptions.Timeout, match=match): + df, meta = pvlib.iotools.get_era5(**params, timeout=1) + + +def test__m_to_cm(): + from pvlib.iotools.era5 import _m_to_cm + assert _m_to_cm(0.01) == 1 # 0.01 m = 1 cm diff --git a/tests/iotools/test_merra2.py b/tests/iotools/test_merra2.py new file mode 100644 index 0000000000..55f28b04e2 --- /dev/null +++ b/tests/iotools/test_merra2.py @@ -0,0 +1,111 @@ +""" +tests for pvlib/iotools/merra2.py +""" + +import pandas as pd +import pytest +import pvlib +import os +import requests +from tests.conftest import RERUNS, RERUNS_DELAY, requires_earthdata_credentials + + +@pytest.fixture +def params(): + earthdata_username = os.environ["EARTHDATA_USERNAME"] + earthdata_password = os.environ["EARTHDATA_PASSWORD"] + + return { + 'latitude': 40.01, 'longitude': -80.01, + 'start': '2020-06-01 15:00', 'end': '2020-06-01 20:00', + 'dataset': 'M2T1NXRAD.5.12.4', 'variables': ['ALBEDO', 'SWGDN'], + 'username': earthdata_username, 'password': earthdata_password, + } + + +@pytest.fixture +def expected(): + index = pd.date_range("2020-06-01 15:30", "2020-06-01 20:30", freq="h", + tz="UTC") + index.name = 'time' + albedo = [0.163931, 0.1609407, 0.1601474, 0.1612476, 0.164664, 0.1711341] + ghi = [ 930., 1002.75, 1020.25, 981.25, 886.5, 743.5] + df = pd.DataFrame({'albedo': albedo, 'ghi': ghi}, index=index) + return df + + +@pytest.fixture +def expected_meta(): + return { + 'dataset': 'M2T1NXRAD.5.12.4', + 'station': 'GridPointRequestedAt[40.010N_80.010W]', + 'latitude': 40.0, + 'longitude': -80.0, + 'units': {'ALBEDO': '1', 'SWGDN': 'W m-2'} + } + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2(params, expected, expected_meta): + df, meta = pvlib.iotools.get_merra2(**params) + pd.testing.assert_frame_equal(df, expected, check_freq=False) + assert meta == expected_meta + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2_map_variables(params, expected, expected_meta): + df, meta = pvlib.iotools.get_merra2(**params, map_variables=False) + expected = expected.rename(columns={'albedo': 'ALBEDO', 'ghi': 'SWGDN'}) + pd.testing.assert_frame_equal(df, expected, check_freq=False) + assert meta == expected_meta + + +def test_get_merra2_error(): + with pytest.raises(ValueError, match='must be in the same year'): + pvlib.iotools.get_merra2(40, -80, '2019-12-31', '2020-01-02', + username='anything', password='anything', + dataset='anything', variables=[]) + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2_timezones(params, expected, expected_meta): + # check with tz-aware start/end inputs + for key in ['start', 'end']: + dt = pd.to_datetime(params[key]) + params[key] = dt.tz_localize('UTC').tz_convert('Etc/GMT+5') + df, meta = pvlib.iotools.get_merra2(**params) + pd.testing.assert_frame_equal(df, expected, check_freq=False) + assert meta == expected_meta + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2_bad_credentials(params, expected, expected_meta): + params['username'] = 'nonexistent' + with pytest.raises(requests.exceptions.HTTPError, match='Unauthorized'): + pvlib.iotools.get_merra2(**params) + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2_bad_dataset(params, expected, expected_meta): + params['dataset'] = 'nonexistent' + with pytest.raises(requests.exceptions.HTTPError, match='404'): + pvlib.iotools.get_merra2(**params) + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2_bad_variables(params, expected, expected_meta): + params['variables'] = ['nonexistent'] + with pytest.raises(requests.exceptions.HTTPError, match='400'): + pvlib.iotools.get_merra2(**params) diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index 6c6ae6ef4b..ab4b53293d 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -14,12 +14,14 @@ def demo_api_key(): # lat=-3, lon=-60 (Brazil) # lat=51, lon=-114 (Canada) # lat=24, lon=33 (Egypt) - return 'demo0000-0000-0000-0000-000000000000' + demo_api_key = 'demo0000-0000-0000-0000-000000000000' + return demo_api_key @pytest.fixture def demo_url(): - return 'https://demo.meteonorm.com/v1/' + demo_url = 'https://demo.meteonorm.com/v1/' + return demo_url @pytest.fixture @@ -32,11 +34,11 @@ def expected_meta(): 'description': 'Global horizontal irradiance', 'name': 'global_horizontal_irradiance', 'unit': { - 'description': 'Watt per square meter', 'name': 'W/m**2'}}, + 'description': 'watt per square meter', 'name': 'W/m**2'}}, {'aggregation_method': 'average', 'description': 'Global horizontal irradiance with shading taken into account', # noqa: E501 'name': 'global_horizontal_irradiance_with_shading', - 'unit': {'description': 'Watt per square meter', + 'unit': {'description': 'watt per square meter', 'name': 'W/m**2'}}, ], 'surface_azimuth': 180, @@ -51,7 +53,7 @@ def expected_meta(): @pytest.fixture def expected_meteonorm_index(): expected_meteonorm_index = \ - pd.date_range('2023-01-01', '2023-12-31 23:59', freq='1h', tz='UTC') \ + pd.date_range('2025-01-01', '2025-12-31 23:59', freq='1h', tz='UTC') \ + pd.Timedelta(minutes=30) expected_meteonorm_index.freq = None return expected_meteonorm_index @@ -69,13 +71,13 @@ def expected_meteonorm_data(): [0.0, 0.0], [0.0, 0.0], [0.0, 0.0], - [2.5, 2.68309898], - [77.5, 77.47671591], - [165.0, 164.98906908], - [210.75, 210.7458778], - [221.0, 220.99278214], + [3.75, 3.74], + [57.25, 57.20], + [149.0, 148.96], + [242.25, 242.24], + [228.0, 227.98], ] - index = pd.date_range('2023-01-01 00:30', periods=12, freq='1h', tz='UTC') + index = pd.date_range('2025-01-01 00:30', periods=12, freq='1h', tz='UTC') index.freq = None expected = pd.DataFrame(expected, index=index, columns=columns) return expected @@ -114,15 +116,20 @@ def test_get_meteonorm_training( expected_meteonorm_data): data, meta = pvlib.iotools.get_meteonorm_observation_training( latitude=50, longitude=10, - start='2023-01-01', end='2024-01-01', + start='2025-01-01', end='2026-01-01', api_key=demo_api_key, parameters=['ghi', 'global_horizontal_irradiance_with_shading'], time_step='1h', url=demo_url) - assert meta == expected_meta + assert meta.items() >= expected_meta.items() # check stable subset + for key in ['version', 'commit']: + assert key in meta # value changes, so only check presence pd.testing.assert_index_equal(data.index, expected_meteonorm_index) - pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_data) + # meteonorm API only guarantees similar, not identical, results between + # calls. so we allow a small amount of variation with atol. + pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_data, + check_exact=False, atol=1) @pytest.mark.remote_data @@ -205,7 +212,7 @@ def test_get_meteonorm_custom_horizon(demo_api_key, demo_url): @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_forecast_HTTPError(demo_api_key, demo_url): with pytest.raises( - HTTPError, match="unknown parameter: not_a_real_parameter"): + HTTPError, match='invalid parameter "not_a_real_parameter"'): _ = pvlib.iotools.get_meteonorm_forecast_basic( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), @@ -238,7 +245,7 @@ def expected_meteonorm_tmy_meta(): 'aggregation_method': 'average', 'description': 'Diffuse horizontal irradiance', 'name': 'diffuse_horizontal_irradiance', - 'unit': {'description': 'Watt per square meter', + 'unit': {'description': 'watt per square meter', 'name': 'W/m**2'}, }], 'surface_azimuth': 90, @@ -263,13 +270,13 @@ def expected_meteonorm_tmy_data(): [0.], [0.], [0.], - [9.], - [8.4], - [86.6], - [110.5], + [9.07], + [8.44], + [86.64], + [110.44], ] index = pd.date_range( - '2005-01-01', periods=12, freq='1h', tz=3600) + '2030-01-01', periods=12, freq='1h', tz=3600) index.freq = None interval_index = pd.IntervalIndex.from_arrays( index, index + pd.Timedelta(hours=1), closed='left') @@ -301,5 +308,10 @@ def test_get_meteonorm_tmy( interval_index=True, map_variables=False, url=demo_url) - assert meta == expected_meteonorm_tmy_meta - pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_tmy_data) + assert meta.items() >= expected_meteonorm_tmy_meta.items() + for key in ['version', 'commit']: + assert key in meta # value changes, so only check presence + # meteonorm API only guarantees similar, not identical, results between + # calls. so we allow a small amount of variation with atol. + pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_tmy_data, + check_exact=False, atol=1) diff --git a/tests/iotools/test_midc.py b/tests/iotools/test_midc.py index 9a327d1394..636550d23c 100644 --- a/tests/iotools/test_midc.py +++ b/tests/iotools/test_midc.py @@ -23,7 +23,7 @@ def test_mapping(): TESTS_DATA_DIR / 'midc_raw_short_header_20191115.txt') # TODO: not used, remove? -# midc_network_testfile = ('https://midcdmz.nrel.gov/apps/data_api.pl' +# midc_network_testfile = ('https://midcdmz.nlr.gov/apps/data_api.pl' # '?site=UAT&begin=20181018&end=20181019') @@ -43,7 +43,7 @@ def test_midc__format_index_tz_conversion(): data = pd.read_csv(MIDC_TESTFILE) data = data.rename(columns={'MST': 'PST'}) data = midc._format_index(data) - assert data.index[0].tz == pytz.timezone('Etc/GMT+8') + assert str(data.index[0].tz) == 'Etc/GMT+8' def test_midc__format_index_raw(): diff --git a/tests/iotools/test_psm3.py b/tests/iotools/test_psm3.py deleted file mode 100644 index 39de06d234..0000000000 --- a/tests/iotools/test_psm3.py +++ /dev/null @@ -1,191 +0,0 @@ -""" -test iotools for PSM3 -""" - -from pvlib.iotools import psm3 -from tests.conftest import ( - TESTS_DATA_DIR, - RERUNS, - RERUNS_DELAY, - assert_index_equal, - nrel_api_key, -) -import numpy as np -import pandas as pd -import pytest -from requests import HTTPError -from io import StringIO - -from pvlib._deprecation import pvlibDeprecationWarning - - -TMY_TEST_DATA = TESTS_DATA_DIR / 'test_psm3_tmy-2017.csv' -YEAR_TEST_DATA = TESTS_DATA_DIR / 'test_psm3_2017.csv' -YEAR_TEST_DATA_5MIN = TESTS_DATA_DIR / 'test_psm3_2019_5min.csv' -MANUAL_TEST_DATA = TESTS_DATA_DIR / 'test_read_psm3.csv' -LATITUDE, LONGITUDE = 40.5137, -108.5449 -METADATA_FIELDS = [ - 'Source', 'Location ID', 'City', 'State', 'Country', 'Latitude', - 'Longitude', 'Time Zone', 'Elevation', 'Local Time Zone', - 'Dew Point Units', 'DHI Units', 'DNI Units', 'GHI Units', - 'Temperature Units', 'Pressure Units', 'Wind Direction Units', - 'Wind Speed Units', 'Surface Albedo Units', 'Version'] -PVLIB_EMAIL = 'pvlib-admin@googlegroups.com' - - -def assert_psm3_equal(data, metadata, expected): - """check consistency of PSM3 data""" - # check datevec columns - assert np.allclose(data.Year, expected.Year) - assert np.allclose(data.Month, expected.Month) - assert np.allclose(data.Day, expected.Day) - assert np.allclose(data.Hour, expected.Hour) - assert np.allclose(data.Minute, expected.Minute) - # check data columns - assert np.allclose(data.GHI, expected.GHI) - assert np.allclose(data.DNI, expected.DNI) - assert np.allclose(data.DHI, expected.DHI) - assert np.allclose(data.Temperature, expected.Temperature) - assert np.allclose(data.Pressure, expected.Pressure) - assert np.allclose(data['Dew Point'], expected['Dew Point']) - assert np.allclose(data['Surface Albedo'], expected['Surface Albedo']) - assert np.allclose(data['Wind Speed'], expected['Wind Speed']) - assert np.allclose(data['Wind Direction'], expected['Wind Direction']) - # check header - for mf in METADATA_FIELDS: - assert mf in metadata - # check timezone - assert (data.index.tzinfo.zone == 'Etc/GMT%+d' % -metadata['Time Zone']) - - -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_tmy(nrel_api_key): - """test get_psm3 with a TMY""" - data, metadata = psm3.get_psm3(LATITUDE, LONGITUDE, nrel_api_key, - PVLIB_EMAIL, names='tmy-2017', - leap_day=False, map_variables=False) - expected = pd.read_csv(TMY_TEST_DATA) - assert_psm3_equal(data, metadata, expected) - - -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_singleyear(nrel_api_key): - """test get_psm3 with a single year""" - data, metadata = psm3.get_psm3(LATITUDE, LONGITUDE, nrel_api_key, - PVLIB_EMAIL, names='2017', - leap_day=False, map_variables=False, - interval=30) - expected = pd.read_csv(YEAR_TEST_DATA) - assert_psm3_equal(data, metadata, expected) - - -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_5min(nrel_api_key): - """test get_psm3 for 5-minute data""" - data, metadata = psm3.get_psm3(LATITUDE, LONGITUDE, nrel_api_key, - PVLIB_EMAIL, names='2019', interval=5, - leap_day=False, map_variables=False) - assert len(data) == 525600/5 - first_day = data.loc['2019-01-01'] - expected = pd.read_csv(YEAR_TEST_DATA_5MIN) - assert_psm3_equal(first_day, metadata, expected) - - -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_check_leap_day(nrel_api_key): - data_2012, _ = psm3.get_psm3(LATITUDE, LONGITUDE, nrel_api_key, - PVLIB_EMAIL, names="2012", interval=60, - leap_day=True, map_variables=False) - assert len(data_2012) == (8760 + 24) - - -@pytest.mark.parametrize('latitude, longitude, api_key, names, interval', - [(LATITUDE, LONGITUDE, 'BAD', 'tmy-2017', 60), - (51, -5, nrel_api_key, 'tmy-2017', 60), - (LATITUDE, LONGITUDE, nrel_api_key, 'bad', 60), - (LATITUDE, LONGITUDE, nrel_api_key, '2017', 15), - ]) -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_tmy_errors( - latitude, longitude, api_key, names, interval -): - """Test get_psm3() for multiple erroneous input scenarios. - - These scenarios include: - * Bad API key -> HTTP 403 forbidden because api_key is rejected - * Bad latitude/longitude -> Coordinates were not found in the NSRDB. - * Bad name -> Name is not one of the available options. - * Bad interval, single year -> Intervals can only be 30 or 60 minutes. - """ - with pytest.raises(HTTPError) as excinfo: - psm3.get_psm3(latitude, longitude, api_key, PVLIB_EMAIL, - names=names, interval=interval, leap_day=False, - map_variables=False) - # ensure the HTTPError caught isn't due to overuse of the API key - assert "OVER_RATE_LIMIT" not in str(excinfo.value) - - -@pytest.fixture -def io_input(request): - """file-like object for read_psm3""" - with MANUAL_TEST_DATA.open() as f: - data = f.read() - obj = StringIO(data) - return obj - - -def test_parse_psm3(io_input): - """test parse_psm3""" - with pytest.warns(pvlibDeprecationWarning, match='Use read_psm3 instead'): - data, metadata = psm3.parse_psm3(io_input, map_variables=False) - expected = pd.read_csv(YEAR_TEST_DATA) - assert_psm3_equal(data, metadata, expected) - - -def test_read_psm3(): - """test read_psm3""" - data, metadata = psm3.read_psm3(MANUAL_TEST_DATA, map_variables=False) - expected = pd.read_csv(YEAR_TEST_DATA) - assert_psm3_equal(data, metadata, expected) - - -def test_read_psm3_buffer(io_input): - data, metadata = psm3.read_psm3(io_input, map_variables=False) - expected = pd.read_csv(YEAR_TEST_DATA) - assert_psm3_equal(data, metadata, expected) - - -def test_read_psm3_map_variables(): - """test read_psm3 map_variables=True""" - data, metadata = psm3.read_psm3(MANUAL_TEST_DATA, map_variables=True) - columns_mapped = ['Year', 'Month', 'Day', 'Hour', 'Minute', 'dhi', 'ghi', - 'dni', 'ghi_clear', 'dhi_clear', 'dni_clear', - 'Cloud Type', 'temp_dew', 'solar_zenith', - 'Fill Flag', 'albedo', 'wind_speed', - 'wind_direction', 'precipitable_water', - 'relative_humidity', 'temp_air', 'pressure'] - data, metadata = psm3.read_psm3(MANUAL_TEST_DATA, map_variables=True) - assert_index_equal(data.columns, pd.Index(columns_mapped)) - - -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_attribute_mapping(nrel_api_key): - """Test that pvlib names can be passed in as attributes and get correctly - reverse mapped to PSM3 names""" - data, meta = psm3.get_psm3(LATITUDE, LONGITUDE, nrel_api_key, PVLIB_EMAIL, - names=2019, interval=60, - attributes=['ghi', 'wind_speed'], - leap_day=False, map_variables=True) - # Check that columns are in the correct order (GH1647) - expected_columns = [ - 'Year', 'Month', 'Day', 'Hour', 'Minute', 'ghi', 'wind_speed'] - pd.testing.assert_index_equal(pd.Index(expected_columns), data.columns) - assert 'latitude' in meta.keys() - assert 'longitude' in meta.keys() - assert 'altitude' in meta.keys() diff --git a/tests/iotools/test_psm4.py b/tests/iotools/test_psm4.py index 6447aed33b..3b4313b070 100644 --- a/tests/iotools/test_psm4.py +++ b/tests/iotools/test_psm4.py @@ -4,7 +4,7 @@ from pvlib.iotools import psm4 from ..conftest import ( - TESTS_DATA_DIR, RERUNS, RERUNS_DELAY, assert_index_equal, nrel_api_key + TESTS_DATA_DIR, RERUNS, RERUNS_DELAY, assert_index_equal, nlr_api_key ) import numpy as np import pandas as pd @@ -49,15 +49,15 @@ def assert_psm4_equal(data, metadata, expected): for mf in METADATA_FIELDS: assert mf in metadata # check timezone - assert (data.index.tzinfo.zone == 'Etc/GMT%+d' % -metadata['Time Zone']) + assert (str(data.index.tzinfo) == 'Etc/GMT%+d' % -metadata['Time Zone']) @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_nsrdb_psm4_tmy(nrel_api_key): +def test_get_nsrdb_psm4_tmy(nlr_api_key): """test get_nsrdb_psm4_tmy with a TMY""" data, metadata = psm4.get_nsrdb_psm4_tmy(LATITUDE, LONGITUDE, - nrel_api_key, PVLIB_EMAIL, + nlr_api_key, PVLIB_EMAIL, year='tmy-2023', leap_day=False, map_variables=False) @@ -67,10 +67,10 @@ def test_get_nsrdb_psm4_tmy(nrel_api_key): @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_nsrdb_psm4_full_disc(nrel_api_key): +def test_get_nsrdb_psm4_full_disc(nlr_api_key): """test get_nsrdb_psm4_full_disc with a single year""" data, metadata = psm4.get_nsrdb_psm4_full_disc(LATITUDE, LONGITUDE, - nrel_api_key, PVLIB_EMAIL, + nlr_api_key, PVLIB_EMAIL, year='2023', leap_day=False, map_variables=False) @@ -80,10 +80,10 @@ def test_get_nsrdb_psm4_full_disc(nrel_api_key): @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_nsrdb_psm4_conus_singleyear(nrel_api_key): +def test_get_nsrdb_psm4_conus_singleyear(nlr_api_key): """test get_nsrdb_psm4_conus with a single year""" data, metadata = psm4.get_nsrdb_psm4_aggregated(LATITUDE, LONGITUDE, - nrel_api_key, + nlr_api_key, PVLIB_EMAIL, year='2023', leap_day=False, @@ -95,10 +95,10 @@ def test_get_nsrdb_psm4_conus_singleyear(nrel_api_key): @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_nsrdb_psm4_conus_5min(nrel_api_key): +def test_get_nsrdb_psm4_conus_5min(nlr_api_key): """test get_nsrdb_psm4_conus for 5-minute data""" data, metadata = psm4.get_nsrdb_psm4_conus(LATITUDE, LONGITUDE, - nrel_api_key, PVLIB_EMAIL, + nlr_api_key, PVLIB_EMAIL, year='2023', time_step=5, leap_day=False, map_variables=False) @@ -110,10 +110,10 @@ def test_get_nsrdb_psm4_conus_5min(nrel_api_key): @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_nsrdb_psm4_aggregated_check_leap_day(nrel_api_key): +def test_get_nsrdb_psm4_aggregated_check_leap_day(nlr_api_key): """test get_nsrdb_psm4_aggregated for leap day""" data_2012, _ = psm4.get_nsrdb_psm4_aggregated(LATITUDE, LONGITUDE, - nrel_api_key, PVLIB_EMAIL, + nlr_api_key, PVLIB_EMAIL, year="2012", time_step=60, leap_day=True, map_variables=False) @@ -122,9 +122,9 @@ def test_get_nsrdb_psm4_aggregated_check_leap_day(nrel_api_key): @pytest.mark.parametrize('latitude, longitude, api_key, year, time_step', [(LATITUDE, LONGITUDE, 'BAD', '2023', 60), - (51, -5, nrel_api_key, '2023', 60), - (LATITUDE, LONGITUDE, nrel_api_key, 'bad', 60), - (LATITUDE, LONGITUDE, nrel_api_key, '2023', 15), + (51, -5, nlr_api_key, '2023', 60), + (LATITUDE, LONGITUDE, nlr_api_key, 'bad', 60), + (LATITUDE, LONGITUDE, nlr_api_key, '2023', 15), ]) @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) @@ -187,11 +187,11 @@ def test_read_nsrdb_psm4_map_variables(): @pytest.mark.remote_data @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_nsrdb_psm4_aggregated_parameter_mapping(nrel_api_key): +def test_get_nsrdb_psm4_aggregated_parameter_mapping(nlr_api_key): """Test that pvlib names can be passed in as parameters and get correctly reverse mapped to psm4 names""" data, meta = psm4.get_nsrdb_psm4_aggregated( - LATITUDE, LONGITUDE, nrel_api_key, PVLIB_EMAIL, year='2019', + LATITUDE, LONGITUDE, nlr_api_key, PVLIB_EMAIL, year='2019', time_step=60, parameters=['ghi', 'wind_speed'], leap_day=False, map_variables=True) # Check that columns are in the correct order (GH1647) diff --git a/tests/iotools/test_sodapro.py b/tests/iotools/test_sodapro.py index 7105e9ac98..4276b8826e 100644 --- a/tests/iotools/test_sodapro.py +++ b/tests/iotools/test_sodapro.py @@ -21,20 +21,24 @@ index_verbose = pd.date_range('2020-06-01 12', periods=4, freq='1min', tz='UTC') -index_monthly = pd.date_range('2020-01-01', periods=4, freq='1M') +index_monthly = pd.to_datetime(['2020-01-31', '2020-02-29', '2020-03-31', + '2020-04-30']) dtypes_mcclear_verbose = [ - 'object', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', + # None indicates string, which differs between pandas 2 and 3 + None, 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'int64', 'float64', 'float64', 'float64', 'float64'] dtypes_mcclear = [ - 'object', 'float64', 'float64', 'float64', 'float64', 'float64'] + # None indicates string, which differs between pandas 2 and 3 + None, 'float64', 'float64', 'float64', 'float64', 'float64'] dtypes_radiation_verbose = [ - 'object', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', + # None indicates string, which differs between pandas 2 and 3 + None, 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'int64', 'float64', 'float64', @@ -42,7 +46,8 @@ 'float64', 'float64'] dtypes_radiation = [ - 'object', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', + # None indicates string, which differs between pandas 2 and 3 + None, 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64', 'float64'] @@ -153,7 +158,9 @@ def generate_expected_dataframe(values, columns, index, dtypes): expected = pd.DataFrame(values, columns=columns, index=index) expected.index.freq = None for (col, _dtype) in zip(expected.columns, dtypes): - expected[col] = expected[col].astype(_dtype) + if _dtype is not None: + # for None (string), use inferred type for pandas 2/3 compat + expected[col] = expected[col].astype(_dtype) return expected diff --git a/tests/iotools/test_solargis.py b/tests/iotools/test_solargis.py index f7aedd58f0..eced226595 100644 --- a/tests/iotools/test_solargis.py +++ b/tests/iotools/test_solargis.py @@ -65,7 +65,8 @@ def test_get_solargis_utc_start_timestamp(hourly_index_start_utc): @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_solargis_http_error(): # Test if HTTPError is raised if date outside range is specified - with pytest.raises(requests.HTTPError, match="data coverage"): + match = r"request fromDate .* is before the available start date" + with pytest.raises(requests.HTTPError, match=match): _, _ = pvlib.iotools.get_solargis( latitude=48.61259, longitude=20.827079, start='1920-01-01', end='1920-01-01', # date outside range diff --git a/tests/iotools/test_tmy.py b/tests/iotools/test_tmy.py index 63d37ac830..02ffe7042f 100644 --- a/tests/iotools/test_tmy.py +++ b/tests/iotools/test_tmy.py @@ -19,6 +19,13 @@ def test_read_tmy3(): tmy.read_tmy3(TMY3_TESTFILE, map_variables=False) +def test_read_tmy3_buffer(): + with open(TMY3_TESTFILE) as f: + data, _ = tmy.read_tmy3(f, map_variables=False) + assert 'GHI source' in data.columns + assert len(data) == 8760 + + def test_read_tmy3_norecolumn(): data, _ = tmy.read_tmy3(TMY3_TESTFILE, map_variables=False) assert 'GHI source' in data.columns @@ -86,7 +93,7 @@ def test_gh865_read_tmy3_feb_leapyear_hr24(): assert all(data.index[:-1].year == 1990) assert data.index[-1].year == 1991 # let's do a quick sanity check, are the indices monotonically increasing? - assert all(np.diff(data.index.view(np.int64)) == 3600000000000) + assert all(np.diff(data.index) == pd.Timedelta(hours=1)) # according to the TMY3 manual, each record corresponds to the previous # hour so check that the 1st hour is 1AM and the last hour is midnite assert data.index[0].hour == 1 diff --git a/tests/ivtools/sdm/test_desoto.py b/tests/ivtools/sdm/test_desoto.py index b861b26819..710bf15d51 100644 --- a/tests/ivtools/sdm/test_desoto.py +++ b/tests/ivtools/sdm/test_desoto.py @@ -92,3 +92,27 @@ def test_fit_desoto_sandia(cec_params_cansol_cs5p_220p): assert_allclose(result['dEgdT'], -0.0002677) assert_allclose(result['EgRef'], 1.3112547292120638) assert_allclose(result['cells_in_series'], specs['cells_in_series']) + + +def test_fit_desoto_batzelis(): + params = {'i_sc': 15.98, 'v_oc': 50.26, 'i_mp': 15.27, 'v_mp': 42.57, + 'alpha_sc': 0.007351, 'beta_voc': -0.120624} + expected = { # calculated with the function itself + 'alpha_sc': 0.007351, + 'a_ref': 1.7257632483733483, + 'I_L_ref': 15.985408866796396, + 'I_o_ref': 3.594308384705643e-12, + 'R_sh_ref': 389.4379947026243, + 'R_s': 0.13181590981241956, + } + out = sdm.fit_desoto_batzelis(**params) + for k in expected: + assert out[k] == pytest.approx(expected[k]) + + # ensure the STC values are reproduced + iv = pvsystem.singlediode(out['I_L_ref'], out['I_o_ref'], out['R_s'], + out['R_sh_ref'], out['a_ref']) + assert iv['i_sc'] == pytest.approx(params['i_sc']) + assert iv['i_mp'] == pytest.approx(params['i_mp'], rel=3e-3) + assert iv['v_oc'] == pytest.approx(params['v_oc'], rel=3e-4) + assert iv['v_mp'] == pytest.approx(params['v_mp'], rel=4e-3) diff --git a/tests/ivtools/test_sde.py b/tests/ivtools/test_sde.py index a465d448c4..079dad2091 100644 --- a/tests/ivtools/test_sde.py +++ b/tests/ivtools/test_sde.py @@ -57,18 +57,18 @@ def test_fit_sandia_simple_bad_iv(get_bad_iv_curves): (np.array([3., 2.9, 2.8, 2.7, 2.6, 2.5, 2.4, 1.7, 0.8, 0.]), np.array([0., 0.2, 0.4, 0.6, 0.8, 1., 1.2, 1.4, 1.45, 1.5]), 10., - (2.3392, 11.6865, -.232, -.2596, -.7119)), + (2.3392, 11.6865, -.2596, -.232, -.7119)), (np.array( [5., 4.9, 4.8, 4.7, 4.6, 4.5, 4.4, 4.3, 4.2, 4.1, 4., 3.8, 3.5, 1.7, 0.]), np.array( [0., .1, .2, .3, .4, .5, .6, .7, .8, .9, 1., 1.1, 1.18, 1.2, 1.22]), 15., - (-22.0795, 27.1196, -4.2076, -.0056, -.0498))]) + (-22.0795, 27.1196, -.0056, -4.2076, -.0498))]) def test__fit_sandia_cocontent(i, v, nsvth, expected): # test confirms agreement with Matlab code. The returned parameters # are nonsense - iph, io, rsh, rs, n = sde._fit_sandia_cocontent(v, i, nsvth) + iph, io, rs, rsh, n = sde._fit_sandia_cocontent(v, i, nsvth) np.testing.assert_allclose(iph, np.array(expected[0]), atol=.0001) np.testing.assert_allclose(io, np.array([expected[1]]), atol=.0001) np.testing.assert_allclose(rs, np.array([expected[2]]), atol=.0001) diff --git a/tests/ivtools/test_utils.py b/tests/ivtools/test_utils.py index 24c5cd81ad..ae87d9c496 100644 --- a/tests/ivtools/test_utils.py +++ b/tests/ivtools/test_utils.py @@ -3,6 +3,7 @@ import pytest from pvlib.ivtools.utils import _numdiff, rectify_iv_curve, astm_e1036 from pvlib.ivtools.utils import _schumaker_qspline +from pvlib.ivtools.utils import _lambertw_pvlib from tests.conftest import TESTS_DATA_DIR @@ -171,3 +172,23 @@ def test_astm_e1036_fit_points(v_array, i_array): 'ff': 0.7520255886236707} result.pop('mp_fit') assert result == pytest.approx(expected) + + +def test_lambertw_pvlib(): + test_x = np.array([0., 1.e-10, 1., 10., 100., 1.e+10, 1.e+100, 1.e+300]) + # known solution from scipy.special.lambertw + # scipy 1.7.1, python 3.13.1, numpy 2.3.5 + expected = np.array([ + 0.0000000000000000e+00, 9.9999999989999997e-11, 5.6714329040978384e-01, + 1.7455280027406994e+00, 3.3856301402900502e+00, 2.0028685413304952e+01, + 2.2484310644511851e+02, 6.8424720862976085e+02]) + result = _lambertw_pvlib(test_x) + assert np.allclose(result, expected, rtol=1e-14) + # with float input + for x, known in zip(test_x[[1, 5]], expected[[1, 5]]): + result = _lambertw_pvlib(x) + assert np.isclose(result, known) + # with 1d array + for x, known in zip(test_x[[1, 5]], expected[[1, 5]]): + result = _lambertw_pvlib(np.array([x])) + assert np.isclose(result, known) diff --git a/tests/spectrum/test_mismatch.py b/tests/spectrum/test_mismatch.py index edd780b2b1..f0443d4ce7 100644 --- a/tests/spectrum/test_mismatch.py +++ b/tests/spectrum/test_mismatch.py @@ -288,3 +288,90 @@ def test_spectral_factor_jrc_supplied_ambiguous(): with pytest.raises(ValueError, match='No valid input provided'): spectrum.spectral_factor_jrc(1.0, 0.8, module_type=None, coefficients=None) + + +@pytest.mark.parametrize("module_type,expected", [ + ('cdte', np.array( + [0.992801, 1.00004, 1.011576, 0.995003, 0.950156, 0.975665])), + ('monosi', np.array( + [1.000152, 0.969588, 0.984636, 1.015405, 1.024238, 1.005061])), + ('cigs', np.array( + [1.004621, 0.956719, 0.971668, 1.0254, 1.060066, 1.020196])), + ('asi', np.array( + [0.986968, 1.049725, 1.051978, 0.957968, 0.842258, 0.941927])), +]) +def test_spectral_factor_polo(module_type, expected): + pws = np.array([0.96, 0.96, 1.85, 1.88, 0.66, 0.66]) + aods = np.array([0.085, 0.085, 0.16, 0.19, 0.088, 0.088]) + ams = np.array([1.34, 1.34, 2.2, 2.2, 2.6, 2.6]) + aois = np.array([46.0, 76.0, 74.0, 28.0, 24.0, 55.0]) + pressure = np.array([101300, 101400, 100500, 101325, 80000, 120000]) + alb = np.array([0.15, 0.2, 0.3, 0.18, 0.32, 0.26]) + out = spectrum.spectral_factor_polo( + pws, ams, aods, aois, pressure, module_type=module_type, albedo=alb) + np.testing.assert_allclose(out, expected, atol=1e-6) + + +@pytest.fixture +def polo_inputs(): + return {'precipitable_water': 0.96, + 'airmass_absolute': 1.34, + 'aod500': 0.085, + 'aoi': 76, + 'pressure': 101400, + 'albedo': 0.2} + + +def test_spectral_factor_polo_coefficients(polo_inputs): + # test that supplying custom coefficients works as expected + coefficients = ( + (0.0027, 10.34, 9.48, 0.31, 0.00077, 0.006) # base Si coeffs + + (0, -0.003, 1.0) # Si albedo correction coeffs + ) + out = spectrum.spectral_factor_polo(**polo_inputs, + coefficients=coefficients) + np.testing.assert_allclose(out, 0.969588, atol=1e-6) + + +def test_spectral_factor_polo_errors(polo_inputs): + with pytest.raises(ValueError, match='Must provide either'): + spectrum.spectral_factor_polo(**polo_inputs) + with pytest.raises(ValueError, match='Only one of'): + spectrum.spectral_factor_polo(**polo_inputs, module_type='CdTe', + coefficients=(1, 1, 1, 1, 1, 1)) + + +def test_spectral_factor_polo_types(polo_inputs): + # float: + out = spectrum.spectral_factor_polo(**polo_inputs, module_type='monosi') + assert isinstance(out, float) + np.testing.assert_allclose(out, 0.969588, atol=1e-6) + + # array: + arrays = {k: np.array([v, v]) for k, v in polo_inputs.items()} + out = spectrum.spectral_factor_polo(**arrays, module_type='monosi') + assert isinstance(out, np.ndarray) + np.testing.assert_allclose(out, [0.969588]*2, atol=1e-6) + + # series: + series = {k: pd.Series(v) for k, v in arrays.items()} + out = spectrum.spectral_factor_polo(**series, module_type='monosi') + assert isinstance(out, pd.Series) + pd.testing.assert_series_equal(out, pd.Series([0.969588]*2), atol=1e-6) + + +def test_spectral_factor_polo_NaN(polo_inputs): + # nan in -> nan out + for key in polo_inputs: + inputs = polo_inputs.copy() + inputs[key] = np.nan + out = spectrum.spectral_factor_polo(**inputs, module_type='monosi') + assert np.isnan(out) + + +def test_spectral_factor_polo_aoi_gt_90(polo_inputs): + polo_inputs['aoi'] = 95 + out95 = spectrum.spectral_factor_polo(**polo_inputs, module_type='monosi') + polo_inputs['aoi'] = 90 + out90 = spectrum.spectral_factor_polo(**polo_inputs, module_type='monosi') + assert out95 == out90 diff --git a/tests/spectrum/test_spectrl2.py b/tests/spectrum/test_spectrl2.py index 38df01830f..c29429c7ea 100644 --- a/tests/spectrum/test_spectrl2.py +++ b/tests/spectrum/test_spectrl2.py @@ -6,7 +6,7 @@ def test_spectrl2(spectrl2_data): - # compare against output from solar_utils wrapper around NREL spectrl2_2.c + # compare against output from solar_utils wrapper around NLR spectrl2_2.c kwargs, expected = spectrl2_data actual = spectrum.spectrl2(**kwargs) assert_allclose(expected['wavelength'].values, actual['wavelength']) diff --git a/tests/test_clearsky.py b/tests/test_clearsky.py index 0ef5dfeacd..687dd9133e 100644 --- a/tests/test_clearsky.py +++ b/tests/test_clearsky.py @@ -505,13 +505,13 @@ def monthly_lt_nointerp(lat, lon, time=months): monthly_lt_nointerp(-90, 180), [1.35, 1.7, 1.35, 1.35, 1.35, 1.35, 1.35, 1.35, 1.35, 1.35, 1.35, 1.7]) # test out of range exceptions at corners - with pytest.raises(IndexError): + with pytest.raises(ValueError, match="out of range"): monthly_lt_nointerp(91, -122) # exceeds max latitude - with pytest.raises(IndexError): + with pytest.raises(ValueError, match="out of range"): monthly_lt_nointerp(38.2, 181) # exceeds max longitude - with pytest.raises(IndexError): + with pytest.raises(ValueError, match="out of range"): monthly_lt_nointerp(-91, -122) # exceeds min latitude - with pytest.raises(IndexError): + with pytest.raises(ValueError, match="out of range"): monthly_lt_nointerp(38.2, -181) # exceeds min longitude @@ -688,6 +688,14 @@ def test_detect_clearsky_window_too_short(detect_clearsky_data): clearsky.detect_clearsky(expected['GHI'], cs['ghi'], window_length=2) +def test_detect_clearsky_infer_checks(detect_clearsky_threshold_data): + # GH 2542 + expected, cs = detect_clearsky_threshold_data + expected = expected.resample('10min').mean() + cs = cs.resample('10min').mean() + clearsky.detect_clearsky(expected['GHI'], cs['ghi'], infer_limits=True) + + @pytest.mark.parametrize("window_length", [5, 10, 15, 20, 25]) def test_detect_clearsky_optimizer_not_failed( detect_clearsky_data, window_length diff --git a/tests/test_irradiance.py b/tests/test_irradiance.py index b36a3ea9c5..a416636ae9 100644 --- a/tests/test_irradiance.py +++ b/tests/test_irradiance.py @@ -16,7 +16,6 @@ assert_series_equal, requires_ephem, requires_numba, - fail_on_pvlib_version, ) from pvlib._deprecation import pvlibDeprecationWarning @@ -210,12 +209,14 @@ def test_haydavies(irrad_data, ephem_data, dni_et): def test_haydavies_components(irrad_data, ephem_data, dni_et): + keys = ['poa_sky_diffuse', 'poa_isotropic', 'poa_circumsolar', + 'poa_horizon'] expected = pd.DataFrame(np.array( [[0, 27.1775, 102.9949, 33.1909], [0, 27.1775, 30.1818, 27.9837], [0, 0, 72.8130, 5.2071], [0, 0, 0, 0]]).T, - columns=['sky_diffuse', 'isotropic', 'circumsolar', 'horizon'], + columns=keys, index=irrad_data.index ) # pandas @@ -229,23 +230,16 @@ def test_haydavies_components(irrad_data, ephem_data, dni_et): 40, 180, irrad_data['dhi'].values, irrad_data['dni'].values, dni_et, ephem_data['apparent_zenith'].values, ephem_data['azimuth'].values, return_components=True) - assert_allclose(result['sky_diffuse'], expected['sky_diffuse'], atol=1e-4) - assert_allclose(result['isotropic'], expected['isotropic'], atol=1e-4) - assert_allclose(result['circumsolar'], expected['circumsolar'], atol=1e-4) - assert_allclose(result['horizon'], expected['horizon'], atol=1e-4) + for key in keys: + assert_allclose(result[key], expected[key], atol=1e-4) assert isinstance(result, dict) # scalar result = irradiance.haydavies( 40, 180, irrad_data['dhi'].values[-1], irrad_data['dni'].values[-1], dni_et[-1], ephem_data['apparent_zenith'].values[-1], ephem_data['azimuth'].values[-1], return_components=True) - assert_allclose(result['sky_diffuse'], expected['sky_diffuse'].iloc[-1], - atol=1e-4) - assert_allclose(result['isotropic'], expected['isotropic'].iloc[-1], - atol=1e-4) - assert_allclose(result['circumsolar'], expected['circumsolar'].iloc[-1], - atol=1e-4) - assert_allclose(result['horizon'], expected['horizon'].iloc[-1], atol=1e-4) + for key in keys: + assert_allclose(result[key], expected[key].iloc[-1], atol=1e-4) assert isinstance(result, dict) @@ -312,13 +306,14 @@ def test_perez_components(irrad_data, ephem_data, dni_et, relative_airmass): [0., 26.84138589, np.nan, 31.72696071], [0., 0., np.nan, 4.47966439], [0., 4.62212181, np.nan, 9.25316454]]).T, - columns=['sky_diffuse', 'isotropic', 'circumsolar', 'horizon'], + columns=['poa_sky_diffuse', 'poa_isotropic', 'poa_circumsolar', + 'poa_horizon'], index=irrad_data.index ) - expected_for_sum = expected['sky_diffuse'].copy() + expected_for_sum = expected['poa_sky_diffuse'].copy() expected_for_sum.iloc[2] = 0 sum_components = out.iloc[:, 1:].sum(axis=1) - sum_components.name = 'sky_diffuse' + sum_components.name = 'poa_sky_diffuse' assert_frame_equal(out, expected, check_less_precise=2) assert_series_equal(sum_components, expected_for_sum, check_less_precise=2) @@ -338,13 +333,14 @@ def test_perez_driesse_components(irrad_data, ephem_data, dni_et, [0., 25.806, np.nan, 33.181], [0., 0.000, np.nan, 4.197], [0., 4.184, np.nan, 10.018]]).T, - columns=['sky_diffuse', 'isotropic', 'circumsolar', 'horizon'], + columns=['poa_sky_diffuse', 'poa_isotropic', 'poa_circumsolar', + 'poa_horizon'], index=irrad_data.index ) - expected_for_sum = expected['sky_diffuse'].copy() + expected_for_sum = expected['poa_sky_diffuse'].copy() expected_for_sum.iloc[2] = 0 sum_components = out.iloc[:, 1:].sum(axis=1) - sum_components.name = 'sky_diffuse' + sum_components.name = 'poa_sky_diffuse' assert_frame_equal(out, expected, check_less_precise=2) assert_series_equal(sum_components, expected_for_sum, check_less_precise=2) @@ -384,13 +380,14 @@ def test_perez_negative_horizon(): [166.785419, 142.24475, 119.173875, 83.525150, 45.725931], [113.548755, 16.09757, 9.956174, 3.142467, 0], [1.076010, -6.13353, -5.262151, -3.831230, -2.208923]]).T, - columns=['sky_diffuse', 'isotropic', 'circumsolar', 'horizon'], + columns=['poa_sky_diffuse', 'poa_isotropic', 'poa_circumsolar', + 'poa_horizon'], index=times ) - expected_for_sum = expected['sky_diffuse'].copy() + expected_for_sum = expected['poa_sky_diffuse'].copy() sum_components = out.iloc[:, 1:].sum(axis=1) - sum_components.name = 'sky_diffuse' + sum_components.name = 'poa_sky_diffuse' assert_frame_equal(out, expected, check_less_precise=2) assert_series_equal(sum_components, expected_for_sum, check_less_precise=2) @@ -1112,20 +1109,6 @@ def test_dirindex(times): equal_nan=True) -@fail_on_pvlib_version("0.14") -def test_dirindex_ghi_clearsky_deprecation(): - times = pd.DatetimeIndex(['2014-06-24T18-1200']) - ghi = pd.Series([1038.62], index=times) - ghi_clearsky = pd.Series([1042.48031487], index=times) - dni_clearsky = pd.Series([939.95469881], index=times) - zenith = pd.Series([10.56413562], index=times) - pressure, tdew = 93193, 10 - with pytest.warns(pvlibDeprecationWarning, match='ghi_clear'): - irradiance.dirindex( - ghi=ghi, ghi_clearsky=ghi_clearsky, dni_clear=dni_clearsky, - zenith=zenith, times=times, pressure=pressure, temp_dew=tdew) - - def test_dirindex_min_cos_zenith_max_zenith(): # map out behavior under difficult conditions with various # limiting kwargs settings @@ -1157,19 +1140,6 @@ def test_dirindex_min_cos_zenith_max_zenith(): assert_series_equal(out, expected) -@fail_on_pvlib_version("0.14") -def test_dirindex_dni_clearsky_deprecation(): - times = pd.DatetimeIndex(['2014-06-24T12-0700', '2014-06-24T18-0700']) - ghi = pd.Series([0, 1], index=times) - ghi_clearsky = pd.Series([0, 1], index=times) - dni_clear = pd.Series([0, 5], index=times) - solar_zenith = pd.Series([90, 89.99], index=times) - with pytest.warns(pvlibDeprecationWarning, match='dni_clear'): - irradiance.dirindex(ghi, ghi_clearsky, dni_clearsky=dni_clear, - zenith=solar_zenith, times=times, - min_cos_zenith=0) - - def test_dni(): ghi = pd.Series([90, 100, 100, 100, 100]) dhi = pd.Series([100, 90, 50, 50, 50]) @@ -1188,17 +1158,6 @@ def test_dni(): 146.190220008, 573.685662283])) -@fail_on_pvlib_version("0.14") -def test_dni_dni_clearsky_deprecation(): - ghi = pd.Series([90, 100, 100, 100, 100]) - dhi = pd.Series([100, 90, 50, 50, 50]) - zenith = pd.Series([80, 100, 85, 70, 85]) - dni_clear = pd.Series([50, 50, 200, 50, 300]) - with pytest.warns(pvlibDeprecationWarning, match='dni_clear'): - irradiance.dni(ghi, dhi, zenith, - clearsky_dni=dni_clear, clearsky_tolerance=2) - - @pytest.mark.parametrize( 'surface_tilt,surface_azimuth,solar_zenith,' + 'solar_azimuth,aoi_expected,aoi_proj_expected', @@ -1291,13 +1250,6 @@ def test_clearsky_index(): assert_series_equal(out, expected) -@fail_on_pvlib_version("0.14") -def test_clearsky_index_clearsky_ghi_deprecation(): - with pytest.warns(pvlibDeprecationWarning, match='ghi_clear'): - ghi, clearsky_ghi = 200, 300 - irradiance.clearsky_index(ghi, clearsky_ghi=clearsky_ghi) - - def test_clearness_index(): ghi = np.array([-1, 0, 1, 1000]) solar_zenith = np.array([180, 90, 89.999, 0]) diff --git a/tests/test_modelchain.py b/tests/test_modelchain.py index ecc2c41447..9ea804f014 100644 --- a/tests/test_modelchain.py +++ b/tests/test_modelchain.py @@ -139,7 +139,8 @@ def cec_dc_adr_ac_system(sam_data, cec_module_cs5p_220m, module=module_parameters['Name'], module_parameters=module_parameters, temperature_model_parameters=temp_model_params, - inverter_parameters=inverter) + inverter_parameters=inverter, + modules_per_string=13) return system @@ -1382,6 +1383,12 @@ def test_ac_models(sapm_dc_snl_ac_system, cec_dc_adr_ac_system, 'adr': 'adr', 'pvwatts': 'pvwatts', 'pvwatts_multi': 'pvwatts'} + inverter_to_ac_model_param = { + 'sandia': 'Paco', + 'sandia_multi': 'Paco', + 'adr': 'Pnom', + 'pvwatts': 'pdc0', + 'pvwatts_multi': 'pdc0'} ac_model = inverter_to_ac_model[inverter_model] system = ac_systems[inverter_model] @@ -1398,7 +1405,12 @@ def test_ac_models(sapm_dc_snl_ac_system, cec_dc_adr_ac_system, assert m.call_count == 1 assert isinstance(mc.results.ac, pd.Series) assert not mc.results.ac.empty - assert mc.results.ac.iloc[1] < 1 + # irradiance 800 W/m2 at 1st timestamp + inv_param = mc.system.inverter_parameters[ + inverter_to_ac_model_param[inverter_model]] + assert (mc.results.ac.iloc[0] > inv_param / 2.) + # irradiance 0 W/m2 at the 2nd timestamp + assert (np.isnan(mc.results.ac.iloc[1]) or (mc.results.ac.iloc[1] < 1)) def test_ac_model_user_func(pvwatts_dc_pvwatts_ac_system, location, weather, diff --git a/tests/test_pvarray.py b/tests/test_pvarray.py index 693ef78b2a..e614f885db 100644 --- a/tests/test_pvarray.py +++ b/tests/test_pvarray.py @@ -1,4 +1,5 @@ import numpy as np +from numpy import nan import pandas as pd from numpy.testing import assert_allclose from .conftest import assert_series_equal @@ -50,10 +51,12 @@ def test_pvefficiency_adr_round_trip(): def test_huld(): + # tests with default k_version='pvgis5' pdc0 = 100 res = pvarray.huld(1000, 25, pdc0, cell_type='cSi') assert np.isclose(res, pdc0) - exp_sum = np.exp(1) * (np.sum(pvarray._infer_k_huld('cSi', pdc0)) + pdc0) + k = pvarray._infer_k_huld('cSi', pdc0, 'pvgis5') + exp_sum = np.exp(1) * (np.sum(k) + pdc0) res = pvarray.huld(1000*np.exp(1), 26, pdc0, cell_type='cSi') assert np.isclose(res, exp_sum) res = pvarray.huld(100, 30, pdc0, k=(1, 1, 1, 1, 1, 1)) @@ -67,5 +70,86 @@ def test_huld(): res = pvarray.huld(eff_irr, tm, pdc0, k=(1, 1, 1, 1, 1, 1)) assert_series_equal(res, expected) with pytest.raises(ValueError, - match='Either k or cell_type must be specified'): - res = pvarray.huld(1000, 25, 100) + match='Either k or cell_type must be specified' + ): + pvarray.huld(1000, 25, 100) + + +def test_huld_params(): + """Test Huld with built-in coefficients.""" + pdc0 = 100 + # Use non-reference values so coefficients affect the result + eff_irr = 800 # W/m^2 (not 1000) + temp_mod = 35 # deg C (not 25) + # calculated by C. Hansen using Excel, 2025 + expected = {'pvgis5': {'csi': 76.405089, + 'cis': 77.086016, + 'cdte': 78.642762 + }, + 'pvgis6': {'csi': 77.649421, + 'cis': 77.723110, + 'cdte': 77.500399 + } + } + # Test with PVGIS5 coefficients for all cell types + for yr in expected: + for cell_type in expected[yr]: + result = pvarray.huld(eff_irr, temp_mod, pdc0, cell_type=cell_type, + k_version=yr) + assert np.isclose(result, expected[yr][cell_type]) + + +def test_huld_errors(): + # Check errors + pdc0 = 100 + # Use non-reference values so coefficients affect the result + eff_irr = 800 # W/m^2 (not 1000) + temp_mod = 35 # deg C (not 25) + # provide both cell_type and k_version + with pytest.raises(KeyError): + pvarray.huld( + eff_irr, temp_mod, pdc0, cell_type='invalid', k_version='pvgis5' + ) + # provide invalid k_version + with pytest.raises(ValueError, match='Invalid k_version=2021'): + pvarray.huld( + eff_irr, temp_mod, pdc0, cell_type='csi', k_version='2021' + ) + + +def test_batzelis(): + params = {'i_sc': 15.98, 'v_oc': 50.26, 'i_mp': 15.27, 'v_mp': 42.57, + 'alpha_sc': 0.007351, 'beta_voc': -0.120624} + g = np.array([1000, 500, 1200, 500, 1200, 0, nan, 1000]) + t = np.array([25, 20, 20, 50, 50, 25, 0, nan]) + expected = { # these values were computed using pvarray.batzelis itself + 'p_mp': [650.044, 328.599, 789.136, 300.079, 723.401, 0, nan, nan], + 'i_mp': [ 15.270, 7.626, 18.302, 7.680, 18.433, 0, nan, nan], + 'v_mp': [ 42.570, 43.090, 43.117, 39.071, 39.246, 0, nan, nan], + 'i_sc': [ 15.980, 7.972, 19.132, 8.082, 19.397, 0, nan, nan], + 'v_oc': [ 50.260, 49.687, 51.172, 45.948, 47.585, 0, nan, nan], + } + + # numpy array + actual = pvarray.batzelis(g, t, **params) + for key, exp in expected.items(): + np.testing.assert_allclose(actual[key], exp, atol=1e-3) + + # pandas series + actual = pvarray.batzelis(pd.Series(g), pd.Series(t), **params) + assert isinstance(actual, pd.DataFrame) + for key, exp in expected.items(): + np.testing.assert_allclose(actual[key], pd.Series(exp), atol=1e-3) + + # scalar + actual = pvarray.batzelis(g[1], t[1], **params) + for key, exp in expected.items(): + assert pytest.approx(exp[1], abs=1e-3) == actual[key] + + +def test_batzelis_negative_voltage(): + params = {'i_sc': 15.98, 'v_oc': 50.26, 'i_mp': 15.27, 'v_mp': 42.57, + 'alpha_sc': 0.007351, 'beta_voc': -0.120624} + actual = pvarray.batzelis(1e-10, 25, **params) + assert actual['v_mp'] == 0 + assert actual['v_oc'] == 0 diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index b58f9fd9e4..267d3b14c9 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -22,6 +22,8 @@ from tests.test_singlediode import get_pvsyst_fs_495 +from .conftest import chandrupatla, chandrupatla_available + @pytest.mark.parametrize('iam_model,model_params', [ ('ashrae', {'b': 0.05}), @@ -492,6 +494,45 @@ def test_PVSystem_faiman_celltemp(mocker): assert_allclose(out, 56.4, atol=1e-1) +def test_PVSystem_faiman_rad_celltemp(mocker): + longwave_down = 50 # arbitrary value + # default values, u0 and u1 being adjusted in same proportion as in + # https://www.osti.gov/servlets/purl/1884890/ (not suggested, just example) + u0, u1 = 25.0*0.86, 6.84*0.88 + sky_view = 1.0 + emissivity = 0.88 + + temp_model_params = {'u0': u0, 'u1': u1, 'sky_view': sky_view, + 'emissivity': emissivity} + system = pvsystem.PVSystem(temperature_model_parameters=temp_model_params) + mocker.spy(temperature, 'faiman_rad') + temps = 25 + irrads = 1000 + winds = 1 + out = system.get_cell_temperature(irrads, temps, winds, + model='faiman_rad', + longwave_down=longwave_down) + temperature.faiman_rad.assert_called_once_with(irrads, temps, winds, + longwave_down, u0, u1, + sky_view, emissivity) + assert_allclose(out, 48.6, atol=1e-1) + + +def test_PVSystem_ross_celltemp(mocker): + # example value (could use equivalent noct as alternative input) + k = 0.0208 # free-standing system + + temp_model_params = {'k': k} + system = pvsystem.PVSystem(temperature_model_parameters=temp_model_params) + m = mocker.spy(temperature, 'ross') + temps = 25 + irrads = 1000 + winds = None + out = system.get_cell_temperature(irrads, temps, winds, model='ross') + m.assert_called_once_with(irrads, temps, k=k) + assert_allclose(out, 45.8, atol=1e-1) + + def test_PVSystem_noct_celltemp(mocker): poa_global, temp_air, wind_speed, noct, module_efficiency = ( 1000., 25., 1., 45., 0.2) @@ -670,7 +711,7 @@ def test_PVSystem_fuentes_celltemp(mocker): assert_series_equal(spy.call_args[0][1], temps) assert_series_equal(spy.call_args[0][2], winds) assert spy.call_args[0][3] == noct_installed - assert_series_equal(out, pd.Series([52.85, 55.85, 55.85], index, + assert_series_equal(out, pd.Series([52.884, 56.835, 56.836], index, name='tmod')) @@ -1371,7 +1412,12 @@ def fixture_i_from_v(request): @pytest.mark.parametrize( - 'method, atol', [('lambertw', 1e-11), ('brentq', 1e-11), ('newton', 1e-11)] + 'method, atol', [ + ('lambertw', 1e-11), ('brentq', 1e-11), ('newton', 1e-11), + pytest.param("chandrupatla", 1e-11, + marks=pytest.mark.skipif(not chandrupatla_available, + reason="needs scipy 1.15")), + ] ) def test_i_from_v(fixture_i_from_v, method, atol): # Solution set loaded from fixture @@ -1400,44 +1446,43 @@ def test_PVSystem_i_from_v(mocker): m.assert_called_once_with(*args) -def test_i_from_v_size(): - with pytest.raises(ValueError): - pvsystem.i_from_v([7.5] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5) - with pytest.raises(ValueError): - pvsystem.i_from_v([7.5] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5, - method='brentq') +@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton', + chandrupatla]) +def test_i_from_v_size(method): + if method == 'newton': + args = ([7.5] * 3, np.array([7., 7.]), 6e-7, 0.1, 20, 0.5) + else: + args = ([7.5] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5) with pytest.raises(ValueError): - pvsystem.i_from_v([7.5] * 3, np.array([7., 7.]), 6e-7, 0.1, 20, 0.5, - method='newton') + pvsystem.i_from_v(*args, method=method) -def test_v_from_i_size(): - with pytest.raises(ValueError): - pvsystem.v_from_i([3.] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5) - with pytest.raises(ValueError): - pvsystem.v_from_i([3.] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5, - method='brentq') +@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton', + chandrupatla]) +def test_v_from_i_size(method): + if method == 'newton': + args = ([3.] * 3, np.array([7., 7.]), 6e-7, [0.1], 20, 0.5) + else: + args = ([3.] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5) with pytest.raises(ValueError): - pvsystem.v_from_i([3.] * 3, np.array([7., 7.]), 6e-7, [0.1], 20, 0.5, - method='newton') + pvsystem.v_from_i(*args, method=method) -def test_mpp_floats(): +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) +def test_mpp_floats(method): """test max_power_point""" IL, I0, Rs, Rsh, nNsVth = (7, 6e-7, .1, 20, .5) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='brentq') + out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method=method) expected = {'i_mp': 6.1362673597376753, # 6.1390251797935704, lambertw 'v_mp': 6.2243393757884284, # 6.221535886625464, lambertw 'p_mp': 38.194210547580511} # 38.194165464983037} lambertw assert isinstance(out, dict) for k, v in out.items(): assert np.isclose(v, expected[k]) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='newton') - for k, v in out.items(): - assert np.isclose(v, expected[k]) -def test_mpp_recombination(): +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) +def test_mpp_recombination(method): """test max_power_point""" pvsyst_fs_495 = get_pvsyst_fs_495() IL, I0, Rs, Rsh, nNsVth = pvsystem.calcparams_pvsyst( @@ -1455,7 +1500,7 @@ def test_mpp_recombination(): IL, I0, Rs, Rsh, nNsVth, d2mutau=pvsyst_fs_495['d2mutau'], NsVbi=VOLTAGE_BUILTIN*pvsyst_fs_495['cells_in_series'], - method='brentq') + method=method) expected_imp = pvsyst_fs_495['I_mp_ref'] expected_vmp = pvsyst_fs_495['V_mp_ref'] expected_pmp = expected_imp*expected_vmp @@ -1465,36 +1510,28 @@ def test_mpp_recombination(): assert isinstance(out, dict) for k, v in out.items(): assert np.isclose(v, expected[k], 0.01) - out = pvsystem.max_power_point( - IL, I0, Rs, Rsh, nNsVth, - d2mutau=pvsyst_fs_495['d2mutau'], - NsVbi=VOLTAGE_BUILTIN*pvsyst_fs_495['cells_in_series'], - method='newton') - for k, v in out.items(): - assert np.isclose(v, expected[k], 0.01) -def test_mpp_array(): +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) +def test_mpp_array(method): """test max_power_point""" IL, I0, Rs, Rsh, nNsVth = (np.array([7, 7]), 6e-7, .1, 20, .5) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='brentq') + out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method=method) expected = {'i_mp': [6.1362673597376753] * 2, 'v_mp': [6.2243393757884284] * 2, 'p_mp': [38.194210547580511] * 2} assert isinstance(out, dict) for k, v in out.items(): assert np.allclose(v, expected[k]) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='newton') - for k, v in out.items(): - assert np.allclose(v, expected[k]) -def test_mpp_series(): +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) +def test_mpp_series(method): """test max_power_point""" idx = ['2008-02-17T11:30:00-0800', '2008-02-17T12:30:00-0800'] IL, I0, Rs, Rsh, nNsVth = (np.array([7, 7]), 6e-7, .1, 20, .5) IL = pd.Series(IL, index=idx) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='brentq') + out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method=method) expected = pd.DataFrame({'i_mp': [6.1362673597376753] * 2, 'v_mp': [6.2243393757884284] * 2, 'p_mp': [38.194210547580511] * 2}, @@ -1502,9 +1539,6 @@ def test_mpp_series(): assert isinstance(out, pd.DataFrame) for k, v in out.items(): assert np.allclose(v, expected[k]) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='newton') - for k, v in out.items(): - assert np.allclose(v, expected[k]) def test_singlediode_series(cec_module_params): @@ -2186,6 +2220,54 @@ def test_pvwatts_dc_series(): assert_series_equal(expected, out) +def test_pvwatts_dc_scalars_with_k(): + expected = 8.9125 + out = pvsystem.pvwatts_dc(100, 30, 100, -0.003, k=0.01) + assert_allclose(out, expected) + + +def test_pvwatts_dc_arrays_with_k(): + irrad_trans = np.array([np.nan, 100, 1200]) + temp_cell = np.array([30, np.nan, 30]) + irrad_trans, temp_cell = np.meshgrid(irrad_trans, temp_cell) + expected = np.array([[nan, 8.9125, 118.45], + [nan, nan, nan], + [nan, 8.9125, 118.45]]) + out = pvsystem.pvwatts_dc(irrad_trans, temp_cell, 100, -0.003, k=0.01) + assert_allclose(out, expected, equal_nan=True) + + +def test_pvwatts_dc_series_with_k(): + irrad_trans = pd.Series([np.nan, 100, 100, 1200]) + temp_cell = pd.Series([30, np.nan, 30, 30]) + expected = pd.Series(np.array([ nan, nan, 8.9125, 118.45])) + out = pvsystem.pvwatts_dc(irrad_trans, temp_cell, 100, -0.003, k=0.01) + assert_series_equal(expected, out) + + +def test_pvwatts_dc_with_k_and_cap_adjustment(): + irrad_trans = [100, 1200] + temp_cell = 25 + out = [] + expected = [0, 120.0] + for irrad in irrad_trans: + out.append(pvsystem.pvwatts_dc(irrad, temp_cell, 100, -0.003, k=0.15, + cap_adjustment=True)) + assert_allclose(out, expected) + + +def test_pvwatts_dc_arrays_with_k_and_cap_adjustment(): + irrad_trans = np.array([np.nan, 100, 1200]) + temp_cell = np.array([30, np.nan, 30]) + irrad_trans, temp_cell = np.meshgrid(irrad_trans, temp_cell) + expected = np.array([[nan, 8.9125, 118.2], + [nan, nan, nan], + [nan, 8.9125, 118.2]]) + out = pvsystem.pvwatts_dc(irrad_trans, temp_cell, 100, -0.003, k=0.01, + cap_adjustment=True) + assert_allclose(out, expected, equal_nan=True) + + def test_pvwatts_losses_default(): expected = 14.075660688264469 out = pvsystem.pvwatts_losses() @@ -2217,7 +2299,8 @@ def pvwatts_system_defaults(): @pytest.fixture def pvwatts_system_kwargs(): - module_parameters = {'pdc0': 100, 'gamma_pdc': -0.003, 'temp_ref': 20} + module_parameters = {'pdc0': 100, 'gamma_pdc': -0.003, 'temp_ref': 20, + 'k': 0.01, 'cap_adjustment': True} inverter_parameters = {'pdc0': 90, 'eta_inv_nom': 0.95, 'eta_inv_ref': 1.0} system = pvsystem.PVSystem(module_parameters=module_parameters, inverter_parameters=inverter_parameters) diff --git a/tests/test_scaling.py b/tests/test_scaling.py index 344e2209b5..f8638f580a 100644 --- a/tests/test_scaling.py +++ b/tests/test_scaling.py @@ -52,7 +52,7 @@ def time_500ms(clear_sky_index): @pytest.fixture def positions(): # Sample positions based on the previous lat/lon (calculated manually) - expect_xpos = np.array([554863.4, 555975.4, 557087.3]) + expect_xpos = np.array([546433.8, 547528.9, 548623.9]) expect_ypos = np.array([1110838.8, 1111950.8, 1113062.7]) return np.array([pt for pt in zip(expect_xpos, expect_ypos)]) @@ -89,14 +89,16 @@ def expect_wavelet(): @pytest.fixture def expect_cs_smooth(): # Expected smoothed clear sky index for indices 5000:5004 (Matlab) - return np.array([1., 1., 1.05774, 0.94226, 1.]) + return np.array([1., 1., 1.057735, 0.942265, 1.]) @pytest.fixture def expect_vr(): # Expected VR for expecttmscale - return np.array([3., 3., 3., 3., 3., 3., 2.9997844, 2.9708118, 2.6806291, - 2.0726611, 1.5653324, 1.2812714, 1.1389995]) + return np.array([3., 3., 3., 3., 3., + 2.99999999, 2.99976775, 2.96971249, + 2.67505872, 2.06592527, 1.5611084, + 1.27910582, 1.13793164]) def test_latlon_to_xy_zero(): diff --git a/tests/test_singlediode.py b/tests/test_singlediode.py index efded9ff3c..10b589b1b1 100644 --- a/tests/test_singlediode.py +++ b/tests/test_singlediode.py @@ -7,16 +7,19 @@ import scipy from pvlib import pvsystem from pvlib.singlediode import (bishop88_mpp, estimate_voc, VOLTAGE_BUILTIN, - bishop88, bishop88_i_from_v, bishop88_v_from_i) + bishop88, bishop88_i_from_v, bishop88_v_from_i, + batzelis) import pytest from numpy.testing import assert_array_equal from .conftest import TESTS_DATA_DIR +from .conftest import chandrupatla, chandrupatla_available + POA = 888 TCELL = 55 -@pytest.mark.parametrize('method', ['brentq', 'newton']) +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) def test_method_spr_e20_327(method, cec_module_spr_e20_327): """test pvsystem.singlediode with different methods on SPR-E20-327""" spr_e20_327 = cec_module_spr_e20_327 @@ -38,7 +41,7 @@ def test_method_spr_e20_327(method, cec_module_spr_e20_327): assert np.isclose(pvs['i_xx'], out['i_xx']) -@pytest.mark.parametrize('method', ['brentq', 'newton']) +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) def test_newton_fs_495(method, cec_module_fs_495): """test pvsystem.singlediode with different methods on FS495""" fs_495 = cec_module_fs_495 @@ -146,7 +149,8 @@ def precise_iv_curves(request): return singlediode_params, pc -@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton']) +@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton', + chandrupatla]) def test_singlediode_precision(method, precise_iv_curves): """ Tests the accuracy of singlediode. ivcurve_pnts is not tested. @@ -156,7 +160,7 @@ def test_singlediode_precision(method, precise_iv_curves): assert np.allclose(pc['i_sc'], outs['i_sc'], atol=1e-10, rtol=0) assert np.allclose(pc['v_oc'], outs['v_oc'], atol=1e-10, rtol=0) - assert np.allclose(pc['i_mp'], outs['i_mp'], atol=7e-8, rtol=0) + assert np.allclose(pc['i_mp'], outs['i_mp'], atol=1e-7, rtol=0) assert np.allclose(pc['v_mp'], outs['v_mp'], atol=1e-6, rtol=0) assert np.allclose(pc['p_mp'], outs['p_mp'], atol=1e-10, rtol=0) assert np.allclose(pc['i_x'], outs['i_x'], atol=1e-10, rtol=0) @@ -187,7 +191,8 @@ def test_singlediode_lambert_negative_voc(mocker): assert_array_equal(outs["v_oc"], [0, 0]) -@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton']) +@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton', + chandrupatla]) def test_v_from_i_i_from_v_precision(method, precise_iv_curves): """ Tests the accuracy of pvsystem.v_from_i and pvsystem.i_from_v. @@ -256,7 +261,7 @@ def get_pvsyst_fs_495(): ) ] ) -@pytest.mark.parametrize('method', ['newton', 'brentq']) +@pytest.mark.parametrize('method', ['newton', 'brentq', chandrupatla]) def test_pvsyst_recombination_loss(method, poa, temp_cell, expected, tol): """test PVSst recombination loss""" pvsyst_fs_495 = get_pvsyst_fs_495() @@ -348,7 +353,7 @@ def test_pvsyst_recombination_loss(method, poa, temp_cell, expected, tol): ) ] ) -@pytest.mark.parametrize('method', ['newton', 'brentq']) +@pytest.mark.parametrize('method', ['newton', 'brentq', chandrupatla]) def test_pvsyst_breakdown(method, brk_params, recomb_params, poa, temp_cell, expected, tol): """test PVSyst recombination loss""" @@ -456,7 +461,13 @@ def bishop88_arguments(): 'xtol': 1e-8, 'rtol': 1e-8, 'maxiter': 30, - }) + }), + # can't include chandrupatla since the function is not available to patch + # TODO: add this once chandrupatla becomes non-optional functionality + # ('chandrupatla', { + # 'tolerances ': {'xtol': 1e-8, 'rtol': 1e-8}, + # 'maxiter': 30, + # }), ]) def test_bishop88_kwargs_transfer(method, method_kwargs, mocker, bishop88_arguments): @@ -495,7 +506,14 @@ def test_bishop88_kwargs_transfer(method, method_kwargs, mocker, 'rtol': 1e-4, 'maxiter': 20, '_inexistent_param': "0.01" - }) + }), + pytest.param('chandrupatla', { + 'xtol': 1e-4, + 'rtol': 1e-4, + 'maxiter': 20, + '_inexistent_param': "0.01" + }, marks=pytest.mark.skipif(not chandrupatla_available, + reason="needs scipy 1.15")), ]) def test_bishop88_kwargs_fails(method, method_kwargs, bishop88_arguments): """test invalid method_kwargs passed onto the optimizer fail""" @@ -513,7 +531,7 @@ def test_bishop88_kwargs_fails(method, method_kwargs, bishop88_arguments): method_kwargs=method_kwargs) -@pytest.mark.parametrize('method', ['newton', 'brentq']) +@pytest.mark.parametrize('method', ['newton', 'brentq', chandrupatla]) def test_bishop88_full_output_kwarg(method, bishop88_arguments): """test call to bishop88_.* with full_output=True return values are ok""" method_kwargs = {'full_output': True} @@ -547,7 +565,7 @@ def test_bishop88_full_output_kwarg(method, bishop88_arguments): assert len(ret_val[1]) >= 2 -@pytest.mark.parametrize('method', ['newton', 'brentq']) +@pytest.mark.parametrize('method', ['newton', 'brentq', chandrupatla]) def test_bishop88_pdSeries_len_one(method, bishop88_arguments): for k, v in bishop88_arguments.items(): bishop88_arguments[k] = pd.Series([v]) @@ -563,7 +581,7 @@ def _sde_check_solution(i, v, il, io, rs, rsh, a, d2mutau=0., NsVbi=np.inf): return il - io*np.expm1(vd/a) - vd/rsh - il*d2mutau/(NsVbi - vd) - i -@pytest.mark.parametrize('method', ['newton', 'brentq']) +@pytest.mark.parametrize('method', ['newton', 'brentq', chandrupatla]) def test_bishop88_init_cond(method): # GH 2013 p = {'alpha_sc': 0.0012256, @@ -600,9 +618,60 @@ def test_bishop88_init_cond(method): NsVbi=NsVbi)) bad_results = np.isnan(vmp2) | (vmp2 < 0) | (err > 0.00001) assert not bad_results.any() - # test v_from_i + # test i_from_v imp2 = bishop88_i_from_v(vmp, *sde_params, d2mutau=d2mutau, NsVbi=NsVbi) err = np.abs(_sde_check_solution(imp2, vmp, *sde_params, d2mutau=d2mutau, NsVbi=NsVbi)) bad_results = np.isnan(imp2) | (imp2 < 0) | (err > 0.00001) assert not bad_results.any() + + +def test_batzelis(): + params = {'photocurrent': 10, 'saturation_current': 1e-10, + 'resistance_series': 0.2, 'resistance_shunt': 3000, + 'nNsVth': 1.7} + + exact_values = { # calculated using pvlib.pvsystem.singlediode + 'i_sc': 9.999333377550565, + 'v_oc': 43.05589965219406, + 'i_mp': 9.513255314772051, + 'v_mp': 35.97259289596944, + 'p_mp': 342.21646055371264, + } + rtol = 5e-3 # accurate to within half a percent in this case + + output = batzelis(**params) + for key in exact_values: + assert output[key] == pytest.approx(exact_values[key], rel=rtol) + + # numpy arrays + params2 = {k: np.array([v] * 2) for k, v in params.items()} + output2 = batzelis(**params2) + for key in exact_values: + exp = np.array([exact_values[key]] * 2) + np.testing.assert_allclose(output2[key], exp, rtol=rtol) + + # pandas + params3 = {k: pd.Series(v) for k, v in params2.items()} + output3 = batzelis(**params3) + assert isinstance(output3, pd.DataFrame) + for key in exact_values: + exp = pd.Series([exact_values[key]] * 2) + np.testing.assert_allclose(output3[key], exp, rtol=rtol) + + +def test_batzelis_night(): + # The De Soto SDM produces photocurrent=0 and resistance_shunt=inf + # at 0 W/m2 irradiance + out = batzelis(photocurrent=0, saturation_current=1e-10, + resistance_series=0.2, resistance_shunt=np.inf, + nNsVth=1.7) + for k, v in out.items(): + assert v == 0, k # ensure all outputs are zero (not nan, etc) + + # test also when Rsh=inf but Iph > 0 + out = batzelis(photocurrent=0.1, saturation_current=1e-10, + resistance_series=0.2, resistance_shunt=np.inf, + nNsVth=1.7) + for k, v in out.items(): + assert v > 0, k # ensure all outputs >0 (not nan, etc) diff --git a/tests/test_solarposition.py b/tests/test_solarposition.py index 88093e05f9..a6cf6b4819 100644 --- a/tests/test_solarposition.py +++ b/tests/test_solarposition.py @@ -38,7 +38,7 @@ def expected_solpos_multi(): @pytest.fixture() def expected_rise_set_spa(): - # for Golden, CO, from NREL SPA website + # for Golden, CO, from NLR SPA website times = pd.DatetimeIndex([datetime.datetime(2015, 1, 2), datetime.datetime(2015, 8, 2), ]).tz_localize('MST') @@ -86,8 +86,8 @@ def expected_rise_set_ephem(): index=times) -# the physical tests are run at the same time as the NREL SPA test. -# pyephem reproduces the NREL result to 2 decimal places. +# the physical tests are run at the same time as the NLR SPA test. +# pyephem reproduces the NLR result to 2 decimal places. # this doesn't mean that one code is better than the other. @requires_spa_c @@ -142,20 +142,15 @@ def test_spa_python_numpy_physical_dst(expected_solpos, golden): @pytest.mark.parametrize('delta_t', [65.0, None, np.array([65, 65])]) def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t): - # solution from NREL SAP web calculator + # solution from NLR SPA web calculator south = Location(-35.0, 0.0, tz='UTC') - times = pd.DatetimeIndex([datetime.datetime(1996, 7, 5, 0), - datetime.datetime(2004, 12, 4, 0)] - ).tz_localize('UTC') - sunrise = pd.DatetimeIndex([datetime.datetime(1996, 7, 5, 7, 8, 15), - datetime.datetime(2004, 12, 4, 4, 38, 57)] - ).tz_localize('UTC').tolist() - sunset = pd.DatetimeIndex([datetime.datetime(1996, 7, 5, 17, 1, 4), - datetime.datetime(2004, 12, 4, 19, 2, 3)] - ).tz_localize('UTC').tolist() - transit = pd.DatetimeIndex([datetime.datetime(1996, 7, 5, 12, 4, 36), - datetime.datetime(2004, 12, 4, 11, 50, 22)] - ).tz_localize('UTC').tolist() + times = pd.to_datetime(["1996-07-05", "2004-12-04"], utc=True) + sunrise = pd.to_datetime(["1996-07-05 07:08:15", "2004-12-04 04:38:57"], + utc=True) + sunset = pd.to_datetime(["1996-07-05 17:01:04", "2004-12-04 19:02:03"], + utc=True) + transit = pd.to_datetime(["1996-07-05 12:04:36", "2004-12-04 11:50:22"], + utc=True) frame = pd.DataFrame({'sunrise': sunrise, 'sunset': sunset, 'transit': transit}, index=times) @@ -169,9 +164,11 @@ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t): for col, data in result.items(): result_rounded[col] = data.dt.round('1s') - assert_frame_equal(frame, result_rounded) + assert_frame_equal(frame, result_rounded, + check_dtype=False # ignore us/ns dtypes + ) - # test for Golden, CO compare to NREL SPA + # test for Golden, CO compare to NLR SPA result = solarposition.sun_rise_set_transit_spa( expected_rise_set_spa.index, golden.latitude, golden.longitude, delta_t=delta_t) @@ -182,7 +179,9 @@ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t): for col, data in result.items(): result_rounded[col] = data.dt.round('s').tz_convert('MST') - assert_frame_equal(expected_rise_set_spa, result_rounded) + assert_frame_equal(expected_rise_set_spa, result_rounded, + check_dtype=False # ignore us/ns dtypes + ) @requires_ephem @@ -673,7 +672,7 @@ def test_analytical_azimuth(): def test_hour_angle(): """ Test conversion from hours to hour angles in degrees given the following - inputs from NREL SPA calculator at Golden, CO + inputs from NLR SPA calculator at Golden, CO date,times,eot,sunrise,sunset 1/2/2015,7:21:55,-3.935172,-70.699400,70.512721 1/2/2015,16:47:43,-4.117227,-70.699400,70.512721 @@ -688,7 +687,7 @@ def test_hour_angle(): eot = np.array([-3.935172, -4.117227, -4.026295]) hourangle = solarposition.hour_angle(times, longitude, eot) expected = (-70.682338, 70.72118825000001, 0.000801250) - # FIXME: there are differences from expected NREL SPA calculator values + # FIXME: there are differences from expected NLR SPA calculator values # sunrise: 4 seconds, sunset: 48 seconds, transit: 0.2 seconds # but the differences may be due to other SPA input parameters assert np.allclose(hourangle, expected) @@ -726,7 +725,10 @@ def test_hour_angle_with_tricky_timezones(): '2014-09-07 02:00:00', ]).tz_localize('America/Santiago', nonexistent='shift_forward') - with pytest.raises(pytz.exceptions.NonExistentTimeError): + with pytest.raises(( + pytz.exceptions.NonExistentTimeError, # pandas 1.x, 2.x + ValueError, # pandas 3.x + )): times.normalize() # should not raise `pytz.exceptions.NonExistentTimeError` @@ -740,7 +742,10 @@ def test_hour_angle_with_tricky_timezones(): '2014-11-02 02:00:00', ]).tz_localize('America/Havana', ambiguous=[True, True, False, False]) - with pytest.raises(pytz.exceptions.AmbiguousTimeError): + with pytest.raises(( + pytz.exceptions.AmbiguousTimeError, # pandas 1.x, 2.x + ValueError, # pandas 3.x + )): solarposition.hour_angle(times, longitude, eot) @@ -798,8 +803,13 @@ def test_sun_rise_set_transit_geometric(expected_rise_set_spa, golden_mst): @pytest.mark.parametrize('tz', [None, 'utc', 'US/Eastern']) def test__datetime_to_unixtime(tz): # for pandas < 2.0 where "unit" doesn't exist in pd.date_range. note that - # unit of ns is the only option in pandas<2, and the default in pandas 2.x - times = pd.date_range(start='2019-01-01', freq='h', periods=3, tz=tz) + # unit of ns is the only option in pandas<2, and the default in pandas 2.x, + # but the default is us in pandas 3.x + kwargs = dict(start='2019-01-01', freq='h', periods=3, tz=tz) + try: + times = pd.date_range(**kwargs, unit='ns') # pandas 2.x, 3.x + except TypeError: + times = pd.date_range(**kwargs) # pandas 1.x expected = times.view(np.int64)/10**9 actual = solarposition._datetime_to_unixtime(times) np.testing.assert_equal(expected, actual) diff --git a/tests/test_spa.py b/tests/test_spa.py index 67cab4cbdb..81ae58a2a6 100644 --- a/tests/test_spa.py +++ b/tests/test_spa.py @@ -3,6 +3,7 @@ import warnings import pytest import pvlib +from pvlib.solarposition import _datetime_to_unixtime try: from importlib import reload @@ -19,9 +20,13 @@ import unittest from .conftest import requires_numba +kwargs = dict(start='2003-10-17 12:30:30', periods=1, freq='D') +try: + times = pd.date_range(**kwargs, unit='ns') # pandas 2.x, 3.x +except TypeError: + times = pd.date_range(**kwargs) # pandas 1.x -times = (pd.date_range('2003-10-17 12:30:30', periods=1, freq='D') - .tz_localize('MST')) +times = times.tz_localize('MST') unixtimes = np.array(times.tz_convert('UTC').view(np.int64)*1.0/10**9) lat = 39.742476 @@ -258,35 +263,29 @@ def test_transit_sunrise_sunset(self): # tests at greenwich times = pd.DatetimeIndex([dt.datetime(1996, 7, 5, 0), dt.datetime(2004, 12, 4, 0)] - ).tz_localize( - 'UTC').view(np.int64)*1.0/10**9 + ).tz_localize('UTC') sunrise = pd.DatetimeIndex([dt.datetime(1996, 7, 5, 7, 8, 15), dt.datetime(2004, 12, 4, 4, 38, 57)] - ).tz_localize( - 'UTC').view(np.int64)*1.0/10**9 + ).tz_localize('UTC') sunset = pd.DatetimeIndex([dt.datetime(1996, 7, 5, 17, 1, 4), dt.datetime(2004, 12, 4, 19, 2, 2)] - ).tz_localize( - 'UTC').view(np.int64)*1.0/10**9 - times = np.array(times) - sunrise = np.array(sunrise) - sunset = np.array(sunset) + ).tz_localize('UTC') + times = _datetime_to_unixtime(times) + sunrise = _datetime_to_unixtime(sunrise) + sunset = _datetime_to_unixtime(sunset) result = self.spa.transit_sunrise_sunset(times, -35.0, 0.0, 64.0, 1) assert_almost_equal(sunrise/1e3, result[1]/1e3, 3) assert_almost_equal(sunset/1e3, result[2]/1e3, 3) times = pd.DatetimeIndex([dt.datetime(1994, 1, 2), ] - ).tz_localize( - 'UTC').view(np.int64)*1.0/10**9 + ).tz_localize('UTC') sunset = pd.DatetimeIndex([dt.datetime(1994, 1, 2, 16, 59, 55), ] - ).tz_localize( - 'UTC').view(np.int64)*1.0/10**9 + ).tz_localize('UTC') sunrise = pd.DatetimeIndex([dt.datetime(1994, 1, 2, 7, 8, 12), ] - ).tz_localize( - 'UTC').view(np.int64)*1.0/10**9 - times = np.array(times) - sunrise = np.array(sunrise) - sunset = np.array(sunset) + ).tz_localize('UTC') + times = _datetime_to_unixtime(times) + sunrise = _datetime_to_unixtime(sunrise) + sunset = _datetime_to_unixtime(sunset) result = self.spa.transit_sunrise_sunset(times, 35.0, 0.0, 64.0, 1) assert_almost_equal(sunrise/1e3, result[1]/1e3, 3) assert_almost_equal(sunset/1e3, result[2]/1e3, 3) @@ -297,23 +296,20 @@ def test_transit_sunrise_sunset(self): dt.datetime(2015, 4, 2), dt.datetime(2015, 8, 2), dt.datetime(2015, 12, 2)], - ).tz_localize( - 'UTC').view(np.int64)*1.0/10**9 + ).tz_localize('UTC') sunrise = pd.DatetimeIndex([dt.datetime(2015, 1, 2, 7, 19), dt.datetime(2015, 4, 2, 5, 43), dt.datetime(2015, 8, 2, 5, 1), dt.datetime(2015, 12, 2, 7, 1)], - ).tz_localize( - 'MST').view(np.int64)*1.0/10**9 + ).tz_localize('MST') sunset = pd.DatetimeIndex([dt.datetime(2015, 1, 2, 16, 49), dt.datetime(2015, 4, 2, 18, 24), dt.datetime(2015, 8, 2, 19, 10), dt.datetime(2015, 12, 2, 16, 38)], - ).tz_localize( - 'MST').view(np.int64)*1.0/10**9 - times = np.array(times) - sunrise = np.array(sunrise) - sunset = np.array(sunset) + ).tz_localize('MST') + times = _datetime_to_unixtime(times) + sunrise = _datetime_to_unixtime(sunrise) + sunset = _datetime_to_unixtime(sunset) result = self.spa.transit_sunrise_sunset(times, 39.0, -105.0, 64.0, 1) assert_almost_equal(sunrise/1e3, result[1]/1e3, 1) assert_almost_equal(sunset/1e3, result[2]/1e3, 1) @@ -323,33 +319,26 @@ def test_transit_sunrise_sunset(self): dt.datetime(2015, 4, 2), dt.datetime(2015, 8, 2), dt.datetime(2015, 12, 2)], - ).tz_localize( - 'UTC').view(np.int64)*1.0/10**9 + ).tz_localize('UTC') sunrise = pd.DatetimeIndex([dt.datetime(2015, 1, 2, 7, 36), dt.datetime(2015, 4, 2, 5, 58), dt.datetime(2015, 8, 2, 5, 13), dt.datetime(2015, 12, 2, 7, 17)], - ).tz_localize('Asia/Shanghai').view( - np.int64)*1.0/10**9 + ).tz_localize('Asia/Shanghai') sunset = pd.DatetimeIndex([dt.datetime(2015, 1, 2, 17, 0), dt.datetime(2015, 4, 2, 18, 39), dt.datetime(2015, 8, 2, 19, 28), dt.datetime(2015, 12, 2, 16, 50)], - ).tz_localize('Asia/Shanghai').view( - np.int64)*1.0/10**9 - times = np.array(times) - sunrise = np.array(sunrise) - sunset = np.array(sunset) + ).tz_localize('Asia/Shanghai') + times = _datetime_to_unixtime(times) + sunrise = _datetime_to_unixtime(sunrise) + sunset = _datetime_to_unixtime(sunset) result = self.spa.transit_sunrise_sunset( times, 39.917, 116.383, 64.0, 1) assert_almost_equal(sunrise/1e3, result[1]/1e3, 1) assert_almost_equal(sunset/1e3, result[2]/1e3, 1) def test_earthsun_distance(self): - times = (pd.date_range('2003-10-17 12:30:30', periods=1, freq='D') - .tz_localize('MST')) - unixtimes = times.tz_convert('UTC').view(np.int64)*1.0/10**9 - unixtimes = np.array(unixtimes) result = self.spa.earthsun_distance(unixtimes, 64.0, 1) assert_almost_equal(R, result, 6) diff --git a/tests/test_temperature.py b/tests/test_temperature.py index bf72a16e22..f7cc242477 100644 --- a/tests/test_temperature.py +++ b/tests/test_temperature.py @@ -152,11 +152,45 @@ def test_faiman_rad_ir(): def test_ross(): - result = temperature.ross(np.array([1000., 600., 1000.]), - np.array([20., 40., 60.]), - np.array([40., 100., 20.])) - expected = np.array([45., 100., 60.]) - assert_allclose(expected, result) + # single values + result1 = temperature.ross(1000., 30., noct=50) + result2 = temperature.ross(1000., 30., k=0.0375) + + expected = 67.5 + assert_allclose(expected, result1) + assert_allclose(expected, result2) + + # pd.Series + times = pd.date_range('2025-07-30 14:00', '2025-07-30 16:00', freq='h') + + df = pd.DataFrame({'t_air': np.array([20., 30., 40.]), + 'ghi': np.array([800., 700., 600.])}, + index=times) + + result1 = temperature.ross(df['ghi'], df['t_air'], noct=50.) + result2 = temperature.ross(df['ghi'], df['t_air'], k=0.0375) + + expected = pd.Series([50., 56.25, 62.5], index=times) + assert_allclose(expected, result1) + assert_allclose(expected, result2) + + # np.array + ghi_array = df['ghi'].values + t_air_array = df['t_air'].values + + result1 = temperature.ross(ghi_array, t_air_array, noct=50.) + result2 = temperature.ross(ghi_array, t_air_array, k=0.0375) + + expected = expected.values + assert_allclose(expected, result1) + assert_allclose(expected, result2) + + +def test_ross_errors(): + with pytest.raises(ValueError, match='Either noct or k is required'): + temperature.ross(1000., 30.) + with pytest.raises(ValueError, match='Provide only one of noct or k'): + temperature.ross(1000., 30., noct=45., k=0.02) def test_faiman_series(): @@ -194,7 +228,7 @@ def _read_pvwatts_8760(filename): ('pvwatts_8760_roofmount.csv', 49), ]) def test_fuentes(filename, inoct): - # Test against data exported from pvwatts.nrel.gov + # Test against data exported from pvwatts.nlr.gov data = _read_pvwatts_8760(TESTS_DATA_DIR / filename) data = data.iloc[:24*7, :] # just use one week inputs = { @@ -237,7 +271,16 @@ def test_fuentes_timezone(tz): out = temperature.fuentes(df['poa_global'], df['temp_air'], df['wind_speed'], noct_installed=45) - assert_series_equal(out, pd.Series([47.85, 50.85, 50.85], index=index, + assert_series_equal(out, pd.Series([48.042, 51.845, 51.846], index=index, + name='tmod')) + # GH 2608 + df = pd.DataFrame({'poa_global': 1000., 'temp_air': 20., 'wind_speed': 1.}, + index) + + out = temperature.fuentes(df['poa_global'], df['temp_air'], + df['wind_speed'], noct_installed=45) + + assert_series_equal(out, pd.Series([48.042, 51.845, 51.846], index=index, name='tmod')) diff --git a/tests/test_tools.py b/tests/test_tools.py index 821b9fec65..4b733ad711 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -102,7 +102,8 @@ def test__golden_sect_DataFrame_nans(): def test_degrees_to_index_1(): """Test that _degrees_to_index raises an error when something other than 'latitude' or 'longitude' is passed.""" - with pytest.raises(IndexError): # invalid value for coordinate argument + # invalid value for coordinate argument + with pytest.raises(ValueError, match="coordinate must be"): tools._degrees_to_index(degrees=22.0, coordinate='width') diff --git a/tests/test_tracking.py b/tests/test_tracking.py index 8f3cb5824d..903cf11d1d 100644 --- a/tests/test_tracking.py +++ b/tests/test_tracking.py @@ -23,7 +23,7 @@ def test_solar_noon(): gcr=2.0/7.0) expect = pd.DataFrame({'tracker_theta': 0, 'aoi': 10, - 'surface_azimuth': 90, 'surface_tilt': 0}, + 'surface_azimuth': 270, 'surface_tilt': 0}, index=index, dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] @@ -38,7 +38,7 @@ def test_scalars(): max_angle=90, backtrack=True, gcr=2.0/7.0) assert isinstance(tracker_data, dict) - expect = {'tracker_theta': 0, 'aoi': 10, 'surface_azimuth': 90, + expect = {'tracker_theta': 0, 'aoi': 10, 'surface_azimuth': 270, 'surface_tilt': 0} for k, v in expect.items(): assert np.isclose(tracker_data[k], v) @@ -52,7 +52,7 @@ def test_arrays(): max_angle=90, backtrack=True, gcr=2.0/7.0) assert isinstance(tracker_data, dict) - expect = {'tracker_theta': 0, 'aoi': 10, 'surface_azimuth': 90, + expect = {'tracker_theta': 0, 'aoi': 10, 'surface_azimuth': 270, 'surface_tilt': 0} for k, v in expect.items(): assert_allclose(tracker_data[k], v, atol=1e-7) @@ -68,7 +68,7 @@ def test_nans(): gcr=2.0/7.0) expect = {'tracker_theta': np.array([0, nan, nan]), 'aoi': np.array([10, nan, nan]), - 'surface_azimuth': np.array([90, nan, nan]), + 'surface_azimuth': np.array([270, nan, nan]), 'surface_tilt': np.array([0, nan, nan])} for k, v in expect.items(): assert_allclose(tracker_data[k], v, atol=1e-7) @@ -82,7 +82,7 @@ def test_nans(): max_angle=90, backtrack=True, gcr=2.0/7.0) expect = pd.DataFrame(np.array( - [[ 0., 10., 90., 0.], + [[ 0., 10., 270., 0.], [nan, nan, nan, nan], [nan, nan, nan, nan]]), columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']) @@ -195,6 +195,54 @@ def test_backtrack(): assert_frame_equal(expect, tracker_data) +def test__unit_normal(): + # with scalar input + unorm = tracking._unit_normal(180., 45., 45.) + assert_allclose(unorm, np.array([[-np.sqrt(2)/2, -0.5, 0.5]])) + # with vector input + az = np.array([0., 90., 180., 270., + 0., 90., 180., 270., + 180., 180., 180, 180., + 180., 180., 180., 180, + 0., 90., 180., 270., + ]) + tilt = np.array([30., 30., 30., 30., + 0., 0., 0., 0., + -30., -90., 90., 180., + 0., 0., 0., 0., + 30., 30., 30., 30, + ]) + theta = np.array([0., 0., 0., 0., + 0., 0., 0., 0., + 0., 0., 0., 0., + -30., 30., -90., 90., + 30., 30., 30., 30., + ]) + expected = np.array( + [[ 0., 0.5, 0.8660254], + [ 0.5, 0., 0.8660254], + [ 0., -0.5, 0.8660254], + [-0.5, -0., 0.8660254], + [ 0., 0., 1.], + [ 0., 0., 1.], + [ 0., -0., 1.], + [-0., 0., 1.], + [-0., 0.5, 0.8660254], + [-0., 1., 0.], + [ 0., -1., 0.], + [ 0., -0., -1.], + [ 0.5, 0., 0.8660254], + [-0.5, -0., 0.8660254], + [ 1., 0., 0.], + [-1., -0., 0.], + [ 0.5, 0.4330127, 0.75], + [ 0.4330127, -0.5, 0.75], + [-0.5, -0.4330127, 0.75], + [-0.4330127, 0.5, 0.75]]) + unorms = tracking._unit_normal(az, tilt, theta) + assert np.allclose(unorms, expected) + + def test_axis_tilt(): apparent_zenith = pd.Series([30]) apparent_azimuth = pd.Series([135]) @@ -226,6 +274,7 @@ def test_axis_tilt(): def test_axis_azimuth(): + # sun to the east, horizontal east-oriented tracker apparent_zenith = pd.Series([30]) apparent_azimuth = pd.Series([90]) @@ -234,13 +283,30 @@ def test_axis_azimuth(): max_angle=90, backtrack=True, gcr=2.0/7.0) - expect = pd.DataFrame({'aoi': 30, 'surface_azimuth': 180, + expect = pd.DataFrame({'aoi': 30, 'surface_azimuth': 0, 'surface_tilt': 0, 'tracker_theta': 0}, index=[0], dtype=np.float64) expect = expect[SINGLEAXIS_COL_ORDER] assert_frame_equal(expect, tracker_data) + # sun to the east, horizontal south-oriented tracker + apparent_zenith = pd.Series([30]) + apparent_azimuth = pd.Series([90]) + + tracker_data = tracking.singleaxis(apparent_zenith, apparent_azimuth, + axis_tilt=0, axis_azimuth=180, + max_angle=90, backtrack=True, + gcr=2.0/7.0) + + expect = pd.DataFrame({'aoi': 0, 'surface_azimuth': 90, + 'surface_tilt': 30, 'tracker_theta': -30}, + index=[0], dtype=np.float64) + expect = expect[SINGLEAXIS_COL_ORDER] + + assert_frame_equal(expect, tracker_data) + + # sun to the south, horizontal east-oriented tracker apparent_zenith = pd.Series([30]) apparent_azimuth = pd.Series([180]) @@ -269,7 +335,7 @@ def test_horizon_flat(): axis_azimuth=180, backtrack=False, max_angle=180) expected = pd.DataFrame(np.array( [[ nan, nan, nan, nan], - [ 0., 45., 270., 0.], + [ 0., 45., 90., 0.], [ nan, nan, nan, nan]]), columns=['tracker_theta', 'aoi', 'surface_azimuth', 'surface_tilt']) assert_frame_equal(out, expected) @@ -294,7 +360,7 @@ def test_horizon_tilted(): def test_low_sun_angles(): # GH 656, 824 result = tracking.singleaxis( - apparent_zenith=80, apparent_azimuth=338, axis_tilt=30, + apparent_zenith=80, solar_azimuth=338, axis_tilt=30, axis_azimuth=180, max_angle=60, backtrack=True, gcr=0.35) expected = { 'tracker_theta': np.array([60.0]), @@ -345,7 +411,7 @@ def test_calc_axis_tilt(): def test_slope_aware_backtracking(): """ - Test validation data set from https://www.nrel.gov/docs/fy20osti/76626.pdf + Test validation data set from https://doi.org/10.2172/1660126 """ index = pd.date_range('2019-01-01T08:00', '2019-01-01T17:00', freq='h') index = index.tz_localize('Etc/GMT+5') @@ -389,6 +455,23 @@ def test_slope_aware_backtracking(): check_less_precise=True) +def test_singleaxis_neg_axis_tilt(): + ''' Check equivalence of (negative tilt, axis azimuth) and + (positive tilt, axis azimuth + 180) + ''' + params = dict(apparent_zenith=45, solar_azimuth=270) + + tr_pos = pvlib.tracking.singleaxis(axis_tilt=10, axis_azimuth=0, + **params) + tr_neg = pvlib.tracking.singleaxis(axis_tilt=-10, axis_azimuth=180, + **params) + + tr_neg['tracker_theta'] *= -1 # expect tracker_theta to be negated + + for key in tr_pos: + assert_allclose(tr_pos[key], tr_neg[key]) + + def test_singleaxis_aoi_gh1221(): # vertical tracker loc = pvlib.location.Location(40.1134, -88.3695) @@ -408,7 +491,8 @@ def test_calc_surface_orientation_types(): # numpy arrays rotations = np.array([-10, 0, 10]) expected_tilts = np.array([10, 0, 10], dtype=float) - expected_azimuths = np.array([270, 90, 90], dtype=float) + expected_azimuths = np.array([270, 270, 90], dtype=float) + # defaults to axis_azimuth=0 out = tracking.calc_surface_orientation(tracker_theta=rotations) np.testing.assert_allclose(expected_tilts, out['surface_tilt']) np.testing.assert_allclose(expected_azimuths, out['surface_azimuth']) @@ -445,7 +529,8 @@ def test_calc_surface_orientation_special(): # special cases for rotations rotations = np.array([-180, -90, -0, 0, 90, 180]) expected_tilts = np.array([180, 90, 0, 0, 90, 180], dtype=float) - expected_azimuths = [270, 270, 90, 90, 90, 90] + expected_azimuths = [270, 270, 270, 270, 90, 90] + # defaults to axis_azimuth=0 out = tracking.calc_surface_orientation(rotations) np.testing.assert_allclose(out['surface_tilt'], expected_tilts) np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths) @@ -454,6 +539,7 @@ def test_calc_surface_orientation_special(): rotations = np.array([-10, 0, 10]) expected_tilts = np.array([90, 90, 90], dtype=float) expected_azimuths = np.array([350, 0, 10], dtype=float) + # defaults to axis_azimuth=0 out = tracking.calc_surface_orientation(rotations, axis_tilt=90) np.testing.assert_allclose(out['surface_tilt'], expected_tilts) np.testing.assert_allclose(out['surface_azimuth'], expected_azimuths) @@ -461,7 +547,7 @@ def test_calc_surface_orientation_special(): # special cases for axis_azimuth rotations = np.array([-10, 0, 10]) expected_tilts = np.array([10, 0, 10], dtype=float) - expected_azimuth_offsets = np.array([-90, 90, 90], dtype=float) + expected_azimuth_offsets = np.array([-90, -90, 90], dtype=float) for axis_azimuth in [0, 90, 180, 270, 360]: expected_azimuths = (axis_azimuth + expected_azimuth_offsets) % 360 out = tracking.calc_surface_orientation(rotations, @@ -471,3 +557,31 @@ def test_calc_surface_orientation_special(): # in a modulo-360 sense. np.testing.assert_allclose(np.round(out['surface_azimuth'], 4) % 360, expected_azimuths, rtol=1e-5, atol=1e-5) + + +@pytest.mark.parametrize('shape', [(3, 5), (1, 7), (4, 1), (2, 3, 4)]) +def test_calc_surface_orientation_2d(shape): + # Regression test for GH#2747: calc_surface_orientation must accept + # tracker_theta of arbitrary rank, not just 1-D. Compare the >1-D result + # to the 1-D result computed on the flattened input. + rotations_flat = np.linspace(-90, 90, int(np.prod(shape))) + rotations_nd = rotations_flat.reshape(shape) + + out_1d = tracking.calc_surface_orientation(rotations_flat, + axis_tilt=20, + axis_azimuth=180) + out_nd = tracking.calc_surface_orientation(rotations_nd, + axis_tilt=20, + axis_azimuth=180) + + assert out_nd['surface_tilt'].shape == shape + assert out_nd['surface_azimuth'].shape == shape + np.testing.assert_allclose(out_nd['surface_tilt'].reshape(-1), + out_1d['surface_tilt']) + np.testing.assert_allclose(out_nd['surface_azimuth'].reshape(-1), + out_1d['surface_azimuth']) + + # _unit_normal must preserve the input rank, appending a trailing axis + # of length 3. + unorm = tracking._unit_normal(180., 20., rotations_nd) + assert unorm.shape == shape + (3,) diff --git a/tests/test_transformer.py b/tests/test_transformer.py index 0739a9e95a..32b0d53f6e 100644 --- a/tests/test_transformer.py +++ b/tests/test_transformer.py @@ -1,9 +1,12 @@ import pandas as pd +import pytest from numpy.testing import assert_allclose from pvlib import transformer +import numpy as np + def test_simple_efficiency(): @@ -40,21 +43,50 @@ def test_simple_efficiency(): assert_allclose(calculated_output_power, expected_output_power) -def test_simple_efficiency_known_values(): +@pytest.mark.parametrize( + "input_power, no_load_loss, load_loss, rating, expected", + [ + # no-load condition + (0.005 * 1000, 0.005, 0.01, 1000, 0.0), + + # rated condition + (1000 * (1 + 0.005 + 0.01), 0.005, 0.01, 1000, 1000), + + # zero load_loss case + # for load_loss = 0, the model reduces to: + # P_out = P_in - L_no_load * P_nom + (1000.0, 0.01, 0.0, 1000.0, 990.0), + ], +) +def test_simple_efficiency_numeric_cases( + input_power, no_load_loss, load_loss, rating, expected +): + result = transformer.simple_efficiency( + input_power=input_power, + no_load_loss=no_load_loss, + load_loss=load_loss, + transformer_rating=rating, + ) + + assert_allclose(result, expected) + + +def test_simple_efficiency_vector_equals_scalar(): + input_power = np.array([200.0, 600.0, 900.0]) no_load_loss = 0.005 load_loss = 0.01 - rating = 1000 - args = (no_load_loss, load_loss, rating) + rating = 1000.0 - # verify correct behavior at no-load condition - assert_allclose( - transformer.simple_efficiency(no_load_loss*rating, *args), - 0.0 + vector_result = transformer.simple_efficiency( + input_power=input_power, + no_load_loss=no_load_loss, + load_loss=load_loss, + transformer_rating=rating, ) - # verify correct behavior at rated condition - assert_allclose( - transformer.simple_efficiency(rating*(1 + no_load_loss + load_loss), - *args), - rating, - ) + scalar_result = np.array([ + transformer.simple_efficiency(p, no_load_loss, load_loss, rating) + for p in input_power + ]) + + assert_allclose(vector_result, scalar_result)