├── docs ├── authors.rst ├── readme.rst ├── changelog.rst ├── contributing.rst ├── units_table.csv ├── reference │ ├── index.rst │ └── feedinlib.rst ├── usage.rst ├── requirements.txt ├── installation.rst ├── spelling_wordlist.txt ├── examples.rst ├── whatsnew │ ├── v00012.txt │ ├── v0009.txt │ ├── v00011.txt │ ├── v00010.txt │ ├── v0008.txt │ └── v0007.txt ├── whats_new.rst ├── index.rst ├── _templates │ └── autosummary │ │ └── class.rst ├── parameter_names.rst ├── conf.py ├── api.rst └── getting_started.rst ├── ci ├── requirements.txt ├── templates │ ├── .appveyor.yml │ └── tox.ini └── bootstrap.py ├── examples ├── ERA5_example_data.nc └── simple_feedin.py ├── CHANGELOG.rst ├── .coveragerc ├── .readthedocs.yml ├── AUTHORS.rst ├── pyproject.toml ├── tests ├── test_weather_download.py ├── test_examples.py └── test_models.py ├── .editorconfig ├── src └── feedinlib │ ├── __init__.py │ ├── models │ ├── __init__.py │ ├── base.py │ ├── geometric_solar.py │ ├── pvlib.py │ └── windpowerlib.py │ ├── dedup.py │ ├── cds_request_tools.py │ ├── era5.py │ ├── powerplants.py │ └── open_FRED.py ├── .pre-commit-config.yaml ├── .bumpversion.cfg ├── MANIFEST.in ├── .github └── workflows │ ├── packaging.yml │ ├── tox_pytests.yml │ └── tox_checks.yml ├── .gitignore ├── LICENSE ├── setup.cfg ├── CONTRIBUTING.rst ├── .cookiecutterrc ├── tox.ini ├── setup.py ├── README.rst └── .appveyor.yml /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /ci/requirements.txt: -------------------------------------------------------------------------------- 1 | virtualenv>=16.6.0 2 | pip>=19.1.1 3 | setuptools>=18.0.1 4 | six>=1.14.0 5 | -------------------------------------------------------------------------------- /docs/units_table.csv: -------------------------------------------------------------------------------- 1 | Variable,Unit,Comment 2 | wind speed,m/s, 3 | power,W, 4 | irradiance,W/m^2, 5 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | .. toctree:: 5 | :glob: 6 | 7 | feedinlib* 8 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use feedinlib in a project:: 6 | 7 | import feedinlib 8 | -------------------------------------------------------------------------------- /examples/ERA5_example_data.nc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oemof/feedinlib/HEAD/examples/ERA5_example_data.nc -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=1.4 2 | ipykernel 3 | nbsphinx 4 | psycopg2 5 | psycopg2-binary 6 | sphinx-rtd-theme 7 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | ========= 4 | 5 | 0.0.0 (2021-06-10) 6 | ------------------ 7 | 8 | * First release on PyPI. 9 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | pip install feedinlib 8 | -------------------------------------------------------------------------------- /docs/reference/feedinlib.rst: -------------------------------------------------------------------------------- 1 | feedinlib 2 | ========= 3 | 4 | .. testsetup:: 5 | 6 | from feedinlib import * 7 | 8 | .. automodule:: feedinlib 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | builtin 2 | builtins 3 | classmethod 4 | staticmethod 5 | classmethods 6 | staticmethods 7 | args 8 | kwargs 9 | callstack 10 | Changelog 11 | Indices 12 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = src 3 | 4 | [run] 5 | branch = true 6 | source = 7 | src 8 | tests 9 | parallel = true 10 | 11 | [report] 12 | show_missing = true 13 | precision = 2 14 | omit = *migrations* 15 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | .. _examples_section_label: 2 | 3 | ############# 4 | Examples 5 | ############# 6 | 7 | 8 | .. toctree:: 9 | load_open_fred_weather_data 10 | load_era5_weather_data 11 | run_pvlib_model 12 | run_windpowerlib_turbine_model 13 | -------------------------------------------------------------------------------- /docs/whatsnew/v00012.txt: -------------------------------------------------------------------------------- 1 | v0.0.12 (June 22, 2017) 2 | +++++++++++++++++++++++++ 3 | 4 | Bug fixes 5 | ######### 6 | 7 | * fixed setup.py since feedinlib only works with windpowerlib version 0.0.4 8 | 9 | Contributors 10 | ############ 11 | 12 | * Birgit Schachler 13 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | version: 2 3 | sphinx: 4 | configuration: docs/conf.py 5 | formats: all 6 | python: 7 | install: 8 | - requirements: docs/requirements.txt 9 | - method: pip 10 | path: . 11 | -------------------------------------------------------------------------------- /docs/whatsnew/v0009.txt: -------------------------------------------------------------------------------- 1 | v0.0.9 (August 23, 2016) 2 | +++++++++++++++++++++++++ 3 | 4 | Bug fixes 5 | ######### 6 | 7 | * Adapt API due to changes in the pvlib 8 | * Avoid pandas future warning running the pv model 9 | 10 | Contributors 11 | ############ 12 | 13 | * Uwe Krien 14 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | 2 | Authors 3 | ======= 4 | 5 | oemof developer group - https://oemof.org 6 | 7 | (alphabetic order) 8 | 9 | * Birgit Schachler 10 | * Cord Kaldemeyer 11 | * Francesco Witte 12 | * gplssm 13 | * Patrik Schönfeldt 14 | * Pierre Francois 15 | * Sabine Haas 16 | * Stephan Günther 17 | * Stephen Bosch 18 | * Uwe Krien 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | target-version = ['py37', 'py38', 'py39'] 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | /( 7 | \.eggs 8 | | \.git 9 | | \.hg 10 | | \.mypy_cache 11 | | \.tox 12 | | \.venv 13 | | _build 14 | | buck-out 15 | | build 16 | | dist 17 | )/ 18 | ''' 19 | skip_magic_trailing_comma = true 20 | -------------------------------------------------------------------------------- /docs/whatsnew/v00011.txt: -------------------------------------------------------------------------------- 1 | v0.0.11 (November 22, 2016) 2 | ++++++++++++++++++++++++++++ 3 | 4 | New features 5 | ############ 6 | 7 | * Using model of windpowerlib instead of internal model. This will be the future of the feedinlib. 8 | 9 | Bug fixes 10 | ######### 11 | 12 | * removed 'vernetzen'-server because it is down 13 | 14 | Contributors 15 | ############ 16 | 17 | * Uwe Krien 18 | -------------------------------------------------------------------------------- /docs/whatsnew/v00010.txt: -------------------------------------------------------------------------------- 1 | v0.0.10 (November 18, 2016) 2 | +++++++++++++++++++++++++++++ 3 | 4 | 5 | Other changes 6 | ############# 7 | Move wind power calculations to windpowerlib 8 | Allow installation of windpowerlib for python versions >3.4 9 | Import requests package instead of urllib5 10 | 11 | Contributors 12 | ############ 13 | 14 | * Uwe Krien 15 | * Stephen Bosch 16 | * Birgit Schachler -------------------------------------------------------------------------------- /docs/whats_new.rst: -------------------------------------------------------------------------------- 1 | What's New 2 | ~~~~~~~~~~ 3 | 4 | These are new features and improvements of note in each release 5 | 6 | .. contents:: `Releases` 7 | :depth: 1 8 | :local: 9 | :backlinks: top 10 | 11 | .. include:: whatsnew/v00011.txt 12 | .. include:: whatsnew/v00010.txt 13 | .. include:: whatsnew/v0009.txt 14 | .. include:: whatsnew/v0008.txt 15 | .. include:: whatsnew/v0007.txt 16 | -------------------------------------------------------------------------------- /tests/test_weather_download.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import cdsapi 4 | 5 | from feedinlib import era5 6 | 7 | 8 | def test_era5_download(): 9 | cdsapi.Client = mock.Mock() 10 | instance_of_client = cdsapi.Client.return_value 11 | instance_of_client.download.return_value = None 12 | era5.get_era5_data_from_datespan_and_position( 13 | "2019-01-19", "2019-01-20", "test_file.nc", "50.0", "12.0" 14 | ) 15 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Contents 3 | ======== 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | readme 9 | getting_started 10 | installation 11 | usage 12 | examples 13 | api 14 | reference/index 15 | parameter_names 16 | contributing 17 | authors 18 | changelog 19 | whats_new 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see https://editorconfig.org/ 2 | root = true 3 | 4 | [*] 5 | # Use Unix-style newlines for most files (except Windows files, see below). 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | insert_final_newline = true 10 | indent_size = 4 11 | charset = utf-8 12 | 13 | [*.{bat,cmd,ps1}] 14 | end_of_line = crlf 15 | 16 | [*.{yml,yaml}] 17 | indent_size = 2 18 | 19 | [*.tsv] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /docs/whatsnew/v0008.txt: -------------------------------------------------------------------------------- 1 | v0.0.8 (Mai 2, 2016) 2 | +++++++++++++++++++++++++ 3 | 4 | New features 5 | ############ 6 | 7 | * add a geometry attribute for shapely.geometry objects to the weather class 8 | * add lookup table for the sandia pv modules 9 | 10 | 11 | Documentation 12 | ############# 13 | 14 | * add link to the developer rules of oemof 15 | 16 | 17 | Bug fixes 18 | ######### 19 | 20 | * Adapt url to sandia's module library 21 | 22 | 23 | Contributors 24 | ############ 25 | 26 | * Uwe Krien 27 | -------------------------------------------------------------------------------- /src/feedinlib/__init__.py: -------------------------------------------------------------------------------- 1 | __copyright__ = "Copyright oemof developer group" 2 | __license__ = "MIT" 3 | __version__ = '0.1.0rc4' 4 | 5 | from . import era5 # noqa: F401 6 | from .models import GeometricSolar # noqa: F401 7 | from .models import Pvlib # noqa: F401 8 | from .models import WindpowerlibTurbine # noqa: F401 9 | from .models import WindpowerlibTurbineCluster # noqa: F401 10 | from .models import get_power_plant_data # noqa: F401 11 | from .powerplants import Photovoltaic # noqa: F401 12 | from .powerplants import WindPowerPlant # noqa: F401 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # To install the git pre-commit hook run: 2 | # pre-commit install 3 | # To update the pre-commit hooks run: 4 | # pre-commit install-hooks 5 | exclude: '^(\.tox|ci/templates|\.bumpversion\.cfg)(/|$)' 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: master 9 | hooks: 10 | - id: trailing-whitespace 11 | - id: end-of-file-fixer 12 | - id: debug-statements 13 | - repo: https://github.com/timothycrosley/isort 14 | rev: master 15 | hooks: 16 | - id: isort 17 | - repo: https://gitlab.com/pycqa/flake8 18 | rev: master 19 | hooks: 20 | - id: flake8 21 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.0.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file (badge):README.rst] 11 | search = /v{current_version}.svg 12 | replace = /v{new_version}.svg 13 | 14 | [bumpversion:file (link):README.rst] 15 | search = /v{current_version}...master 16 | replace = /v{new_version}...master 17 | 18 | [bumpversion:file:docs/conf.py] 19 | search = version = release = '{current_version}' 20 | replace = version = release = '{new_version}' 21 | 22 | [bumpversion:file:src/feedinlib/__init__.py] 23 | search = __version__ = '{current_version}' 24 | replace = __version__ = '{new_version}' 25 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft src 3 | graft ci 4 | graft tests 5 | 6 | include .bumpversion.cfg 7 | include .cookiecutterrc 8 | include .coveragerc 9 | include .editorconfig 10 | include tox.ini 11 | include .appveyor.yml 12 | include .readthedocs.yml 13 | include .pre-commit-config.yaml 14 | include AUTHORS.rst 15 | include CHANGELOG.rst 16 | include CONTRIBUTING.rst 17 | include LICENSE 18 | include README.rst 19 | 20 | exclude docs/temp/*.rst 21 | exclude examples/example_data/* 22 | exclude tests/test_data/* 23 | 24 | recursive-include docs *.ipynb 25 | recursive-include examples *.csv 26 | recursive-include examples *.geojson 27 | recursive-include examples *.ipynb 28 | recursive-include examples *.nc 29 | recursive-include examples *.py 30 | 31 | global-exclude *.py[cod] __pycache__/* *.so *.dylib 32 | -------------------------------------------------------------------------------- /docs/whatsnew/v0007.txt: -------------------------------------------------------------------------------- 1 | v0.0.7 (October 20, 2015) 2 | +++++++++++++++++++++++++ 3 | 4 | New features 5 | ############ 6 | 7 | * add a weather class to define the structure of the weather data input 8 | * add example file to pass your own model class to the feedinlib 9 | 10 | 11 | Documentation 12 | ############# 13 | 14 | * correct some typos 15 | * some distribtions are clearer now 16 | * describe the used units 17 | 18 | 19 | Testing 20 | ####### 21 | 22 | * add more doctests 23 | * removed obsolete tests 24 | 25 | 26 | Bug fixes 27 | ######### 28 | 29 | * does not overwrite class attributes (issue 7) 30 | 31 | 32 | Other changes 33 | ############# 34 | 35 | * rename classes to more describing names 36 | * initialisation of a power plant changed (see README for details) 37 | 38 | 39 | Contributors 40 | ############ 41 | 42 | * Uwe Krien 43 | * Stephan Günther 44 | * Cord Kaldemeyer 45 | -------------------------------------------------------------------------------- /src/feedinlib/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Feed-in model classes. 5 | 6 | SPDX-FileCopyrightText: Birgit Schachler 7 | SPDX-FileCopyrightText: Uwe Krien 8 | SPDX-FileCopyrightText: Stephan Günther 9 | SPDX-FileCopyrightText: Stephen Bosch 10 | SPDX-FileCopyrightText: Patrik Schönfeldt 11 | 12 | SPDX-License-Identifier: MIT 13 | 14 | This module provides abstract classes as blueprints for classes that implement 15 | feed-in models for weather dependent renewable energy resources (in base). 16 | Furthermore, this module holds implementations of feed-in models (other files). 17 | """ 18 | 19 | from .base import get_power_plant_data # noqa: F401 20 | from .geometric_solar import GeometricSolar # noqa: F401 21 | from .pvlib import Pvlib # noqa: F401 22 | from .windpowerlib import WindpowerlibTurbine # noqa: F401 23 | from .windpowerlib import WindpowerlibTurbineCluster # noqa: F401 24 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/class.rst: -------------------------------------------------------------------------------- 1 | {{ fullname | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | 7 | {% block methods %} 8 | {% if methods %} 9 | .. rubric:: Methods 10 | 11 | .. autosummary:: 12 | {% for item in methods %} 13 | ~{{ name }}.{{ item }} 14 | {%- endfor %} 15 | {% endif %} 16 | {% endblock %} 17 | 18 | {% block attributes %} 19 | {% if attributes %} 20 | .. rubric:: Attributes 21 | 22 | .. autosummary:: 23 | {% for item in attributes %} 24 | ~{{ name }}.{{ item }} 25 | {%- endfor %} 26 | {% endif %} 27 | {% endblock %} 28 | 29 | {% block automethods %} 30 | {% if methods %} 31 | {% for item in methods %} 32 | .. automethod:: {{ item }} 33 | {%- endfor %} 34 | {% endif %} 35 | {% endblock %} 36 | 37 | {% block autoattributes %} 38 | {% if attributes %} 39 | {% for item in attributes %} 40 | .. autoattribute:: {{ item }} 41 | {%- endfor %} 42 | {% endif %} 43 | {% endblock %} -------------------------------------------------------------------------------- /.github/workflows/packaging.yml: -------------------------------------------------------------------------------- 1 | name: packaging 2 | 3 | on: 4 | # Make sure packaging process is not broken 5 | push: 6 | branches: [master] 7 | pull_request: 8 | # Make a package for release 9 | release: 10 | types: [published] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | max-parallel: 4 18 | matrix: 19 | python-version: [3.9] 20 | 21 | steps: 22 | - uses: actions/checkout@v1 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip setuptools setuptools_scm twine wheel 30 | - name: Create packages 31 | run: python setup.py sdist bdist_wheel 32 | - name: Run twine check 33 | run: twine check dist/* 34 | - uses: actions/upload-artifact@v4 35 | with: 36 | name: tox-gh-actions-dist 37 | path: dist 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | .eggs 14 | parts 15 | bin 16 | var 17 | sdist 18 | wheelhouse 19 | develop-eggs 20 | .installed.cfg 21 | lib 22 | lib64 23 | venv*/ 24 | pyvenv*/ 25 | pip-wheel-metadata/ 26 | 27 | # Installer logs 28 | pip-log.txt 29 | 30 | # Unit test / coverage reports 31 | .coverage 32 | .tox 33 | .coverage.* 34 | .pytest_cache/ 35 | nosetests.xml 36 | coverage.xml 37 | htmlcov 38 | 39 | # Translations 40 | *.mo 41 | 42 | # Buildout 43 | .mr.developer.cfg 44 | 45 | # IDE project files 46 | .project 47 | .pydevproject 48 | .idea 49 | .vscode 50 | *.iml 51 | *.komodoproject 52 | 53 | # Complexity 54 | output/*.html 55 | output/*/index.html 56 | 57 | # Sphinx 58 | docs/_build 59 | 60 | .DS_Store 61 | *~ 62 | .*.sw[po] 63 | .build 64 | .ve 65 | .env 66 | .cache 67 | .pytest 68 | .benchmarks 69 | .bootstrap 70 | .appveyor.token 71 | *.bak 72 | docs/temp/* 73 | 74 | # Mypy Cache 75 | .mypy_cache/ 76 | 77 | tests/test_data/* 78 | examples/example_data/* 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) oemof developer group 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /.github/workflows/tox_pytests.yml: -------------------------------------------------------------------------------- 1 | name: tox pytests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | pull_request: 9 | branches: 10 | - master 11 | - dev 12 | - releases/0.1.0 13 | workflow_dispatch: 14 | schedule: 15 | - cron: "0 5 * * 6" # 5:00 UTC every Saturday 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | python-version: [3.6, 3.9] 23 | 24 | steps: 25 | - uses: actions/checkout@v1 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install tox tox-gh-actions coverage coveralls 34 | - name: Test with tox 35 | run: tox 36 | 37 | - name: Check test coverage 38 | run: coverage report -m --fail-under=${{ matrix.vcs == 'bzr' && 57 || 58 }} 39 | 40 | - name: Report to coveralls 41 | run: coveralls 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | COVERALLS_SERVICE_NAME: github 45 | -------------------------------------------------------------------------------- /docs/parameter_names.rst: -------------------------------------------------------------------------------- 1 | ==================================================== ====== ================== ====== ================== ====== 2 | feedinlib windpowerlib pvlib 3 | ------------------------------------------------------------ -------------------------- -------------------------- 4 | parameter unit parameter unit parameter unit 5 | ==================================================== ====== ================== ====== ================== ====== 6 | wind_speed m/s wind_speed m/s wind_speed m/s 7 | air_temperature K temperature K temp_air C 8 | pressure Pa pressure Pa 9 | roughness_length m roughness_length m 10 | surface_normalized_global_downwelling_shortwave_flux W/m^2 ghi W/m^2 11 | surface_diffuse_downwelling_shortwave_flux W/m^2 dhi W/m^2 12 | surface_normalized_direct_downwelling_shortwave_flux W/m^2 dni W/m^2 13 | ==================================================== ====== ================== ====== ================== ====== -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import os 5 | 6 | extensions = [ 7 | 'sphinx.ext.autodoc', 8 | 'sphinx.ext.autosummary', 9 | 'sphinx.ext.coverage', 10 | 'sphinx.ext.doctest', 11 | 'sphinx.ext.extlinks', 12 | 'sphinx.ext.ifconfig', 13 | 'sphinx.ext.napoleon', 14 | 'sphinx.ext.todo', 15 | 'sphinx.ext.viewcode', 16 | ] 17 | source_suffix = '.rst' 18 | master_doc = 'index' 19 | project = 'feedinlib' 20 | year = '2015-2021' 21 | author = 'oemof developer group' 22 | copyright = '{0}, {1}'.format(year, author) 23 | version = release = '0.0.0' 24 | 25 | pygments_style = 'trac' 26 | templates_path = ['.'] 27 | extlinks = { 28 | 'issue': ('https://github.com/oemof/feedinlib/issues/%s', '#'), 29 | 'pr': ('https://github.com/oemof/feedinlib/pull/%s', 'PR #'), 30 | } 31 | # on_rtd is whether we are on readthedocs.org 32 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 33 | 34 | if not on_rtd: # only set the theme if we're building docs locally 35 | html_theme = 'sphinx_rtd_theme' 36 | 37 | html_use_smartypants = True 38 | html_last_updated_fmt = '%b %d, %Y' 39 | html_split_index = False 40 | html_sidebars = { 41 | '**': ['searchbox.html', 'globaltoc.html', 'sourcelink.html'], 42 | } 43 | html_short_title = '%s-%s' % (project, version) 44 | 45 | napoleon_use_ivar = True 46 | napoleon_use_rtype = False 47 | napoleon_use_param = False 48 | -------------------------------------------------------------------------------- /.github/workflows/tox_checks.yml: -------------------------------------------------------------------------------- 1 | # NB: this name is used in the status badge 2 | name: tox checks 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | - dev 9 | pull_request: 10 | branches: 11 | - master 12 | - dev 13 | - releases/0.1.0 14 | workflow_dispatch: 15 | schedule: 16 | - cron: "0 5 * * 6" # 5:00 UTC every Saturday 17 | 18 | jobs: 19 | lint: 20 | name: ${{ matrix.toxenv }} 21 | runs-on: ubuntu-latest 22 | 23 | strategy: 24 | matrix: 25 | toxenv: 26 | - clean 27 | - check 28 | - docs 29 | 30 | steps: 31 | - name: Git clone 32 | uses: actions/checkout@v2 33 | 34 | - name: Set up Python ${{ env.default_python || '3.9' }} 35 | uses: actions/setup-python@v2 36 | with: 37 | python-version: "${{ env.default_python || '3.9' }}" 38 | 39 | - name: Pip cache 40 | uses: actions/cache@v2 41 | with: 42 | path: ~/.cache/pip 43 | key: ${{ runner.os }}-pip-${{ matrix.toxenv }}-${{ hashFiles('tox.ini', 'setup.py') }} 44 | restore-keys: | 45 | ${{ runner.os }}-pip-${{ matrix.toxenv }}- 46 | ${{ runner.os }}-pip- 47 | 48 | - name: Install dependencies 49 | run: | 50 | python -m pip install -U pip 51 | python -m pip install -U setuptools wheel 52 | python -m pip install -U tox 53 | 54 | - name: Run ${{ matrix.toxenv }} 55 | run: python -m tox -e ${{ matrix.toxenv }} 56 | -------------------------------------------------------------------------------- /ci/templates/.appveyor.yml: -------------------------------------------------------------------------------- 1 | version: '{branch}-{build}' 2 | build: off 3 | image: Visual Studio 2019 4 | environment: 5 | global: 6 | COVERALLS_EXTRAS: '-v' 7 | COVERALLS_REPO_TOKEN: COVERALLSTOKEN 8 | matrix: 9 | - TOXENV: check 10 | TOXPYTHON: C:\Python36\python.exe 11 | PYTHON_HOME: C:\Python36 12 | PYTHON_VERSION: '3.6' 13 | PYTHON_ARCH: '32' 14 | {% for env, config in tox_environments|dictsort %} 15 | {% if env.startswith(('py2', 'py3')) %} 16 | - TOXENV: {{ env }}{% if config.cover %},coveralls{% endif %}{{ "" }} 17 | TOXPYTHON: C:\Python{{ env[2:4] }}\python.exe 18 | PYTHON_HOME: C:\Python{{ env[2:4] }} 19 | PYTHON_VERSION: '{{ env[2] }}.{{ env[3] }}' 20 | PYTHON_ARCH: '32' 21 | {% if 'nocov' in env %} 22 | WHEEL_PATH: .tox/dist 23 | {% endif %} 24 | - TOXENV: {{ env }}{% if config.cover %},coveralls{% endif %}{{ "" }} 25 | TOXPYTHON: C:\Python{{ env[2:4] }}-x64\python.exe 26 | PYTHON_HOME: C:\Python{{ env[2:4] }}-x64 27 | PYTHON_VERSION: '{{ env[2] }}.{{ env[3] }}' 28 | PYTHON_ARCH: '64' 29 | {% if 'nocov' in env %} 30 | WHEEL_PATH: .tox/dist 31 | {% endif %} 32 | {% endif %}{% endfor %} 33 | init: 34 | - ps: echo $env:TOXENV 35 | - ps: ls C:\Python* 36 | install: 37 | - '%PYTHON_HOME%\python -mpip install --progress-bar=off tox -rci/requirements.txt' 38 | - '%PYTHON_HOME%\Scripts\virtualenv --version' 39 | - '%PYTHON_HOME%\Scripts\pip --version' 40 | - '%PYTHON_HOME%\Scripts\tox --version' 41 | test_script: 42 | - %PYTHON_HOME%\Scripts\tox 43 | on_failure: 44 | - ps: dir "env:" 45 | - ps: get-content .tox\*\log\* 46 | 47 | ### To enable remote debugging uncomment this (also, see: http://www.appveyor.com/docs/how-to/rdp-to-build-worker): 48 | # on_finish: 49 | # - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) 50 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | ############# 4 | API 5 | ############# 6 | 7 | 8 | Power plant classes 9 | ==================== 10 | 11 | Power plant classes for specific weather dependent renewable energy resources. 12 | 13 | .. autosummary:: 14 | :toctree: temp/ 15 | 16 | feedinlib.powerplants.Photovoltaic 17 | feedinlib.powerplants.WindPowerPlant 18 | 19 | Feed-in models 20 | =============== 21 | 22 | Feed-in models take in power plant and weather data to calculate power plant feed-in. 23 | So far models using the python libraries pvlib and windpowerlib to calculate photovoltaic and 24 | wind power feed-in, respectively, have been implemented. 25 | 26 | .. autosummary:: 27 | :toctree: temp/ 28 | 29 | feedinlib.models.Pvlib 30 | feedinlib.models.WindpowerlibTurbine 31 | feedinlib.models.WindpowerlibTurbineCluster 32 | 33 | Weather data 34 | ==================== 35 | 36 | The feedinlib enables download of open_FRED weather data (local reanalysis data for Germany) 37 | and ERA5 weather data (global reanalysis data for the whole world). 38 | 39 | .. autosummary:: 40 | :toctree: temp/ 41 | 42 | feedinlib.open_FRED.Weather 43 | feedinlib.era5.weather_df_from_era5 44 | feedinlib.era5.get_era5_data_from_datespan_and_position 45 | 46 | Tools 47 | ==================== 48 | 49 | .. autosummary:: 50 | :toctree: temp/ 51 | 52 | feedinlib.models.get_power_plant_data 53 | 54 | Abstract classes 55 | ==================== 56 | 57 | The feedinlib uses abstract classes for power plant and feed-in models that serve as blueprints 58 | for classes that implement those models. This ensures that new models provide required 59 | implementations that make it possible to easily exchange the model used in your calculation. 60 | They are important for people who want to implement new power plant and model classes 61 | rather than for users. 62 | 63 | .. autosummary:: 64 | :toctree: temp/ 65 | 66 | feedinlib.powerplants.Base 67 | feedinlib.models.Base 68 | feedinlib.models.PhotovoltaicModelBase 69 | feedinlib.models.WindpowerModelBase -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import tempfile 4 | 5 | import nbformat 6 | import pytest 7 | 8 | 9 | class TestExamples: 10 | 11 | def _notebook_run(self, path): 12 | """ 13 | Execute a notebook via nbconvert and collect output. 14 | Returns (parsed nb object, execution errors) 15 | """ 16 | dirname, __ = os.path.split(path) 17 | os.chdir(dirname) 18 | with tempfile.NamedTemporaryFile(suffix=".ipynb") as fout: 19 | args = ["jupyter", "nbconvert", "--to", "notebook", "--execute", 20 | "--ExecutePreprocessor.timeout=200", 21 | "--output", fout.name, path] 22 | subprocess.check_call(args) 23 | 24 | fout.seek(0) 25 | nb = nbformat.read(fout, nbformat.current_nbformat) 26 | 27 | errors = [output for cell in nb.cells if "outputs" in cell 28 | for output in cell["outputs"] 29 | if output.output_type == "error"] 30 | 31 | return nb, errors 32 | 33 | @pytest.mark.skip(reason="Should examples be part of the package" 34 | "in the first place?.") 35 | def test_load_era5_ipynb(self): 36 | parent_dirname = os.path.dirname(os.path.dirname(__file__)) 37 | nb, errors = self._notebook_run( 38 | os.path.join(parent_dirname, 'example', 39 | 'load_era5_weather_data.ipynb')) 40 | assert errors == [] 41 | 42 | @pytest.mark.skip(reason="Requires open_FRED," 43 | "which depends on oemof <0.4.") 44 | def test_pvlib_ipynb(self): 45 | parent_dirname = os.path.dirname(os.path.dirname(__file__)) 46 | nb, errors = self._notebook_run( 47 | os.path.join(parent_dirname, 'example', 48 | 'run_pvlib_model.ipynb')) 49 | assert errors == [] 50 | 51 | @pytest.mark.skip(reason="Requires open_FRED," 52 | "which depends on oemof <0.4.") 53 | def test_windpowerlib_turbine_ipynb(self): 54 | parent_dirname = os.path.dirname(os.path.dirname(__file__)) 55 | nb, errors = self._notebook_run( 56 | os.path.join(parent_dirname, 'example', 57 | 'run_windpowerlib_turbine_model.ipynb')) 58 | assert errors == [] 59 | -------------------------------------------------------------------------------- /ci/templates/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | clean, 4 | check, 5 | docs, 6 | {% for env in tox_environments|sort %} 7 | {{ env }}, 8 | {% endfor %} 9 | report 10 | 11 | [testenv] 12 | basepython = 13 | {bootstrap,clean,check,report,docs,coveralls}: {env:TOXPYTHON:python3} 14 | setenv = 15 | PYTHONPATH={toxinidir}/tests 16 | PYTHONUNBUFFERED=yes 17 | passenv = 18 | * 19 | deps = 20 | pytest 21 | commands = 22 | {posargs:pytest -vv --ignore=src} 23 | 24 | [testenv:bootstrap] 25 | deps = 26 | jinja2 27 | matrix 28 | skip_install = true 29 | commands = 30 | python ci/bootstrap.py --no-env 31 | 32 | [testenv:check] 33 | deps = 34 | docutils 35 | check-manifest 36 | flake8 37 | readme-renderer 38 | pygments 39 | isort 40 | skip_install = true 41 | commands = 42 | python setup.py check --strict --metadata --restructuredtext 43 | check-manifest {toxinidir} 44 | flake8 45 | isort --verbose --check-only --diff --filter-files . 46 | 47 | 48 | [testenv:docs] 49 | usedevelop = true 50 | deps = 51 | -r{toxinidir}/docs/requirements.txt 52 | commands = 53 | sphinx-build {posargs:-E} -b html docs dist/docs 54 | sphinx-build -b linkcheck docs dist/docs 55 | 56 | [testenv:coveralls] 57 | deps = 58 | coveralls 59 | skip_install = true 60 | commands = 61 | coveralls [] 62 | 63 | 64 | 65 | [testenv:report] 66 | deps = coverage 67 | skip_install = true 68 | commands = 69 | coverage report 70 | coverage html 71 | 72 | [testenv:clean] 73 | commands = coverage erase 74 | skip_install = true 75 | deps = coverage 76 | {% for env, config in tox_environments|dictsort %} 77 | 78 | [testenv:{{ env }}] 79 | basepython = {env:TOXPYTHON:{{ env.split("-")[0] if env.startswith("pypy") else "python{0[2]}.{0[3]}".format(env) }}} 80 | {% if config.cover or config.env_vars %} 81 | setenv = 82 | {[testenv]setenv} 83 | {% endif %} 84 | {% for var in config.env_vars %} 85 | {{ var }} 86 | {% endfor %} 87 | {% if config.cover %} 88 | usedevelop = true 89 | commands = 90 | {posargs:pytest --cov --cov-report=term-missing -vv} 91 | {% endif %} 92 | {% if config.cover or config.deps %} 93 | deps = 94 | {[testenv]deps} 95 | {% endif %} 96 | {% if config.cover %} 97 | pytest-cov 98 | {% endif %} 99 | {% for dep in config.deps %} 100 | {{ dep }} 101 | {% endfor -%} 102 | {% endfor -%} 103 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | max-line-length = 79 6 | exclude = .tox,.eggs,ci/templates,build,dist 7 | 8 | [tool:pytest] 9 | # If a pytest section is found in one of the possible config files 10 | # (pytest.ini, tox.ini or setup.cfg), then pytest will not look for any others, 11 | # so if you add a pytest config section elsewhere, 12 | # you will need to delete this section from setup.cfg. 13 | norecursedirs = 14 | .git 15 | .tox 16 | .env 17 | dist 18 | build 19 | migrations 20 | 21 | python_files = 22 | test_*.py 23 | *_test.py 24 | tests.py 25 | addopts = 26 | -ra 27 | --strict-markers 28 | --ignore=docs/conf.py 29 | --ignore=setup.py 30 | --ignore=ci 31 | --ignore=.eggs 32 | --doctest-modules 33 | --doctest-glob=\*.rst 34 | --tb=short 35 | --pyargs 36 | testpaths = 37 | feedinlib 38 | tests/ 39 | 40 | [tool:isort] 41 | force_single_line = True 42 | line_length = 79 43 | known_first_party = feedinlib 44 | default_section = THIRDPARTY 45 | forced_separate = test_feedinlib 46 | skip = .tox,.eggs,ci/templates,build,dist 47 | multi_line_output = 3 48 | include_trailing_comma = True 49 | use_parentheses = True 50 | 51 | [matrix] 52 | # This is the configuration for the `./bootstrap.py` script. 53 | # It generates `.travis.yml`, `tox.ini` and `.appveyor.yml`. 54 | # 55 | # Syntax: [alias:] value [!variable[glob]] [&variable[glob]] 56 | # 57 | # alias: 58 | # - is used to generate the tox environment 59 | # - it's optional 60 | # - if not present the alias will be computed from the `value` 61 | # value: 62 | # - a value of "-" means empty 63 | # !variable[glob]: 64 | # - exclude the combination of the current `value` with 65 | # any value matching the `glob` in `variable` 66 | # - can use as many you want 67 | # &variable[glob]: 68 | # - only include the combination of the current `value` 69 | # when there's a value matching `glob` in `variable` 70 | # - can use as many you want 71 | 72 | python_versions = 73 | py36 74 | py37 75 | py38 76 | py39 77 | 78 | dependencies = 79 | # 1.4: Django==1.4.16 !python_versions[py3*] 80 | # 1.5: Django==1.5.11 81 | # 1.6: Django==1.6.8 82 | # 1.7: Django==1.7.1 !python_versions[py26] 83 | # Deps commented above are provided as examples. That's what you would use in a Django project. 84 | 85 | coverage_flags = 86 | cover: true 87 | nocov: false 88 | environment_variables = 89 | - 90 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Bug reports 9 | =========== 10 | 11 | When `reporting a bug `_ please include: 12 | 13 | * Your operating system name and version. 14 | * Any details about your local setup that might be helpful in troubleshooting. 15 | * Detailed steps to reproduce the bug. 16 | 17 | Documentation improvements 18 | ========================== 19 | 20 | feedinlib could always use more documentation, whether as part of the 21 | official feedinlib docs, in docstrings, or even on the web in blog posts, 22 | articles, and such. 23 | 24 | Feature requests and feedback 25 | ============================= 26 | 27 | The best way to send feedback is to file an issue at https://github.com/oemof/feedinlib/issues. 28 | 29 | If you are proposing a feature: 30 | 31 | * Explain in detail how it would work. 32 | * Keep the scope as narrow as possible, to make it easier to implement. 33 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 34 | 35 | Development 36 | =========== 37 | 38 | To set up `feedinlib` for local development: 39 | 40 | 1. Fork `feedinlib `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:YOURGITHUBNAME/feedinlib.git 45 | 46 | 3. Create a branch for local development:: 47 | 48 | git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 4. When you're done making changes run all the checks and docs builder with `tox `_ one command:: 53 | 54 | tox 55 | 56 | 5. Commit your changes and push your branch to GitHub:: 57 | 58 | git add . 59 | git commit -m "Your detailed description of your changes." 60 | git push origin name-of-your-bugfix-or-feature 61 | 62 | 6. Submit a pull request through the GitHub website. 63 | 64 | Pull Request Guidelines 65 | ----------------------- 66 | 67 | If you need some code review or feedback while you're developing the code just make the pull request. 68 | 69 | For merging, you should: 70 | 71 | 1. Include passing tests (run ``tox``). 72 | 2. Update documentation when there's new API, functionality etc. 73 | 3. Add a note to ``CHANGELOG.rst`` about the changes. 74 | 4. Add yourself to ``AUTHORS.rst``. 75 | 76 | 77 | 78 | Tips 79 | ---- 80 | 81 | To run a subset of tests:: 82 | 83 | tox -e envname -- pytest -k test_myfeature 84 | 85 | To run all the test environments in *parallel*:: 86 | 87 | tox -p auto 88 | -------------------------------------------------------------------------------- /.cookiecutterrc: -------------------------------------------------------------------------------- 1 | # This file exists so you can easily regenerate your project. 2 | # 3 | # `cookiepatcher` is a convenient shim around `cookiecutter` 4 | # for regenerating projects (it will generate a .cookiecutterrc 5 | # automatically for any template). To use it: 6 | # 7 | # pip install cookiepatcher 8 | # cookiepatcher gh:ionelmc/cookiecutter-pylibrary feedinlib 9 | # 10 | # See: 11 | # https://pypi.org/project/cookiepatcher 12 | # 13 | # Alternatively, you can run: 14 | # 15 | # cookiecutter --overwrite-if-exists --config-file=feedinlib/.cookiecutterrc gh:ionelmc/cookiecutter-pylibrary 16 | 17 | default_context: 18 | 19 | _extensions: ['jinja2_time.TimeExtension'] 20 | _template: 'gh:ionelmc/cookiecutter-pylibrary' 21 | allow_tests_inside_package: 'no' 22 | appveyor: 'yes' 23 | c_extension_function: 'longest' 24 | c_extension_module: '_feedinlib' 25 | c_extension_optional: 'no' 26 | c_extension_support: 'no' 27 | c_extension_test_pypi: 'no' 28 | c_extension_test_pypi_username: 'oemof' 29 | codacy: 'no' 30 | codacy_projectid: 'CODACY_ID' 31 | codeclimate: 'no' 32 | codecov: 'no' 33 | command_line_interface: 'plain' 34 | command_line_interface_bin_name: 'feedinlib' 35 | coveralls: 'yes' 36 | coveralls_token: 'COVERALLSTOKEN' 37 | distribution_name: 'feedinlib' 38 | email: 'contact@oemof.org' 39 | full_name: 'oemof developer group' 40 | legacy_python: 'no' 41 | license: 'MIT license' 42 | linter: 'flake8' 43 | package_name: 'feedinlib' 44 | pre_commit: 'yes' 45 | project_name: 'feedinlib' 46 | project_short_description: 'Connect weather data interfaces with interfaces of wind and pv power models.' 47 | pypi_badge: 'yes' 48 | pypi_disable_upload: 'no' 49 | release_date: 'today' 50 | repo_hosting: 'github.com' 51 | repo_hosting_domain: 'github.com' 52 | repo_name: 'feedinlib' 53 | repo_username: 'oemof' 54 | requiresio: 'yes' 55 | scrutinizer: 'no' 56 | setup_py_uses_setuptools_scm: 'no' 57 | setup_py_uses_test_runner: 'no' 58 | sphinx_docs: 'yes' 59 | sphinx_docs_hosting: 'https://feedinlib.readthedocs.io/' 60 | sphinx_doctest: 'no' 61 | sphinx_theme: 'sphinx-rtd-theme' 62 | test_matrix_configurator: 'yes' 63 | test_matrix_separate_coverage: 'yes' 64 | test_runner: 'pytest' 65 | travis: 'no' 66 | travis_osx: 'no' 67 | version: '0.0.0' 68 | version_manager: 'bump2version' 69 | website: 'https://oemof.org' 70 | year_from: '2015' 71 | year_to: '2021' 72 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | clean, 4 | check, 5 | docs, 6 | py36-cover, 7 | py36-nocov, 8 | py37-cover, 9 | py37-nocov, 10 | py38-cover, 11 | py38-nocov, 12 | py39-cover, 13 | py39-nocov, 14 | py3-nocov, 15 | report 16 | 17 | [gh-actions] 18 | python = 19 | 3.6: py36-cover 20 | 3.7: py37-cover 21 | 3.8: py38-cover 22 | 3.9: py39-cover 23 | 24 | [testenv] 25 | basepython = 26 | {bootstrap,clean,check,report,docs,coveralls}: {env:TOXPYTHON:python3} 27 | setenv = 28 | PYTHONPATH={toxinidir}/tests 29 | PYTHONUNBUFFERED=yes 30 | passenv = 31 | * 32 | extras = dev 33 | deps = 34 | pytest 35 | commands = 36 | {posargs:pytest -vv --ignore=src} 37 | 38 | [testenv:bootstrap] 39 | deps = 40 | jinja2 41 | matrix 42 | skip_install = true 43 | commands = 44 | python ci/bootstrap.py --no-env 45 | 46 | [testenv:check] 47 | deps = 48 | docutils 49 | check-manifest 50 | flake8 51 | readme-renderer 52 | pygments 53 | isort 54 | skip_install = true 55 | commands = 56 | python setup.py check --strict --metadata --restructuredtext 57 | check-manifest {toxinidir} 58 | flake8 59 | isort --verbose --check-only --diff --filter-files . 60 | 61 | 62 | [testenv:docs] 63 | usedevelop = true 64 | deps = 65 | -r{toxinidir}/docs/requirements.txt 66 | commands = 67 | sphinx-build {posargs:-E} -b html docs dist/docs 68 | sphinx-build -b linkcheck docs dist/docs 69 | 70 | [testenv:coveralls] 71 | deps = 72 | coveralls 73 | skip_install = true 74 | commands = 75 | coveralls [] 76 | 77 | 78 | 79 | [testenv:report] 80 | deps = coverage 81 | skip_install = true 82 | commands = 83 | coverage report 84 | coverage html 85 | 86 | [testenv:clean] 87 | commands = coverage erase 88 | skip_install = true 89 | deps = coverage 90 | 91 | [testenv:py36-cover] 92 | basepython = {env:TOXPYTHON:python3.6} 93 | setenv = 94 | {[testenv]setenv} 95 | usedevelop = true 96 | commands = 97 | {posargs:pytest --cov --cov-report=term-missing -vv} 98 | deps = 99 | {[testenv]deps} 100 | pytest-cov 101 | 102 | [testenv:py36-nocov] 103 | basepython = {env:TOXPYTHON:python3.6} 104 | 105 | [testenv:py37-cover] 106 | basepython = {env:TOXPYTHON:python3.7} 107 | setenv = 108 | {[testenv]setenv} 109 | usedevelop = true 110 | commands = 111 | {posargs:pytest --cov --cov-report=term-missing -vv} 112 | deps = 113 | {[testenv]deps} 114 | pytest-cov 115 | 116 | [testenv:py37-nocov] 117 | basepython = {env:TOXPYTHON:python3.7} 118 | 119 | [testenv:py38-cover] 120 | basepython = {env:TOXPYTHON:python3.8} 121 | setenv = 122 | {[testenv]setenv} 123 | usedevelop = true 124 | commands = 125 | {posargs:pytest --cov --cov-report=term-missing -vv} 126 | deps = 127 | {[testenv]deps} 128 | pytest-cov 129 | 130 | [testenv:py38-nocov] 131 | basepython = {env:TOXPYTHON:python3.8} 132 | 133 | [testenv:py39-cover] 134 | basepython = {env:TOXPYTHON:python3.9} 135 | setenv = 136 | {[testenv]setenv} 137 | usedevelop = true 138 | commands = 139 | {posargs:pytest --cov --cov-report=term-missing -vv} 140 | deps = 141 | {[testenv]deps} 142 | pytest-cov 143 | 144 | [testenv:py39-nocov] 145 | basepython = {env:TOXPYTHON:python3.9} 146 | 147 | [testenv:py3-nocov] 148 | basepython = {env:TOXPYTHON:python3} 149 | -------------------------------------------------------------------------------- /ci/bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import absolute_import 4 | from __future__ import print_function 5 | from __future__ import unicode_literals 6 | 7 | import os 8 | import subprocess 9 | import sys 10 | from os.path import abspath 11 | from os.path import dirname 12 | from os.path import exists 13 | from os.path import join 14 | 15 | base_path = dirname(dirname(abspath(__file__))) 16 | 17 | 18 | def check_call(args): 19 | print("+", *args) 20 | subprocess.check_call(args) 21 | 22 | 23 | def exec_in_env(): 24 | env_path = join(base_path, ".tox", "bootstrap") 25 | if sys.platform == "win32": 26 | bin_path = join(env_path, "Scripts") 27 | else: 28 | bin_path = join(env_path, "bin") 29 | if not exists(env_path): 30 | import subprocess 31 | 32 | print("Making bootstrap env in: {0} ...".format(env_path)) 33 | try: 34 | check_call([sys.executable, "-m", "venv", env_path]) 35 | except subprocess.CalledProcessError: 36 | try: 37 | check_call([sys.executable, "-m", "virtualenv", env_path]) 38 | except subprocess.CalledProcessError: 39 | check_call(["virtualenv", env_path]) 40 | print("Installing `jinja2` into bootstrap environment...") 41 | check_call( 42 | [join(bin_path, "pip"), "install", "jinja2", "tox", "matrix"] 43 | ) 44 | python_executable = join(bin_path, "python") 45 | if not os.path.exists(python_executable): 46 | python_executable += ".exe" 47 | 48 | print("Re-executing with: {0}".format(python_executable)) 49 | print("+ exec", python_executable, __file__, "--no-env") 50 | os.execv(python_executable, [python_executable, __file__, "--no-env"]) 51 | 52 | 53 | def main(): 54 | import jinja2 55 | import matrix 56 | 57 | print("Project path: {0}".format(base_path)) 58 | 59 | jinja = jinja2.Environment( 60 | loader=jinja2.FileSystemLoader(join(base_path, "ci", "templates")), 61 | trim_blocks=True, 62 | lstrip_blocks=True, 63 | keep_trailing_newline=True, 64 | ) 65 | 66 | tox_environments = {} 67 | for (alias, conf) in matrix.from_file( 68 | join(base_path, "setup.cfg") 69 | ).items(): 70 | deps = conf["dependencies"] 71 | tox_environments[alias] = { 72 | "deps": deps.split(), 73 | } 74 | if "coverage_flags" in conf: 75 | cover = {"false": False, "true": True}[ 76 | conf["coverage_flags"].lower() 77 | ] 78 | tox_environments[alias].update(cover=cover) 79 | if "environment_variables" in conf: 80 | env_vars = conf["environment_variables"] 81 | tox_environments[alias].update(env_vars=env_vars.split()) 82 | 83 | for name in os.listdir(join("ci", "templates")): 84 | with open(join(base_path, name), "w") as fh: 85 | fh.write( 86 | jinja.get_template(name).render( 87 | tox_environments=tox_environments 88 | ) 89 | ) 90 | print("Wrote {}".format(name)) 91 | print("DONE.") 92 | 93 | 94 | if __name__ == "__main__": 95 | args = sys.argv[1:] 96 | if args == ["--no-env"]: 97 | main() 98 | elif not args: 99 | exec_in_env() 100 | else: 101 | print("Unexpected arguments {0}".format(args), file=sys.stderr) 102 | sys.exit(1) 103 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | import io 5 | import re 6 | from glob import glob 7 | from os.path import basename 8 | from os.path import dirname 9 | from os.path import join 10 | from os.path import splitext 11 | 12 | from setuptools import find_packages 13 | from setuptools import setup 14 | 15 | 16 | def read(*names, **kwargs): 17 | with io.open( 18 | join(dirname(__file__), *names), 19 | encoding=kwargs.get("encoding", "utf8"), 20 | ) as fh: 21 | return fh.read() 22 | 23 | 24 | setup( 25 | name="feedinlib", 26 | version="0.1.0rc4", 27 | license="MIT", 28 | description=( 29 | "Connect weather data interfaces with interfaces of wind and pv power " 30 | "models." 31 | ), 32 | long_description="%s\n%s" 33 | % ( 34 | re.compile("^.. start-badges.*^.. end-badges", re.M | re.S).sub( 35 | "", read("README.rst") 36 | ), 37 | re.sub(":[a-z]+:`~?(.*?)`", r"``\1``", read("CHANGELOG.rst")), 38 | ), 39 | long_description_content_type="text/x-rst", 40 | author="oemof developer group", 41 | author_email="contact@oemof.org", 42 | url="https://github.com/oemof/feedinlib", 43 | packages=find_packages("src"), 44 | package_dir={"": "src"}, 45 | py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")], 46 | include_package_data=True, 47 | zip_safe=False, 48 | classifiers=[ 49 | # complete classifier list: 50 | # http://pypi.python.org/pypi?%3Aaction=list_classifiers 51 | "Development Status :: 4 - Beta", 52 | "Intended Audience :: Developers", 53 | "License :: OSI Approved :: MIT License", 54 | "Operating System :: Unix", 55 | "Operating System :: POSIX", 56 | "Operating System :: Microsoft :: Windows", 57 | "Programming Language :: Python", 58 | "Programming Language :: Python :: 3", 59 | "Programming Language :: Python :: 3 :: Only", 60 | "Programming Language :: Python :: 3.6", 61 | "Programming Language :: Python :: 3.7", 62 | "Programming Language :: Python :: 3.8", 63 | "Programming Language :: Python :: 3.9", 64 | "Programming Language :: Python :: Implementation :: CPython", 65 | "Programming Language :: Python :: Implementation :: PyPy", 66 | # uncomment if you test on these interpreters: 67 | # 'Programming Language :: Python :: Implementation :: IronPython', 68 | # 'Programming Language :: Python :: Implementation :: Jython', 69 | # 'Programming Language :: Python :: Implementation :: Stackless', 70 | "Topic :: Utilities", 71 | ], 72 | project_urls={ 73 | "Documentation": "https://feedinlib.readthedocs.io/", 74 | "Changelog": ( 75 | "https://feedinlib.readthedocs.io/en/latest/changelog.html" 76 | ), 77 | "Issue Tracker": "https://github.com/oemof/feedinlib/issues", 78 | }, 79 | keywords=[ 80 | # eg: 'keyword1', 'keyword2', 'keyword3', 81 | ], 82 | python_requires=">=3.6", 83 | install_requires=[ 84 | "cdsapi >= 0.1.4", 85 | "geopandas", 86 | "numpy >= 1.17.0", 87 | "oedialect >= 0.0.6.dev0", 88 | "pvlib >= 0.7.0", 89 | "tables", 90 | "open_FRED-cli", 91 | "windpowerlib > 0.2.0", 92 | "pandas >= 1.0", 93 | "xarray >= 0.12.0", 94 | "descartes", 95 | "SQLAlchemy < 1.4.0, >=1.3.0" 96 | ], 97 | extras_require={ 98 | "dev": [ 99 | "jupyter", 100 | "nbformat", 101 | "punch.py", 102 | "pytest", 103 | "sphinx_rtd_theme", 104 | "open_FRED-cli" 105 | ], 106 | "data-sources": [ 107 | "open_FRED-cli", 108 | ], 109 | "examples": ["jupyter", "matplotlib", "descartes"], 110 | }, 111 | ) 112 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Overview 3 | ======== 4 | 5 | .. start-badges 6 | 7 | .. list-table:: 8 | :stub-columns: 1 9 | 10 | |workflow_pytests| |workflow_checks| |docs| |appveyor| |requires| |coveralls| |packaging| 11 | |version| |wheel| |supported-versions| |supported-implementations| |commits-since| 12 | 13 | .. |docs| image:: https://readthedocs.org/projects/feedinlib/badge/?style=flat 14 | :target: https://feedinlib.readthedocs.io/ 15 | :alt: Documentation Status 16 | 17 | .. |workflow_pytests| image:: https://github.com/oemof/feedinlib/workflows/tox%20pytests/badge.svg?branch=revision/add-tox-github-workflows-src-directory-ci 18 | :target: https://github.com/oemof/feedinlib/actions?query=workflow%3A%22tox+pytests%22 19 | 20 | .. |workflow_checks| image:: https://github.com/oemof/feedinlib/workflows/tox%20checks/badge.svg?branch=revision/add-tox-github-workflows-src-directory-ci 21 | :target: https://github.com/oemof/feedinlib/actions?query=workflow%3A%22tox+checks%22 22 | 23 | .. |packaging| image:: https://github.com/oemof/feedinlib/workflows/packaging/badge.svg?branch=revision/add-tox-github-workflows-src-directory-ci 24 | :target: https://github.com/oemof/feedinlib/actions?query=workflow%3Apackaging 25 | 26 | .. |appveyor| image:: https://ci.appveyor.com/api/projects/status/github/oemof/feedinlib?branch=master&svg=true 27 | :alt: AppVeyor Build Status 28 | :target: https://ci.appveyor.com/project/oemof/feedinlib 29 | 30 | .. |requires| image:: https://requires.io/github/oemof/feedinlib/requirements.svg?branch=master 31 | :alt: Requirements Status 32 | :target: https://requires.io/github/oemof/feedinlib/requirements/?branch=master 33 | 34 | .. |coveralls| image:: https://coveralls.io/repos/oemof/feedinlib/badge.svg?branch=master&service=github 35 | :alt: Coverage Status 36 | :target: https://coveralls.io/r/oemof/feedinlib 37 | 38 | .. |version| image:: https://img.shields.io/pypi/v/feedinlib.svg 39 | :alt: PyPI Package latest release 40 | :target: https://pypi.org/project/feedinlib 41 | 42 | .. |wheel| image:: https://img.shields.io/pypi/wheel/feedinlib.svg 43 | :alt: PyPI Wheel 44 | :target: https://pypi.org/project/feedinlib 45 | 46 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/feedinlib.svg 47 | :alt: Supported versions 48 | :target: https://pypi.org/project/feedinlib 49 | 50 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/feedinlib.svg 51 | :alt: Supported implementations 52 | :target: https://pypi.org/project/feedinlib 53 | 54 | .. |commits-since| image:: https://img.shields.io/github/commits-since/oemof/feedinlib/v0.0.12.svg 55 | :alt: Commits since latest release 56 | :target: https://github.com/oemof/feedinlib/compare/v0.0.12...master 57 | 58 | 59 | 60 | .. end-badges 61 | 62 | Connect weather data interfaces with interfaces of wind and pv power models. 63 | 64 | * Free software: MIT license 65 | 66 | Installation 67 | ============ 68 | 69 | On Linux systems, you can just:: 70 | 71 | pip install feedinlib 72 | 73 | You can also install the in-development version with:: 74 | 75 | pip install https://github.com/oemof/feedinlib/archive/master.zip 76 | 77 | On Windows systems, some dependencies are not pip-installable. Thus, Windws 78 | users first have to manually install the dependencies e.g. using conda or mamba. 79 | 80 | 81 | Documentation 82 | ============= 83 | 84 | 85 | https://feedinlib.readthedocs.io/ 86 | 87 | 88 | Development 89 | =========== 90 | 91 | To run all the tests run:: 92 | 93 | tox 94 | 95 | Note, to combine the coverage data from all the tox environments run: 96 | 97 | .. list-table:: 98 | :widths: 10 90 99 | :stub-columns: 1 100 | 101 | - - Windows 102 | - :: 103 | 104 | set PYTEST_ADDOPTS=--cov-append 105 | tox 106 | 107 | - - Other 108 | - :: 109 | 110 | PYTEST_ADDOPTS=--cov-append tox 111 | -------------------------------------------------------------------------------- /.appveyor.yml: -------------------------------------------------------------------------------- 1 | version: '{branch}-{build}' 2 | build: off 3 | image: Visual Studio 2019 4 | environment: 5 | global: 6 | COVERALLS_EXTRAS: '-v' 7 | COVERALLS_REPO_TOKEN: COVERALLSTOKEN 8 | matrix: 9 | - TOXENV: check 10 | TOXPYTHON: C:\Python36\python.exe 11 | PYTHON_HOME: C:\Python36 12 | PYTHON_VERSION: '3.6' 13 | PYTHON_ARCH: '32' 14 | - TOXENV: py36-cover,coveralls 15 | TOXPYTHON: C:\Python36\python.exe 16 | PYTHON_HOME: C:\Python36 17 | PYTHON_VERSION: '3.6' 18 | PYTHON_ARCH: '32' 19 | - TOXENV: py36-cover,coveralls 20 | TOXPYTHON: C:\Python36-x64\python.exe 21 | PYTHON_HOME: C:\Python36-x64 22 | PYTHON_VERSION: '3.6' 23 | PYTHON_ARCH: '64' 24 | - TOXENV: py36-nocov 25 | TOXPYTHON: C:\Python36\python.exe 26 | PYTHON_HOME: C:\Python36 27 | PYTHON_VERSION: '3.6' 28 | PYTHON_ARCH: '32' 29 | WHEEL_PATH: .tox/dist 30 | - TOXENV: py36-nocov 31 | TOXPYTHON: C:\Python36-x64\python.exe 32 | PYTHON_HOME: C:\Python36-x64 33 | PYTHON_VERSION: '3.6' 34 | PYTHON_ARCH: '64' 35 | WHEEL_PATH: .tox/dist 36 | - TOXENV: py37-cover,coveralls 37 | TOXPYTHON: C:\Python37\python.exe 38 | PYTHON_HOME: C:\Python37 39 | PYTHON_VERSION: '3.7' 40 | PYTHON_ARCH: '32' 41 | - TOXENV: py37-cover,coveralls 42 | TOXPYTHON: C:\Python37-x64\python.exe 43 | PYTHON_HOME: C:\Python37-x64 44 | PYTHON_VERSION: '3.7' 45 | PYTHON_ARCH: '64' 46 | - TOXENV: py37-nocov 47 | TOXPYTHON: C:\Python37\python.exe 48 | PYTHON_HOME: C:\Python37 49 | PYTHON_VERSION: '3.7' 50 | PYTHON_ARCH: '32' 51 | WHEEL_PATH: .tox/dist 52 | - TOXENV: py37-nocov 53 | TOXPYTHON: C:\Python37-x64\python.exe 54 | PYTHON_HOME: C:\Python37-x64 55 | PYTHON_VERSION: '3.7' 56 | PYTHON_ARCH: '64' 57 | WHEEL_PATH: .tox/dist 58 | - TOXENV: py38-cover,coveralls 59 | TOXPYTHON: C:\Python38\python.exe 60 | PYTHON_HOME: C:\Python38 61 | PYTHON_VERSION: '3.8' 62 | PYTHON_ARCH: '32' 63 | - TOXENV: py38-cover,coveralls 64 | TOXPYTHON: C:\Python38-x64\python.exe 65 | PYTHON_HOME: C:\Python38-x64 66 | PYTHON_VERSION: '3.8' 67 | PYTHON_ARCH: '64' 68 | - TOXENV: py38-nocov 69 | TOXPYTHON: C:\Python38\python.exe 70 | PYTHON_HOME: C:\Python38 71 | PYTHON_VERSION: '3.8' 72 | PYTHON_ARCH: '32' 73 | WHEEL_PATH: .tox/dist 74 | - TOXENV: py38-nocov 75 | TOXPYTHON: C:\Python38-x64\python.exe 76 | PYTHON_HOME: C:\Python38-x64 77 | PYTHON_VERSION: '3.8' 78 | PYTHON_ARCH: '64' 79 | WHEEL_PATH: .tox/dist 80 | - TOXENV: py39-cover,coveralls 81 | TOXPYTHON: C:\Python39\python.exe 82 | PYTHON_HOME: C:\Python39 83 | PYTHON_VERSION: '3.9' 84 | PYTHON_ARCH: '32' 85 | - TOXENV: py39-cover,coveralls 86 | TOXPYTHON: C:\Python39-x64\python.exe 87 | PYTHON_HOME: C:\Python39-x64 88 | PYTHON_VERSION: '3.9' 89 | PYTHON_ARCH: '64' 90 | - TOXENV: py39-nocov 91 | TOXPYTHON: C:\Python39\python.exe 92 | PYTHON_HOME: C:\Python39 93 | PYTHON_VERSION: '3.9' 94 | PYTHON_ARCH: '32' 95 | WHEEL_PATH: .tox/dist 96 | - TOXENV: py39-nocov 97 | TOXPYTHON: C:\Python39-x64\python.exe 98 | PYTHON_HOME: C:\Python39-x64 99 | PYTHON_VERSION: '3.9' 100 | PYTHON_ARCH: '64' 101 | WHEEL_PATH: .tox/dist 102 | init: 103 | - ps: echo $env:TOXENV 104 | - ps: ls C:\Python* 105 | install: 106 | - '%PYTHON_HOME%\python -mpip install --progress-bar=off tox -rci/requirements.txt' 107 | - '%PYTHON_HOME%\Scripts\virtualenv --version' 108 | - '%PYTHON_HOME%\Scripts\pip --version' 109 | - '%PYTHON_HOME%\Scripts\tox --version' 110 | test_script: 111 | - %PYTHON_HOME%\Scripts\tox 112 | on_failure: 113 | - ps: dir "env:" 114 | - ps: get-content .tox\*\log\* 115 | 116 | ### To enable remote debugging uncomment this (also, see: http://www.appveyor.com/docs/how-to/rdp-to-build-worker): 117 | # on_finish: 118 | # - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) 119 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | ~~~~~~~~~~~~~~~~~~~~~~ 2 | Getting started 3 | ~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | The feedinlib is designed to calculate feed-in time series of photovoltaic and wind power plants. 6 | It is part of the oemof group but works as a standalone application. 7 | 8 | The feedinlib is ready to use but it definitely has a lot of space for 9 | further development, new and improved models and nice features. 10 | 11 | Introduction 12 | ============ 13 | 14 | So far the feedinlib provides interfaces to download *open_FRED* and 15 | `ERA5 `_ weather data. 16 | *open_FRED* is a local reanalysis weather data set that provides weather data for Germany (and bounding box). 17 | *ERA5* is a global reanalysis weather data set that provides weather data for the whole world. 18 | The weather data can be used to calculate the electrical output of PV and wind power plants. 19 | At the moment the feedinlib provides interfaces to the `pvlib `_ and the 20 | `windpowerlib `_. 21 | Furthermore, technical parameters for many PV modules and inverters, 22 | as well as wind turbines, are made available and can be easily used for calculations. 23 | 24 | Installation 25 | ============ 26 | 27 | If you have a working Python 3 environment, use pip to install the latest feedinlib version: 28 | 29 | :: 30 | 31 | pip install feedinlib 32 | 33 | The feedinlib is designed for Python 3 and tested on Python >= 3.6. 34 | 35 | We highly recommend to use virtual environments. 36 | 37 | 38 | Examples and basic usage 39 | ========================= 40 | 41 | The basic usage of the feedinlib is shown in the :ref:`examples_section_label` section. 42 | The examples are provided as jupyter notebooks that you can download here: 43 | 44 | * :download:`ERA5 weather data example <../example/load_era5_weather_data.ipynb>` 45 | * :download:`open_FRED weather data example <../example/load_open_fred_weather_data.ipynb>` 46 | * :download:`pvlib model example <../example/run_pvlib_model.ipynb>` 47 | * :download:`windpowerlib model example <../example/run_windpowerlib_turbine_model.ipynb>` 48 | 49 | Furthermore, you have to install the feedinlib with additional packages needed to run the notebooks, e.g. `jupyter`. 50 | 51 | :: 52 | 53 | pip install feedinlib[examples] 54 | 55 | To launch jupyter notebook type ``jupyter notebook`` in the terminal. 56 | This will open a browser window. Navigate to the directory containing the notebook(s) to open it. See the jupyter 57 | notebook quick start guide for more information on 58 | `how to run `_ jupyter notebooks. 59 | 60 | Contributing 61 | ============== 62 | 63 | We are warmly welcoming all who want to contribute to the feedinlib. If you are interested 64 | do not hesitate to contact us via github. 65 | 66 | As the feedinlib started with contributors from the 67 | `oemof developer group `_ 68 | we use the same 69 | `developer rules `_. 70 | 71 | **How to create a pull request:** 72 | 73 | * `Fork `_ the feedinlib repository to your own github account. 74 | * Create a local clone of your fork and install the cloned repository using pip with -e option: 75 | 76 | .. code:: bash 77 | 78 | pip install -e /path/to/the/repository 79 | 80 | * Change, add or remove code. 81 | * Commit your changes. 82 | * Create a `pull request `_ and describe what you will do and why. 83 | * Wait for approval. 84 | 85 | **Generally the following steps are required when changing, adding or removing code:** 86 | 87 | * Add new tests if you have written new functions/classes. 88 | * Add/change the documentation (new feature, API changes ...). 89 | * Add a whatsnew entry and your name to Contributors. 90 | * Check if all tests still work by simply executing pytest in your feedinlib directory: 91 | 92 | .. role:: bash(code) 93 | :language: bash 94 | 95 | .. code:: bash 96 | 97 | pytest 98 | 99 | Citing the feedinlib 100 | ======================== 101 | 102 | We use the zenodo project to get a DOI for each version. 103 | `Search zenodo for the right citation of your feedinlib version `_. 104 | 105 | License 106 | ============ 107 | 108 | MIT License 109 | 110 | Copyright (C) 2017 oemof developer group 111 | -------------------------------------------------------------------------------- /src/feedinlib/dedup.py: -------------------------------------------------------------------------------- 1 | """ Deduplication tools 2 | 3 | This module contains tools, mainly the single `deduplicate` function, to remove 4 | duplicates from data. 5 | """ 6 | from functools import reduce 7 | from itertools import filterfalse 8 | from itertools import tee 9 | from numbers import Number 10 | from pprint import pformat 11 | from typing import Dict 12 | from typing import List 13 | from typing import Tuple 14 | from typing import Union 15 | 16 | from pandas import Timestamp 17 | 18 | TimeseriesEntry = Tuple[Timestamp, Timestamp, Union[str, Number]] 19 | 20 | 21 | def runs( 22 | accumulator: List[List[Tuple[int, TimeseriesEntry]]], 23 | element: Tuple[int, TimeseriesEntry], 24 | ) -> List[List[Tuple[int, TimeseriesEntry]]]: 25 | (index, (start, stop, value)) = element 26 | last = accumulator[-1] 27 | if (not last) or ((start, stop) == tuple(last[-1][1][0:2])): 28 | last.append(element) 29 | else: 30 | accumulator.append([element]) 31 | return accumulator 32 | 33 | 34 | def partition(predicate, iterable): 35 | """ Use a predicate to partition entries into false and true ones. 36 | 37 | Taken from: 38 | 39 | https://docs.python.org/dev/library/itertools.html#itertools-recipes 40 | 41 | Examples 42 | -------- 43 | >>> def is_odd(x): return x % 2 != 0 44 | ... 45 | >>> [list(t) for t in partition(is_odd, range(10))] 46 | [[0, 2, 4, 6, 8], [1, 3, 5, 7, 9]] 47 | """ 48 | t1, t2 = tee(iterable) 49 | return filterfalse(predicate, t1), filter(predicate, t2) 50 | 51 | 52 | # TODO: Figure out which of the above can be replaced by stuff from the 53 | # `more-itertools` package. 54 | 55 | 56 | def compress( 57 | multiples: List[Tuple[int, TimeseriesEntry]], margins: Dict[str, float], 58 | ) -> List[Tuple[slice, TimeseriesEntry]]: 59 | """ {} 60 | """.format( 61 | "Compresses equal timestamp runs if the values are inside " 62 | "the margin of error." 63 | ) 64 | if not multiples: 65 | return multiples 66 | result = [] 67 | index, (start, stop, value) = multiples[0] 68 | for ci, (start_, stop_, cv) in multiples[1:]: 69 | if not ( 70 | (value == cv) 71 | or ( 72 | isinstance(value, Number) 73 | and isinstance(cv, Number) 74 | and (abs(value - cv) <= margins["absolute"]) 75 | and ( 76 | abs(value - cv) / max(abs(v) for v in [value, cv]) 77 | <= margins["relative"] 78 | ) 79 | ) 80 | ): 81 | result.append((slice(index, ci + 1), (start, stop, value))) 82 | index, value = ci, cv 83 | result.append((slice(index, multiples[-1][0] + 1), (start, stop, value))) 84 | return result 85 | 86 | 87 | def deduplicate( 88 | timeseries: List[TimeseriesEntry], margins: Dict[str, float] = {}, 89 | ) -> List[TimeseriesEntry]: 90 | """ Remove duplicates from the supplied `timeseries`. 91 | 92 | Currently the deduplication relies on `timemseries` being formatted 93 | according to how data is stored in `Weather.series.values()`. The function 94 | removes duplicates if the start and stop timestamps of consecutive segments 95 | are equal and the values are either equal or, if they are numeric, if their 96 | differences are smaller than a certain margin of error. 97 | 98 | Parameters 99 | ---------- 100 | timeseries : List[TimeseriesEntry] 101 | The timeseries to duplicate. 102 | margins : Dict[str, float] 103 | The margins of error. Can contain one or both of the strings 104 | :code:`"absolute"` and :code:`"relative"` as keys with the numbers 105 | stored under these keys having the following meaning: 106 | 107 | - for :code:`absolute` value of the difference between the two 108 | values has to be smaller than or equal to this while 109 | - for :code:`relative` this difference has to be smaller than or 110 | equal to this when interpreted as a fraction of the maximum of 111 | the absolute values of the two compared values. 112 | 113 | By default these limits are set to be infinitely big. 114 | 115 | Returns 116 | ------- 117 | timeseries : List[TimeseriesEntry] 118 | A copy of the input data with duplicate values removed. 119 | 120 | Raises 121 | ------ 122 | ValueError 123 | If the data contains duplicates outside of the allowed margins. 124 | """ 125 | # TODO: Fix the data. If possible add a constraint preventing this from 126 | # happending again alongside the fix. 127 | # This is just here because there's duplicate data (that we know) 128 | # at the end of 2017. The last timestamp of 2017 is duplicated in 129 | # the first timespan of 2018. And unfortunately it's not exactly 130 | # duplicated. The timestamps are equal, but the values are only 131 | # equal within a certain margin. 132 | # TODO: Use [`unique_iter`][0] for unsafe removal, i.e. if both margins 133 | # are infinite. Or find an alternative in [`more-itertools`][1]. 134 | # [0]: https://boltons.readthedocs.io/en/latest/iterutils.html 135 | # #boltons.iterutils.unique_iter 136 | # [1]: https://pypi.org/project/more-itertools/ 137 | 138 | margins = { 139 | **{"absolute": float("inf"), "relative": float("inf")}, 140 | **margins, 141 | } 142 | multiples = [ 143 | run 144 | for run in reduce(runs, enumerate(timeseries), [[]]) 145 | if len(run) > 1 146 | ] 147 | compressed = [compress(m, margins) for m in multiples] 148 | errors = [c for c in compressed if len(c) > 1] 149 | if errors: 150 | raise ValueError( 151 | "Found duplicate timestamps while retrieving data:\n{}".format( 152 | pformat(errors) 153 | ) 154 | ) 155 | compressed.reverse() 156 | result = timeseries.copy() 157 | for c in compressed: 158 | result[c[0][0]] = (c[0][1],) 159 | return result 160 | -------------------------------------------------------------------------------- /examples/simple_feedin.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import matplotlib.pyplot as plt 4 | import pandas as pd 5 | 6 | from feedinlib import Photovoltaic 7 | from feedinlib import WindPowerPlant 8 | from feedinlib.models import WindpowerlibTurbineCluster 9 | 10 | 11 | def run_example(): 12 | # ######## set up weather dataframes (temporary) ######### 13 | 14 | # set up weather dataframe for windpowerlib 15 | filename = os.path.join(os.path.dirname(__file__), "weather.csv") 16 | weather_df_wind = pd.read_csv( 17 | filename, 18 | index_col=0, 19 | date_parser=lambda idx: pd.to_datetime(idx, utc=True), 20 | ) 21 | # change type of index to datetime and set time zone 22 | weather_df_wind.index = pd.to_datetime(weather_df_wind.index).tz_convert( 23 | "Europe/Berlin" 24 | ) 25 | data_height = { 26 | "pressure": 0, 27 | "temperature": 2, 28 | "wind_speed": 10, 29 | "roughness_length": 0, 30 | } 31 | weather_df_wind = weather_df_wind[["v_wind", "temp_air", "z0", "pressure"]] 32 | weather_df_wind.columns = [ 33 | ["wind_speed", "temperature", "roughness_length", "pressure"], 34 | [ 35 | data_height["wind_speed"], 36 | data_height["temperature"], 37 | data_height["roughness_length"], 38 | data_height["pressure"], 39 | ], 40 | ] 41 | 42 | # set up weather dataframe for pvlib 43 | weather_df_pv = pd.read_csv( 44 | filename, 45 | index_col=0, 46 | date_parser=lambda idx: pd.to_datetime(idx, utc=True), 47 | ) 48 | # change type of index to datetime and set time zone 49 | weather_df_pv.index = pd.to_datetime(weather_df_pv.index).tz_convert( 50 | "Europe/Berlin" 51 | ) 52 | weather_df_pv["temp_air"] = weather_df_pv.temp_air - 273.15 53 | weather_df_pv["ghi"] = weather_df_pv.dirhi + weather_df_pv.dhi 54 | weather_df_pv.rename(columns={"v_wind": "wind_speed"}, inplace=True) 55 | 56 | # ######## Pvlib model ######### 57 | 58 | # specify pv system 59 | yingli210 = { 60 | "module_name": "Yingli_YL210__2008__E__", 61 | "inverter_name": "ABB__PVI_3_0_OUTD_S_US_Z__277V__277V__CEC_2018_", 62 | "azimuth": 180, 63 | "tilt": 30, 64 | "albedo": 0.2, 65 | "modules_per_string": 4, 66 | } 67 | 68 | # instantiate feedinlib Photovoltaic object 69 | yingli_module = Photovoltaic(**yingli210) 70 | 71 | # calculate feedin 72 | feedin = yingli_module.feedin( 73 | weather=weather_df_pv[["wind_speed", "temp_air", "dhi", "ghi"]], 74 | location=(52, 13), 75 | scaling="peak_power", 76 | scaling_value=10, 77 | ) 78 | 79 | # plot 80 | feedin.fillna(0).plot(title="PV feedin") 81 | plt.xlabel("Time") 82 | plt.ylabel("Power in W") 83 | plt.show() 84 | 85 | # ######## WindpowerlibTurbine model ######### 86 | 87 | # specify wind turbine 88 | enercon_e126 = { 89 | "turbine_type": "E-82/3000", # turbine name as in register 90 | "hub_height": 135, # in m 91 | } 92 | 93 | # instantiate feedinlib WindPowerPlant object (single turbine) 94 | e126 = WindPowerPlant(**enercon_e126) 95 | 96 | # calculate feedin 97 | feedin = e126.feedin(weather=weather_df_wind, location=(52, 13)) 98 | feedin_scaled = e126.feedin( 99 | weather=weather_df_wind, 100 | location=(52, 13), 101 | scaling="capacity", 102 | scaling_value=5e6, 103 | ) 104 | 105 | feedin_scaled.fillna(0).plot( 106 | legend=True, label="scaled to 5 MW", title="Wind turbine feedin" 107 | ) 108 | feedin.fillna(0).plot(legend=True, label="single turbine") 109 | plt.xlabel("Time") 110 | plt.ylabel("Power in W") 111 | plt.show() 112 | 113 | # ######## WindpowerlibTurbineCluster model ######### 114 | 115 | # specify (and instantiate) wind turbines 116 | enercon_e126 = { 117 | "turbine_type": "E-82/3000", # turbine name as in register 118 | "hub_height": 135, # in m 119 | } 120 | e126 = WindPowerPlant(**enercon_e126) 121 | 122 | vestas_v90 = { 123 | "turbine_type": "V90/2000", # turbine name as in register 124 | "hub_height": 120, # in m 125 | } 126 | v90 = WindPowerPlant(**vestas_v90) 127 | 128 | # instantiate feedinlib WindPowerPlant object with 129 | # WindpowerlibTurbineCluster model 130 | 131 | # wind farms need a wind turbine fleet specifying the turbine types in the 132 | # farm and their number or total installed capacity 133 | # the wind turbines can either be provided in the form of a dictionary 134 | farm1 = { 135 | "wind_turbine_fleet": [ 136 | {"wind_turbine": enercon_e126, "number_of_turbines": 6}, 137 | {"wind_turbine": vestas_v90, "total_capacity": 6e6}, 138 | ] 139 | } 140 | windfarm1 = WindPowerPlant(**farm1, model=WindpowerlibTurbineCluster) 141 | 142 | # or you can provide the wind turbines WindPowerPlant objects 143 | farm2 = { 144 | "wind_turbine_fleet": [ 145 | {"wind_turbine": e126, "number_of_turbines": 2}, 146 | {"wind_turbine": v90, "total_capacity": 6e6}, 147 | ] 148 | } 149 | windfarm2 = WindPowerPlant(**farm2, model=WindpowerlibTurbineCluster) 150 | 151 | # wind turbine clusters need a list of wind farms (specified as 152 | # dictionaries) in that cluster 153 | cluster = {"wind_farms": [farm1, farm2]} 154 | windcluster = WindPowerPlant(**cluster, model=WindpowerlibTurbineCluster) 155 | 156 | # calculate feedin 157 | feedin1 = windfarm1.feedin(weather=weather_df_wind, location=(52, 13)) 158 | feedin2 = windfarm2.feedin(weather=weather_df_wind, location=(52, 13)) 159 | feedin3 = windcluster.feedin(weather=weather_df_wind, location=(52, 13)) 160 | 161 | feedin3.fillna(0).plot(legend=True, label="Wind cluster") 162 | feedin1.fillna(0).plot(legend=True, label="Windfarm 1") 163 | feedin2.fillna(0).plot( 164 | legend=True, label="Windfarm 2", title="Wind cluster feedin" 165 | ) 166 | 167 | plt.xlabel("Time") 168 | plt.ylabel("Power in W") 169 | plt.show() 170 | 171 | 172 | if __name__ == "__main__": 173 | run_example() 174 | -------------------------------------------------------------------------------- /src/feedinlib/models/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | """ 3 | Feed-in model classes. 4 | 5 | SPDX-FileCopyrightText: Birgit Schachler 6 | SPDX-FileCopyrightText: Uwe Krien 7 | SPDX-FileCopyrightText: Stephan Günther 8 | SPDX-FileCopyrightText: Stephen Bosch 9 | SPDX-FileCopyrightText: Patrik Schönfeldt 10 | 11 | SPDX-License-Identifier: MIT 12 | 13 | This module provides abstract classes as blueprints for classes that implement 14 | feed-in models for weather dependent renewable energy resources. These models 15 | take in power plant and weather data to calculate power plant feed-in. 16 | """ 17 | 18 | import warnings 19 | from abc import ABC 20 | from abc import abstractmethod 21 | 22 | import pvlib.pvsystem 23 | from windpowerlib import get_turbine_types 24 | 25 | 26 | class Base(ABC): 27 | r""" 28 | The base class of feedinlib models. 29 | 30 | This base class is an abstract class serving as a blueprint for classes 31 | that implement feed-in models for weather dependent renewable energy 32 | resources. It forces implementors to implement certain properties and 33 | methods. 34 | 35 | """ 36 | 37 | def __init__(self, **kwargs): 38 | """ 39 | """ 40 | self._power_plant_requires = kwargs.get("powerplant_requires", None) 41 | self._requires = kwargs.get("requires", None) 42 | 43 | @property 44 | @abstractmethod 45 | def power_plant_requires(self): 46 | """ 47 | The (names of the) power plant parameters this model requires in 48 | order to calculate the feed-in. 49 | 50 | As this is an abstract property you have to override it in a subclass 51 | so that the model can be instantiated. This forces implementors to make 52 | the required power plant parameters for a model explicit, even if they 53 | are empty, and gives them a good place to document them. 54 | 55 | By default, this property is settable and its value can be specified 56 | via an argument upon construction. If you want to keep this 57 | functionality, simply delegate all calls to the superclass. 58 | 59 | Parameters 60 | ---------- 61 | names : list(str), optional 62 | Containing the names of the required power plant parameters. 63 | 64 | """ 65 | return self._power_plant_requires 66 | 67 | @power_plant_requires.setter 68 | def power_plant_requires(self, names): 69 | self._power_plant_requires = names 70 | 71 | def _power_plant_requires_check(self, parameters): 72 | """ 73 | Function to check if all required power plant parameters are provided. 74 | 75 | This function only needs to be implemented in a subclass in case 76 | required power plant parameters specified in 77 | :attr:`power_plant_requires` are not a simple list that can be checked 78 | by :func:`~.power_plants.Base.check_models_power_plant_requirements`. 79 | 80 | """ 81 | raise NotImplementedError 82 | 83 | @property 84 | @abstractmethod 85 | def requires(self): 86 | """ 87 | The (names of the) parameters this model requires in order to 88 | calculate the feed-in. 89 | 90 | As this is an abstract property you have to override it in a subclass 91 | so that the model can be instantiated. This forces implementors to make 92 | the required model parameters explicit, even if they 93 | are empty, and gives them a good place to document them. 94 | 95 | By default, this property is settable and its value can be specified 96 | via an argument upon construction. If you want to keep this 97 | functionality, simply delegate all calls to the superclass. 98 | 99 | Parameters 100 | ---------- 101 | names : list(str), optional 102 | Containing the names of the required power plant parameters. 103 | 104 | """ 105 | return self._requires 106 | 107 | @requires.setter 108 | def requires(self, names): 109 | self._requires = names 110 | 111 | @abstractmethod 112 | def feedin(self, weather, power_plant_parameters, **kwargs): 113 | """ 114 | Calculates power plant feed-in in Watt. 115 | 116 | As this is an abstract method you have to override it in a subclass 117 | so that the power plant feed-in using the respective model can be 118 | calculated. 119 | 120 | Parameters 121 | ---------- 122 | weather : 123 | Weather data to calculate feed-in. Format and required parameters 124 | depend on the model. 125 | power_plant_parameters : dict 126 | Dictionary with power plant specifications. Keys of the dictionary 127 | are the power plant parameter names, values of the dictionary hold 128 | the corresponding value. The dictionary must at least contain the 129 | power plant parameters required by the respective model and may 130 | further contain optional power plant parameters. See 131 | `power_plant_requires` property of the respective model for futher 132 | information. 133 | **kwargs : 134 | Keyword arguments for respective model's feed-in calculation. 135 | 136 | Returns 137 | ------- 138 | feedin : :pandas:`pandas.Series` 139 | Series with power plant feed-in for specified time span in Watt. 140 | If respective model does calculate AC and DC feed-in, AC feed-in 141 | should be returned by default. `mode` parameter can be used to 142 | overwrite this default behavior and return DC power output instead 143 | (for an example see :meth:`~.models.Pvlib.feedin`). 144 | 145 | """ 146 | pass 147 | 148 | 149 | class PhotovoltaicModelBase(Base): 150 | """ 151 | Expands model base class :class:`~.models.Base` by PV specific attributes. 152 | 153 | """ 154 | 155 | @property 156 | @abstractmethod 157 | def pv_system_area(self): 158 | r""" 159 | Area of PV system in :math:`m^2`. 160 | 161 | As this is an abstract property you have to override it in a subclass 162 | so that the model can be instantiated. This forces implementors to 163 | provide a way to retrieve the area of the PV system that is e.g. used 164 | to scale the feed-in by area. 165 | 166 | """ 167 | 168 | @property 169 | @abstractmethod 170 | def pv_system_peak_power(self): 171 | """ 172 | Peak power of PV system in Watt. 173 | 174 | As this is an abstract property you have to override it in a subclass 175 | so that the model can be instantiated. This forces implementors to 176 | provide a way to retrieve the peak power of the PV system that is e.g. 177 | used to scale the feed-in by installed capacity. 178 | 179 | """ 180 | 181 | 182 | class WindpowerModelBase(Base): 183 | """ 184 | Expands model base class :class:`~.models.Base` by wind power specific 185 | attributes. 186 | 187 | """ 188 | 189 | @property 190 | @abstractmethod 191 | def nominal_power_wind_power_plant(self): 192 | """ 193 | Nominal power of wind power plant in Watt. 194 | 195 | As this is an abstract property you have to override it in a subclass 196 | so that the model can be instantiated. This forces implementors to 197 | provide a way to retrieve the nominal power of the wind power plant 198 | that is e.g. used to scale the feed-in by installed capacity. 199 | 200 | """ 201 | 202 | 203 | def get_power_plant_data(dataset, **kwargs): 204 | r""" 205 | Function to retrieve power plant data sets provided by feed-in models. 206 | 207 | This function can be used to retrieve power plant data from data sets 208 | and to get an overview of which modules, inverters and turbine types are 209 | provided and can be used in feed-in calculations. 210 | 211 | Parameters 212 | ---------- 213 | dataset : str 214 | Specifies data set to retrieve. Possible options are: 215 | 216 | * pvlib PV module and inverter datasets: 'sandiamod', 'cecinverter' 217 | 218 | The original data sets are hosted here: 219 | https://github.com/NREL/SAM/tree/develop/deploy/libraries 220 | 221 | See :pvlib:`retrieve_sam ` for further 222 | information. 223 | * windpowerlib wind turbine dataset: 'oedb_turbine_library' 224 | 225 | See :windpowerlib:`get_turbine_types ` for further information. 227 | 228 | **kwargs 229 | See referenced functions for each dataset above for further optional 230 | parameters. 231 | 232 | Example 233 | ------- 234 | >>> from feedinlib import get_power_plant_data 235 | >>> data = get_power_plant_data('sandiamod') 236 | >>> # list of all provided PV modules 237 | >>> pv_modules = data.columns 238 | >>> print(data.loc["Area", data.columns.str.contains('Aleo_S03')]) 239 | Aleo_S03_160__2007__E__ 1.28 240 | Aleo_S03_165__2007__E__ 1.28 241 | Name: Area, dtype: object 242 | 243 | """ 244 | dataset = dataset.lower() 245 | if dataset in ["sandiamod", "cecinverter"]: 246 | return pvlib.pvsystem.retrieve_sam( 247 | name=dataset, path=kwargs.get("path", None) 248 | ) 249 | elif dataset == "oedb_turbine_library": 250 | return get_turbine_types( 251 | turbine_library=kwargs.get("turbine_library", "local"), 252 | print_out=kwargs.get("print_out", False), 253 | filter_=kwargs.get("filter_", True), 254 | ) 255 | else: 256 | warnings.warn("Unknown dataset {}.".format(dataset)) 257 | return None 258 | -------------------------------------------------------------------------------- /src/feedinlib/models/geometric_solar.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | r""" 4 | Geometric solar feed-in model class. 5 | 6 | SPDX-FileCopyrightText: Lucas Schmeling 7 | SPDX-FileCopyrightText: Keno Oltmanns 8 | SPDX-FileCopyrightText: Patrik Schönfeldt 9 | 10 | SPDX-License-Identifier: MIT 11 | 12 | This module holds implementations of solar feed-in models based on geometry. 13 | I.e. it calculates the angles solar radiation hits a collector. 14 | 15 | ======================= ======================= ========= 16 | symbol explanation attribute 17 | ======================= ======================= ========= 18 | :math:`t` time (absolute, :py:obj:`datetime` 19 | including date) 20 | :math:`\beta` slope of collector :py:obj:`tilt` 21 | :math:`\gamma` direction, the :py:obj:`surface_azimuth` 22 | collector faces 23 | :math:`\phi` location (N-S) :py:obj:`longitude` 24 | :math:`L_{loc}` location (O-W) :py:obj:`latitude` 25 | :math:`\theta` angle of incidence :py:obj:`angle_of_incidence` 26 | :math:`\theta_{z}` angle of incidence :py:obj:`solar_zenith_angle` 27 | at a horizontal plane 28 | :math:`R_{b}` fraction of direct :py:obj:`beam_corr_factor` 29 | radiation that 30 | :math:`\delta` angular position of :py:obj:`declination_angle` 31 | the sun at solar noon 32 | 33 | 34 | [DB13] Duffie, John A.; Beckman, William A.: 35 | Solar Engineering of Thermal Processes (2013), 36 | DOI: 10.1002/9781118671603 37 | 38 | """ 39 | 40 | import numpy as np 41 | 42 | SOLAR_CONSTANT = 1367 # in W/m² 43 | 44 | 45 | def solar_angles(datetime, 46 | tilt, surface_azimuth, 47 | latitude, longitude): 48 | """ 49 | Calculate the radiation on a sloped surface 50 | 51 | Parameters 52 | ---------- 53 | datetime : :pandas:`pandas.DatetimeIndex` 54 | points in time to calculate radiation for 55 | (need to be time zone aware) 56 | tilt : numeric 57 | collector tilt in degree 58 | surface_azimuth : numeric 59 | collector surface azimuth in degree 60 | latitude : numeric 61 | location (north–south) of solar plant installation 62 | longitude : numeric 63 | location (east–west) of solar plant installation 64 | 65 | Returns 66 | ------- 67 | two numpy arrays 68 | containing the cosine of angle of incidence, 69 | and of the solar zenith angle, respectively. 70 | 71 | """ 72 | 73 | tilt = np.deg2rad(tilt) 74 | surface_azimuth = np.deg2rad(surface_azimuth) 75 | latitude = np.deg2rad(latitude) 76 | 77 | # convert time zone (to UTC) 78 | datetime = datetime.tz_convert(tz='UTC') 79 | 80 | day_of_year = datetime.dayofyear 81 | # DB13, Eq. 1.4.2 but using angles in Rad. 82 | day_angle = 2 * np.pi * (day_of_year - 1) / 365 83 | 84 | # DB13, Eq. 1.5.3 85 | equation_of_time = 229.2 * (0.000075 86 | + 0.001868 * np.cos(day_angle) 87 | - 0.030277 * np.sin(day_angle) 88 | - 0.014615 * np.cos(2 * day_angle) 89 | - 0.04089 * np.sin(2 * day_angle)) 90 | 91 | true_solar_time = (datetime.hour + (datetime.minute + datetime.second / 60 92 | + equation_of_time) / 60 93 | - longitude / 360 * 24) 94 | hour_angle = np.deg2rad(15 * (true_solar_time - 12)) 95 | declination_angle = np.deg2rad(23.45) * np.sin( 96 | 2 * np.pi / 365 * (284 + day_of_year)) 97 | 98 | # DB13, Eq. 1.6.5 99 | solar_zenith_angle = ( 100 | np.sin(declination_angle) * np.sin(latitude) 101 | + np.cos(declination_angle) * np.cos(latitude) 102 | * np.cos(hour_angle)) 103 | 104 | # DB13, Eq. 1.6.2 105 | angle_of_incidence = ( 106 | + np.sin(declination_angle) * np.sin(latitude) * np.cos(tilt) 107 | - np.sin(declination_angle) * np.cos(latitude) 108 | * np.sin(tilt) * np.cos(surface_azimuth) 109 | + np.cos(declination_angle) * np.cos(latitude) 110 | * np.cos(tilt) * np.cos(hour_angle) 111 | + np.cos(declination_angle) * np.sin(latitude) 112 | * np.sin(tilt) * np.cos(surface_azimuth) * np.cos(hour_angle) 113 | + np.cos(declination_angle) * np.sin(tilt) 114 | * np.sin(surface_azimuth) * np.sin(hour_angle)) 115 | 116 | # We do not allow backside illumination. 117 | angle_of_incidence = np.array(angle_of_incidence) 118 | angle_of_incidence[angle_of_incidence < 0] = 0 119 | 120 | solar_zenith_angle = np.array(solar_zenith_angle) 121 | 122 | return angle_of_incidence, solar_zenith_angle 123 | 124 | 125 | def geometric_radiation(data_weather, 126 | collector_slope, surface_azimuth, 127 | latitude, longitude, 128 | albedo=0.2, 129 | sunset_angle=6): 130 | r""" 131 | Refines the simplistic clear sky model by taking weather conditions 132 | and losses of the PV installation into account 133 | 134 | Parameters 135 | ---------- 136 | data_weather : :pandas:`pandas.DataFrame` 137 | Has to contain time stamps (including time zone) as index, 138 | and irradiation data ("dhi" and one of "ghi"/"dni") 139 | collector_slope : numeric 140 | collector tilt in degree 141 | surface_azimuth : numeric 142 | collector surface azimuth in degree 143 | latitude : numeric 144 | location (north–south) of solar plant installation 145 | longitude : numeric 146 | location (east–west) of solar plant installation 147 | albedo : numeric (default: 0.2) 148 | ground reflectance of surrounding area 149 | sunset_angle : numeric (default: 6) 150 | When sun approaches horizon to this angle (in degree), 151 | we disallow direct radiation. 152 | 153 | Returns 154 | ------- 155 | :pandas:`pandas.DataFrame` 156 | containing the total radiation on the sloped surface 157 | 158 | Internally, beam irradiation (bi, direct radiation to a horizontal surface) 159 | is used. However, typically, either direct normal irradiation (dni) 160 | or global horizontal irradiation (ghi) is given. So we use 161 | .. math:: \mathrm{ghi} = \mathrm{bi} + \mathrm{dhi} 162 | = \mathrm{dni} * \cos(\theta) + \mathrm{dhi} 163 | to calculate the beam irradiation. 164 | """ 165 | angle_of_incidence, solar_zenith_angle = solar_angles( 166 | data_weather.index, collector_slope, surface_azimuth, 167 | latitude, longitude) 168 | 169 | angle_of_incidence[solar_zenith_angle < np.cos( 170 | np.deg2rad(90 - sunset_angle))] = 0 171 | 172 | # DHI should be always present 173 | irradiation_diffuse = data_weather['dhi'] 174 | if 'ghi' in data_weather: 175 | irradiation_global_horizontal = data_weather['ghi'] 176 | msg = ("Global irradiation includes diffuse radiation." 177 | + "Thus, it has to be bigger.") 178 | if not (irradiation_global_horizontal 179 | >= irradiation_diffuse).all(): 180 | raise ValueError(msg) 181 | irradiation_direct_horizontal = (irradiation_global_horizontal 182 | - irradiation_diffuse) 183 | irradiation_beam = irradiation_direct_horizontal/np.cos( 184 | angle_of_incidence) 185 | else: 186 | irradiation_beam = data_weather['dni'] * np.cos(angle_of_incidence) 187 | 188 | # beam radiation correction factor 189 | beam_corr_factor = np.array(angle_of_incidence / solar_zenith_angle) 190 | 191 | irradiation = irradiation_beam + irradiation_diffuse 192 | 193 | # direct radiation 194 | radiation_directed = irradiation_beam * beam_corr_factor 195 | 196 | # DIFFUSE RADIATION 197 | 198 | # Anisotropy index, DB13, Eq. 2.16.3 199 | anisotropy_index = irradiation_beam / SOLAR_CONSTANT 200 | 201 | # DB13, Eq. 2.16.6 202 | # horizon brightening diffuse correction term 203 | f = np.sqrt(irradiation_beam / irradiation).fillna(0) 204 | 205 | # DB13, Eq. 2.16.5 206 | collector_slope = np.deg2rad(collector_slope) 207 | radiation_diffuse = irradiation_diffuse * (( 208 | 1 - anisotropy_index) * ( 209 | (1 + np.cos(collector_slope)) / 2) * (1 + f * np.sin( 210 | collector_slope / 2) ** 3) + (anisotropy_index * beam_corr_factor)) 211 | 212 | # Reflected radiation, last term in DB13, Eq. 2.16.7 213 | radiation_reflected = irradiation * albedo * ( 214 | 1 - np.cos(collector_slope)) / 2 215 | 216 | # Total radiation, DB13, Eq. 2.16.7 217 | return radiation_directed + radiation_diffuse + radiation_reflected 218 | 219 | 220 | class GeometricSolar: 221 | r""" 222 | Model to determine the feed-in of a solar plant using geometric formulas. 223 | """ 224 | 225 | def __init__(self, **attributes): 226 | r""" 227 | Parameters 228 | ---------- 229 | tilt : numeric 230 | collector tilt in degree 231 | azimuth : numeric 232 | direction, collector surface faces (in Degree) 233 | (e.g. Equathor: 0, East: -180, West: +180) 234 | latitude : numeric 235 | location (north-south) of solar plant installation 236 | longitude : numeric 237 | location (east–west) of solar plant installation 238 | albedo : numeric, default 0.2 239 | nominal_peak_power : numeric, default : 1 240 | nominal peak power of the installation 241 | radiation_STC : numeric, default: 1000 242 | Radiation (in W/m²) under standard test condition 243 | temperature_STC : numeric, default: 25 244 | Temperature (in °C) under standard test condition 245 | temperature_NCO : numeric, default: 45 246 | Normal operation cell temperature (in °C) 247 | temperature_coefficient: numeric, default: 0.004 248 | system_efficiency : numeric, default: 0.8 249 | overall system efficiency (inverter, etc.) 250 | """ 251 | 252 | self.tilt = attributes.get("tilt") 253 | self.azimuth = attributes.get("azimuth") 254 | self.latitude = attributes.get("latitude") 255 | self.longitude = attributes.get("longitude") 256 | self.albedo = attributes.get("albedo", 0.2) 257 | self.nominal_peak_power = attributes.get("nominal_peak_power", 1) 258 | 259 | self.radiation_STC = attributes.get("radiation_STC", 1000) 260 | self.temperature_STC = attributes.get("temperature_STC", 25) 261 | self.temperature_NCO = attributes.get("temperature_NCO", 45) 262 | self.temperature_coefficient = attributes.get( 263 | "temperature_coefficient", 0.004) 264 | self.system_efficiency = attributes.get("system_efficiency", 0.80) 265 | 266 | def feedin(self, weather, location=None): 267 | r""" 268 | Parameters 269 | ---------- 270 | weather : :pandas:`pandas.DataFrame` 271 | containing direct radiation ('dni') and diffuse radiation ('dhi') 272 | location 273 | 274 | Returns 275 | ------- 276 | :pandas:`pandas.Series` 277 | Series with PV system feed-in in the unit the peak power was given. 278 | 279 | """ 280 | if location is None: 281 | latitude = self.latitude 282 | longitude = self.longitude 283 | else: 284 | latitude = location[0] 285 | longitude = location[1] 286 | 287 | radiation_surface = geometric_radiation(weather, 288 | self.tilt, 289 | self.azimuth, 290 | latitude, 291 | longitude, 292 | self.albedo) 293 | 294 | if 'temperature' in weather: 295 | temperature_celsius = weather['temperature'] - 273.15 296 | else: 297 | temperature_celsius = weather['temp_air'] 298 | 299 | temperature_cell = temperature_celsius + radiation_surface/800 * ( 300 | (self.temperature_NCO - 20)) 301 | 302 | feedin = (self.nominal_peak_power * radiation_surface / 303 | self.radiation_STC * (1 - self.temperature_coefficient * ( 304 | temperature_cell - self.temperature_STC))) 305 | 306 | feedin[feedin < 0] = 0 307 | 308 | return feedin * self.system_efficiency 309 | 310 | def geometric_radiation(self, weather_data): 311 | return geometric_radiation(weather_data, 312 | self.tilt, self.azimuth, 313 | self.latitude, self.longitude, 314 | self.albedo) 315 | 316 | def solar_angles(self, datetime): 317 | return solar_angles(datetime, 318 | self.tilt, self.azimuth, 319 | self.latitude, self.longitude) 320 | -------------------------------------------------------------------------------- /src/feedinlib/models/pvlib.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | """ 3 | Feed-in model class using pvlib. 4 | 5 | SPDX-FileCopyrightText: Birgit Schachler 6 | SPDX-FileCopyrightText: Uwe Krien 7 | SPDX-FileCopyrightText: Stephan Günther 8 | SPDX-FileCopyrightText: Stephen Bosch 9 | SPDX-FileCopyrightText: Patrik Schönfeldt 10 | 11 | SPDX-License-Identifier: MIT 12 | 13 | This module holds an implementations of a photovoltaic feed-in model 14 | using the python library pvlib. 15 | """ 16 | 17 | from pvlib.location import Location as PvlibLocation 18 | from pvlib.modelchain import ModelChain as PvlibModelChain 19 | from pvlib.pvsystem import PVSystem as PvlibPVSystem 20 | 21 | from .base import PhotovoltaicModelBase 22 | from .base import get_power_plant_data 23 | 24 | 25 | class Pvlib(PhotovoltaicModelBase): 26 | r""" 27 | Model to determine the feed-in of a photovoltaic module using the pvlib. 28 | 29 | The pvlib [1]_ is a python library for simulating the performance of 30 | photovoltaic energy systems. For more information about the photovoltaic 31 | model check the documentation of the pvlib [2]_. 32 | 33 | Notes 34 | ------ 35 | In order to use this model various power plant and model parameters have to 36 | be provided. See :attr:`~.power_plant_requires` as well as 37 | :attr:`~.requires` for further information. Furthermore, the weather 38 | data used to calculate the feed-in has to have a certain format. See 39 | :meth:`~.feedin` for further information. 40 | 41 | References 42 | ---------- 43 | .. [1] `pvlib on github `_ 44 | .. [2] `pvlib documentation `_ 45 | 46 | See Also 47 | -------- 48 | :class:`~.models.Base` 49 | :class:`~.models.PhotovoltaicModelBase` 50 | 51 | """ 52 | 53 | def __init__(self, **kwargs): 54 | """ 55 | """ 56 | super().__init__(**kwargs) 57 | self.power_plant = None 58 | 59 | def __repr__(self): 60 | return "pvlib" 61 | 62 | @property 63 | def power_plant_requires(self): 64 | r""" 65 | The power plant parameters this model requires to calculate a feed-in. 66 | 67 | The required power plant parameters are: 68 | 69 | `module_name`, `inverter_name`, `azimuth`, `tilt`, 70 | `albedo/surface_type` 71 | 72 | module_name (str) 73 | Name of the PV module as in the Sandia module database. Use 74 | :func:`~.get_power_plant_data` with `dataset` = 'sandiamod' to get 75 | an overview of all provided modules. See the data set documentation 76 | [3]_ for further information on provided parameters. 77 | inverter_name (str) 78 | Name of the inverter as in the CEC inverter database. Use 79 | :func:`~.get_power_plant_data` with `dataset` = 'cecinverter' to 80 | get an overview of all provided inverters. See the data set 81 | documentation [4]_ for further information on provided parameters. 82 | azimuth (float) 83 | Azimuth angle of the module surface (South=180). 84 | 85 | See also :pvlib:`PVSystem.surface_azimuth ` in pvlib documentation. 87 | tilt (float) 88 | Surface tilt angle in decimal degrees. 89 | The tilt angle is defined as degrees from horizontal 90 | (e.g. surface facing up = 0, surface facing horizon = 90). 91 | 92 | See also :pvlib:`PVSystem.surface_tilt ` in pvlib documentation. 94 | albedo (float) 95 | The ground albedo. See also :pvlib:`PVSystem.albedo ` in pvlib documentation. 97 | surface_type (str) 98 | The ground surface type. See `SURFACE_ALBEDOS` in 99 | `pvlib.irradiance `_ module for valid values. 100 | 101 | References 102 | ---------- 103 | .. [3] `Sandia module database documentation `_ 104 | .. [4] `CEC inverter database documentation `_ 105 | 106 | """ # noqa: E501 107 | # ToDo Maybe add method to assign suitable inverter if none is 108 | # specified 109 | required = [ 110 | "azimuth", 111 | "tilt", 112 | "module_name", 113 | ["albedo", "surface_type"], 114 | "inverter_name", 115 | ] 116 | # ToDo @Günni: is this necessary? 117 | if super().power_plant_requires is not None: 118 | required.extend(super().power_plant_requires) 119 | return required 120 | 121 | @property 122 | def requires(self): 123 | r""" 124 | The parameters this model requires to calculate a feed-in. 125 | 126 | The required model parameters are: 127 | 128 | `location` 129 | 130 | location (:obj:`tuple` or :shapely:`Point`) 131 | Geo location of the PV system. Can either be provided as a tuple 132 | with first entry being the latitude and second entry being the 133 | longitude or as a :shapely:`Point`. 134 | 135 | """ 136 | required = ["location"] 137 | if super().requires is not None: 138 | required.extend(super().requires) 139 | return required 140 | 141 | @property 142 | def pv_system_area(self): 143 | """ 144 | Area of PV system in :math:`m^2`. 145 | 146 | """ 147 | if self.power_plant: 148 | return ( 149 | self.power_plant.module_parameters.Area 150 | * self.power_plant.strings_per_inverter 151 | * self.power_plant.modules_per_string 152 | ) 153 | else: 154 | return None 155 | 156 | @property 157 | def pv_system_peak_power(self): 158 | """ 159 | Peak power of PV system in Watt. 160 | 161 | The peak power of the PV system can either be limited by the inverter 162 | or the PV module(s), wherefore in the case the `mode` parameter, 163 | which specifies whether AC or DC feed-in is calculated, is set to 164 | 'ac' (which is the default), the minimum of AC inverter power and 165 | maximum power of the module(s) is returned. In the case that 166 | `mode` is set to 'dc' the inverter power is not considered and the 167 | peak power is equal to the maximum power of the module(s). 168 | 169 | """ 170 | if self.power_plant: 171 | if self.mode == "ac": 172 | return min( 173 | self.power_plant.module_parameters.Impo 174 | * self.power_plant.module_parameters.Vmpo 175 | * self.power_plant.strings_per_inverter 176 | * self.power_plant.modules_per_string, 177 | self.power_plant.inverter_parameters.Paco, 178 | ) 179 | elif self.mode == "dc": 180 | return ( 181 | self.power_plant.module_parameters.Impo 182 | * self.power_plant.module_parameters.Vmpo 183 | * self.power_plant.strings_per_inverter 184 | * self.power_plant.modules_per_string 185 | ) 186 | else: 187 | raise ValueError( 188 | "{} is not a valid `mode`. `mode` must " 189 | "either be 'ac' or 'dc'.".format(self.mode) 190 | ) 191 | else: 192 | return None 193 | 194 | def _power_plant_requires_check(self, parameters): 195 | """ 196 | Function to check if all required power plant parameters are provided. 197 | 198 | Power plant parameters this model requires are specified in 199 | :attr:`~.models.Pvlib.power_plant_requires`. 200 | 201 | Parameters 202 | ----------- 203 | parameters : list(str) 204 | List of provided power plant parameters. 205 | 206 | """ 207 | for k in self.power_plant_requires: 208 | if not isinstance(k, list): 209 | if k not in parameters: 210 | raise AttributeError( 211 | "The specified model '{model}' requires power plant " 212 | "parameter '{k}' but it's not provided as an " 213 | "argument.".format(k=k, model=self) 214 | ) 215 | else: 216 | # in case one of several parameters can be provided 217 | if not list(filter(lambda x: x in parameters, k)): 218 | raise AttributeError( 219 | "The specified model '{model}' requires one of the " 220 | "following power plant parameters '{k}' but neither " 221 | "is provided as an argument.".format(k=k, model=self) 222 | ) 223 | 224 | def instantiate_module(self, **kwargs): 225 | """ 226 | Instantiates a :pvlib:`pvlib.PVSystem ` 227 | object. 228 | 229 | Parameters 230 | ----------- 231 | **kwargs 232 | See `power_plant_parameters` parameter in :meth:`~.feedin` for more 233 | information. 234 | 235 | Returns 236 | -------- 237 | :pvlib:`pvlib.PVSystem ` 238 | PV system to calculate feed-in for. 239 | 240 | """ 241 | # match all power plant parameters from power_plant_requires property 242 | # to pvlib's PVSystem parameters 243 | rename = { 244 | "module_parameters": get_power_plant_data("SandiaMod")[ 245 | kwargs.pop("module_name") 246 | ], 247 | "inverter_parameters": get_power_plant_data("CECInverter")[ 248 | kwargs.pop("inverter_name") 249 | ], 250 | "surface_azimuth": kwargs.pop("azimuth"), 251 | "surface_tilt": kwargs.pop("tilt"), 252 | } 253 | # update kwargs with renamed power plant parameters 254 | kwargs.update(rename) 255 | return PvlibPVSystem(**kwargs) 256 | 257 | def feedin(self, weather, power_plant_parameters, **kwargs): 258 | r""" 259 | Calculates power plant feed-in in Watt. 260 | 261 | This function uses the :pvlib:`pvlib.ModelChain ` to calculate the feed-in for the given weather time series 263 | and PV system. 264 | By default the AC feed-in is returned. Set `mode` parameter to 'dc' 265 | to retrieve DC feed-in. 266 | 267 | Parameters 268 | ---------- 269 | weather : :pandas:`pandas.DataFrame` 270 | Weather time series used to calculate feed-in. See `weather` 271 | parameter in pvlib's Modelchain :pvlib:`run_model ` method for more information on 273 | required variables, units, etc. 274 | power_plant_parameters : dict 275 | Dictionary with power plant specifications. Keys of the dictionary 276 | are the power plant parameter names, values of the dictionary hold 277 | the corresponding value. The dictionary must at least contain the 278 | required power plant parameters (see 279 | :attr:`~.power_plant_requires`) and may further contain optional 280 | power plant parameters (see :pvlib:`pvlib.PVSystem `). 282 | location : :obj:`tuple` or :shapely:`Point` 283 | Geo location of the PV system. Can either be provided as a tuple 284 | with first entry being the latitude and second entry being the 285 | longitude or as a :shapely:`Point`. 286 | mode : str (optional) 287 | Can be used to specify whether AC or DC feed-in is returned. By 288 | default `mode` is 'ac'. To retrieve DC feed-in set `mode` to 'dc'. 289 | 290 | `mode` also influences the peak power of the PV system. See 291 | :attr:`~.pv_system_peak_power` for more information. 292 | **kwargs : 293 | Further keyword arguments can be used to overwrite :pvlib:`pvlib.\ 294 | ModelChain ` parameters. 295 | 296 | Returns 297 | ------- 298 | :pandas:`pandas.Series` 299 | Power plant feed-in time series in Watt. 300 | 301 | """ 302 | self.mode = kwargs.pop("mode", "ac").lower() 303 | 304 | # ToDo Allow usage of feedinlib weather object which makes location 305 | # parameter obsolete 306 | location = kwargs.pop("location") 307 | # ToDo Allow location provided as shapely Point 308 | location = PvlibLocation( 309 | latitude=location[0], longitude=location[1], tz=weather.index.tz 310 | ) 311 | self.power_plant = self.instantiate_module(**power_plant_parameters) 312 | 313 | mc = PvlibModelChain(self.power_plant, location, **kwargs) 314 | mc.complete_irradiance(weather=weather) 315 | mc.run_model(weather=weather) 316 | 317 | if self.mode == "ac": 318 | return mc.ac 319 | elif self.mode == "dc": 320 | return mc.dc.p_mp 321 | else: 322 | raise ValueError( 323 | "{} is not a valid `mode`. `mode` must " 324 | "either be 'ac' or 'dc'.".format(self.mode) 325 | ) 326 | -------------------------------------------------------------------------------- /src/feedinlib/cds_request_tools.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from datetime import timedelta 4 | 5 | import cdsapi 6 | import numpy as np 7 | import xarray as xr 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def _get_cds_data( 13 | target_file, 14 | dataset_name="reanalysis-era5-single-levels", 15 | cds_client=None, 16 | **cds_params, 17 | ): 18 | """ 19 | Download data from the Climate Data Store (CDS) 20 | 21 | Requirements: 22 | * user account at https://cds.climate.copernicus.eu to use this function 23 | * cdsapi package installed (https://cds.climate.copernicus.eu/api-how-to) 24 | 25 | :param dataset_name: (str) short name of the dataset of the CDS. To find 26 | it, click on a dataset found in 27 | https://cds.climate.copernicus.eu/cdsapp#!/search?type=dataset and go 28 | to the 'Download data' tab, then scroll down the page and click on 29 | 'Show API request', the short name is the string on the 6th line 30 | after 'c.retrieve(' 31 | :param target_file: (str) name of the file to save downloaded locally 32 | :param cds_client: handle to CDS client (if none is provided, then it is 33 | created) 34 | :param cds_params: (dict) parameter to pass to the CDS request 35 | 36 | """ 37 | 38 | # https://cds.climate.copernicus.eu/api-how-to 39 | if cds_client is None: 40 | cds_client = cdsapi.Client() 41 | 42 | # Default request 43 | request = { 44 | "format": "netcdf", 45 | "product_type": "reanalysis", 46 | "time": [ 47 | "00:00", 48 | "01:00", 49 | "02:00", 50 | "03:00", 51 | "04:00", 52 | "05:00", 53 | "06:00", 54 | "07:00", 55 | "08:00", 56 | "09:00", 57 | "10:00", 58 | "11:00", 59 | "12:00", 60 | "13:00", 61 | "14:00", 62 | "15:00", 63 | "16:00", 64 | "17:00", 65 | "18:00", 66 | "19:00", 67 | "20:00", 68 | "21:00", 69 | "22:00", 70 | "23:00", 71 | ], 72 | } 73 | 74 | # Add user provided cds parameters to the request dict 75 | request.update(cds_params) 76 | 77 | assert {"year", "month", "variable"}.issubset( 78 | request 79 | ), "Need to specify at least 'variable', 'year' and 'month'" 80 | 81 | # Send the data request to the server 82 | result = cds_client.retrieve(dataset_name, request) 83 | 84 | # Create a file in a secure way if a target filename was not provided 85 | if target_file.split(".")[-1] != "nc": 86 | target_file = target_file + ".nc" 87 | 88 | logger.info( 89 | "Downloading request for {} variables to {}".format( 90 | len(request["variable"]), target_file 91 | ) 92 | ) 93 | 94 | # Download the data in the target file 95 | result.download(target_file) 96 | 97 | 98 | def _format_cds_request_datespan(start_date, end_date): 99 | """ 100 | Format the dates between two given dates in order to submit a CDS request 101 | 102 | :param start_date: (str) start date of the range in YYYY-MM-DD format 103 | :param end_date: (str) end date of the range in YYYY-MM-DD format 104 | 105 | :return: a dict with the years, months and days of all the dates as lists 106 | of string 107 | 108 | """ 109 | 110 | answer = {"year": [], "month": [], "day": []} 111 | fmt = "%Y-%m-%d" 112 | specific_fmt = {"year": "%4d", "month": "%02d", "day": "%02d"} 113 | start_dt = datetime.strptime(start_date, fmt) 114 | end_dt = datetime.strptime(end_date, fmt) 115 | 116 | if end_dt < start_dt: 117 | logger.warning( 118 | "Swapping input dates as the end date '{}' is prior to the " 119 | "start date '{}'.".format(end_date, start_date) 120 | ) 121 | start_dt = end_dt 122 | end_dt = datetime.strptime(start_date, fmt) 123 | 124 | for n in range(int((end_dt - start_dt).days) + 1): 125 | cur_dt = start_dt + timedelta(n) 126 | 127 | # Add string value of the date's year, month and day to the 128 | # corresponding lists in the dict which will be returned 129 | for key, val in zip( 130 | ["year", "month", "day"], [cur_dt.year, cur_dt.month, cur_dt.day] 131 | ): 132 | val = specific_fmt[key] % val 133 | if val not in answer[key]: 134 | answer[key].append(val) 135 | 136 | # If the datespan is over more than a month, then all days are filled and 137 | # the entire months are returned (for CDS request the days format for a 138 | # full month is 31 days). 139 | if len(answer["month"]) > 1: 140 | answer["day"] = [str(d) for d in range(1, 32, 1)] 141 | 142 | return answer 143 | 144 | 145 | def _format_cds_request_area( 146 | latitude_span=None, longitude_span=None, grid=None 147 | ): 148 | """ 149 | Format the area between two given latitude and longitude spans in order 150 | to submit a CDS request 151 | 152 | The grid convention of the era5 HRES is used with a native resolution of 153 | 0.28125 deg. For NetCDF format, the data is interpolated to a regular 154 | lat/lon grid with 0.25 deg resolution. 155 | In this grid the earth is modelled by a sphere with radius 156 | R_E = 6367.47 km. Latitude values in the range [-90, 90] relative to the 157 | equator and longitude values in the range [-180, 180] 158 | relative to the Greenwich Prime Meridian [1]. 159 | 160 | References: 161 | [1] https://confluence.ecmwf.int/display/CKB/ERA5%3A+What+is+the+spatial+reference 162 | [2] https://confluence.ecmwf.int/display/UDOC/Post-processing+keywords 163 | 164 | :param latitude_span: (list of float) formatted as [N,S]. The span is 165 | between North and South latitudes (relative to the equator). North 166 | corresponds to positive latitude [2]. 167 | :param longitude_span: (list of float) formatted as [W,E]. The span is 168 | between East and West longitudes (relative to the Greenwich meridian). 169 | East corresponds to positive longitude [2]. 170 | :param grid: (list of float) provide the latitude and longitude grid 171 | resolutions in deg. It needs to be an integer fraction of 90 deg [2]. 172 | 173 | :return: a dict containing the grid and, if `latitude_span` and/or 174 | `longitude_span` were specified, the area formatted for a CDS request 175 | 176 | """ # noqa: E501 177 | 178 | answer = {} 179 | 180 | # Default value of the grid 181 | if grid is None: 182 | grid = [0.25, 0.25] 183 | 184 | if latitude_span is not None and longitude_span is not None: 185 | area = [ 186 | latitude_span[0], 187 | longitude_span[0], 188 | latitude_span[1], 189 | longitude_span[1], 190 | ] 191 | elif latitude_span is None and longitude_span is not None: 192 | area = [90, longitude_span[0], -90, latitude_span[1]] 193 | elif latitude_span is not None and longitude_span is None: 194 | area = [latitude_span[0], -180, latitude_span[1], 180] 195 | else: 196 | area = [] 197 | 198 | # Format the 'grid' keyword of the CDS request as 199 | # lat_resolution/lon_resolution 200 | answer["grid"] = "%.2f/%.2f" % (grid[0], grid[1]) 201 | 202 | # Format the 'area' keyword of the CDS request as N/W/S/E 203 | if area: 204 | answer["area"] = "/".join(str(e) for e in area) 205 | 206 | return answer 207 | 208 | 209 | def _format_cds_request_position(latitude, longitude, grid=None): 210 | """ 211 | Reduce the area of a CDS request to a single GIS point on the earth grid 212 | 213 | Find the closest grid point for the given longitude and latitude. 214 | 215 | The grid convention of the era5 HRES is used here with a native 216 | resolution of 0.28125 deg. For NetCDF format the data is interpolated to a 217 | regular lat/lon grid with 0.25 deg resolution. In this grid the earth is 218 | modelled by a sphere with radius R_E = 6367.47 km. latitude values 219 | in the range [-90, 90] relative to the equator and longitude values in the 220 | range [-180, 180] relative to the Greenwich Prime Meridian [1]. 221 | 222 | References: 223 | [1] https://confluence.ecmwf.int/display/CKB/ERA5%3A+What+is+the+spatial+reference 224 | [2] https://confluence.ecmwf.int/display/UDOC/Post-processing+keywords 225 | 226 | :param latitude: (number) latitude in the range [-90, 90] relative to the 227 | equator, north correspond to positive latitude. 228 | :param longitude: (number) longitude in the range [-180, 180] relative to 229 | Greenwich Meridian, east relative to the meridian correspond to 230 | positive longitude. 231 | :param grid: (list of float) provide the latitude and longitude grid 232 | resolutions in deg. It needs to be an integer fraction of 90 deg [2]. 233 | 234 | :return: a dict containing the grid and the area formatted for a CDS 235 | request 236 | 237 | """ # noqa: E501 238 | 239 | # Default value of the grid 240 | if grid is None: 241 | grid = [0.25, 0.25] 242 | 243 | # Find the nearest point on the grid corresponding to the given latitude 244 | # and longitude 245 | grid_point = xr.Dataset( 246 | { 247 | "lat": np.arange(90, -90, -grid[0]), 248 | "lon": np.arange(-180, 180.0, grid[1]), 249 | } 250 | ).sel(lat=latitude, lon=longitude, method="nearest") 251 | 252 | # Prepare an area which consists of only one grid point 253 | lat, lon = [float(grid_point.coords[s]) for s in ("lat", "lon")] 254 | return _format_cds_request_area( 255 | latitude_span=[lat, lat], longitude_span=[lon, lon], grid=grid 256 | ) 257 | 258 | 259 | def get_cds_data_from_datespan_and_position( 260 | start_date, 261 | end_date, 262 | latitude=None, 263 | longitude=None, 264 | grid=None, 265 | **cds_params, 266 | ): 267 | """ 268 | Format request for data from the Climate Data Store (CDS) 269 | 270 | prepare a CDS request from user specified date span for a single grid point 271 | closest to the specified latitude and longitude. 272 | 273 | see _get_cds_data() for prior requirements and more information 274 | 275 | :param start_date: (str) start date of the datespan in YYYY-MM-DD format 276 | :param end_date: (str) end date of the datespan in YYYY-MM-DD format 277 | :param latitude: (number or list of float or None) 278 | * number: latitude in the range [-90, 90] relative to the 279 | equator, north correspond to positive latitude. 280 | * list of float: must be formatted as [N,S]. The span is 281 | between North and South latitudes (relative to the equator). North 282 | corresponds to positive latitude. 283 | * None: No geographical subset is selected. 284 | :param longitude: (number or list of float or None) 285 | * number: longitude in the range [-180, 180] relative to 286 | Greenwich Meridian, east relative to the meridian correspond to 287 | positive longitude. 288 | * list of float: must be formatted as [W,E]. The span is 289 | between East and West longitudes (relative to the Greenwich meridian). 290 | East corresponds to positive longitude 291 | * None: No geographical subset is selected. 292 | :param grid: (list of float) provide the latitude and longitude grid 293 | resolutions in deg. It needs to be an integer fraction of 90 deg. 294 | :param dataset_name: (str) short name of the dataset of the CDS. To find 295 | it, click on a dataset found in 296 | https://cds.climate.copernicus.eu/cdsapp#!/search?type=dataset and go 297 | to the 'Download data' tab, then scroll down the page and click on 298 | 'Show API request', the short name is the string on the 6th line 299 | after 'c.retrieve(' 300 | :param target_file: (str) name of the file in which downloading the data 301 | locally 302 | :param cds_client: handle to CDS client (if none is provided, then it is 303 | created) 304 | :param cds_params: (dict) parameter to pass to the CDS request 305 | 306 | :return: CDS data in an xarray format 307 | 308 | """ 309 | 310 | # Get the formatted year, month and day parameter from the datespan 311 | request_dates = _format_cds_request_datespan(start_date, end_date) 312 | cds_params.update(request_dates) 313 | 314 | # Get the area corresponding to a position on the globe for a given grid 315 | # size 316 | # if both longitude and latitude are provided as number, select single 317 | # position 318 | if isinstance(longitude, (int, float)) or isinstance( 319 | latitude, (int, float) 320 | ): 321 | request_area = _format_cds_request_position(latitude, longitude, grid) 322 | cds_params.update(request_area) 323 | # if longitude or latitude is provided as list and the other one is either 324 | # None (in which case all latitudes or longitudes are selected) or also 325 | # provided as list, select area 326 | elif isinstance(longitude, list) or isinstance(latitude, list): 327 | if not isinstance(longitude, (int, float)) and not isinstance( 328 | latitude, (int, float) 329 | ): 330 | request_area = _format_cds_request_area(latitude, longitude, grid) 331 | cds_params.update(request_area) 332 | else: 333 | raise ValueError( 334 | "It is currently not supported that latitude or longitude is " 335 | "provided as a number while the other is provided as a list." 336 | ) 337 | # in any other case no geographical subset is selected 338 | 339 | _get_cds_data(**cds_params) 340 | -------------------------------------------------------------------------------- /src/feedinlib/era5.py: -------------------------------------------------------------------------------- 1 | import geopandas as gpd 2 | import numpy as np 3 | import pandas as pd 4 | import xarray as xr 5 | from shapely.geometry import Point 6 | 7 | from feedinlib.cds_request_tools import get_cds_data_from_datespan_and_position 8 | 9 | 10 | def get_era5_data_from_datespan_and_position( 11 | start_date, 12 | end_date, 13 | target_file, 14 | variable="feedinlib", 15 | latitude=None, 16 | longitude=None, 17 | grid=None, 18 | cds_client=None, 19 | ): 20 | """ 21 | Download a netCDF file from the era5 weather data server for you position 22 | and time range. 23 | 24 | Parameters 25 | ---------- 26 | start_date : str 27 | Start date of the date span in YYYY-MM-DD format. 28 | end_date : str 29 | End date of the date span in YYYY-MM-DD format. 30 | target_file : str 31 | Name of the file in which to store downloaded data locally 32 | variable : str 33 | ERA5 variables to download. If you want to download all variables 34 | necessary to use the pvlib, set `variable` to 'pvlib'. If you want to 35 | download all variables necessary to use the windpowerlib, set 36 | `variable` to 'windpowerlib'. To download both variable sets for pvlib 37 | and windpowerlib, set `variable` to 'feedinlib'. 38 | latitude : numeric 39 | Latitude in the range [-90, 90] relative to the equator, north 40 | corresponds to positive latitude. 41 | longitude : numeric 42 | Longitude in the range [-180, 180] relative to Greenwich Meridian, east 43 | relative to the meridian corresponds to 44 | grid : list or float 45 | Provide the latitude and longitude grid resolutions in deg. It needs to 46 | be an integer fraction of 90 deg. 47 | cds_client : cdsapi.Client() 48 | Handle to CDS client (if none is provided, then it is created) 49 | 50 | Returns 51 | ------- 52 | CDS data in an xarray format : xarray 53 | 54 | """ 55 | 56 | if variable == "pvlib": 57 | variable = ["fdir", "ssrd", "2t", "10u", "10v"] 58 | elif variable == "windpowerlib": 59 | variable = ["100u", "100v", "10u", "10v", "2t", "fsr", "sp"] 60 | elif variable == "feedinlib": 61 | variable = [ 62 | "100u", 63 | "100v", 64 | "fsr", 65 | "sp", 66 | "fdir", 67 | "ssrd", 68 | "2t", 69 | "10u", 70 | "10v", 71 | ] 72 | get_cds_data_from_datespan_and_position(**locals()) 73 | 74 | 75 | def format_windpowerlib(ds): 76 | """ 77 | Format dataset to dataframe as required by the windpowerlib's ModelChain. 78 | 79 | The windpowerlib's ModelChain requires a weather DataFrame with time 80 | series for 81 | 82 | - wind speed `wind_speed` in m/s, 83 | - temperature `temperature` in K, 84 | - roughness length `roughness_length` in m, 85 | - pressure `pressure` in Pa. 86 | 87 | The columns of the DataFrame need to be a MultiIndex where the first level 88 | contains the variable name as string (e.g. 'wind_speed') and the second 89 | level contains the height as integer in m at which it applies (e.g. 10, 90 | if it was measured at a height of 10 m). 91 | 92 | Parameters 93 | ---------- 94 | ds : xarray.Dataset 95 | Dataset with ERA5 weather data. 96 | 97 | Returns 98 | -------- 99 | pd.DataFrame 100 | Dataframe formatted for the windpowerlib. 101 | 102 | """ 103 | 104 | # compute the norm of the wind speed 105 | ds["wnd100m"] = np.sqrt(ds["u100"] ** 2 + ds["v100"] ** 2).assign_attrs( 106 | units=ds["u100"].attrs["units"], long_name="100 metre wind speed" 107 | ) 108 | 109 | ds["wnd10m"] = np.sqrt(ds["u10"] ** 2 + ds["v10"] ** 2).assign_attrs( 110 | units=ds["u10"].attrs["units"], long_name="10 metre wind speed" 111 | ) 112 | 113 | # drop not needed variables 114 | windpowerlib_vars = ["wnd10m", "wnd100m", "sp", "t2m", "fsr"] 115 | ds_vars = list(ds.variables) 116 | drop_vars = [ 117 | _ 118 | for _ in ds_vars 119 | if _ not in windpowerlib_vars + ["latitude", "longitude", "time"] 120 | ] 121 | ds = ds.drop(drop_vars) 122 | 123 | # convert to dataframe 124 | df = ds.to_dataframe().reset_index() 125 | 126 | # the time stamp given by ERA5 for mean values (probably) corresponds to 127 | # the end of the valid time interval; the following sets the time stamp 128 | # to the middle of the valid time interval 129 | df["time"] = df.time - pd.Timedelta(minutes=60) 130 | 131 | df.set_index(["time", "latitude", "longitude"], inplace=True) 132 | df.sort_index(inplace=True) 133 | df = df.tz_localize("UTC", level=0) 134 | 135 | # reorder the columns of the dataframe 136 | df = df[windpowerlib_vars] 137 | 138 | # define a multiindexing on the columns 139 | midx = pd.MultiIndex( 140 | levels=[ 141 | ["wind_speed", "pressure", "temperature", "roughness_length"], 142 | # variable 143 | [0, 2, 10, 100], # height 144 | ], 145 | codes=[ 146 | [0, 0, 1, 2, 3], # indexes from variable list above 147 | [2, 3, 0, 1, 0], # indexes from the height list above 148 | ], 149 | names=["variable", "height"], # name of the levels 150 | ) 151 | 152 | df.columns = midx 153 | df.dropna(inplace=True) 154 | 155 | return df 156 | 157 | 158 | def format_pvlib(ds): 159 | """ 160 | Format dataset to dataframe as required by the pvlib's ModelChain. 161 | 162 | The pvlib's ModelChain requires a weather DataFrame with time series for 163 | 164 | - wind speed `wind_speed` in m/s, 165 | - temperature `temp_air` in C, 166 | - direct irradiation 'dni' in W/m² (calculated later), 167 | - global horizontal irradiation 'ghi' in W/m², 168 | - diffuse horizontal irradiation 'dhi' in W/m² 169 | 170 | Parameters 171 | ---------- 172 | ds : xarray.Dataset 173 | Dataset with ERA5 weather data. 174 | 175 | Returns 176 | -------- 177 | pd.DataFrame 178 | Dataframe formatted for the pvlib. 179 | 180 | """ 181 | 182 | # compute the norm of the wind speed 183 | ds["wind_speed"] = np.sqrt(ds["u10"] ** 2 + ds["v10"] ** 2).assign_attrs( 184 | units=ds["u10"].attrs["units"], long_name="10 metre wind speed" 185 | ) 186 | 187 | # convert temperature to Celsius (from Kelvin) 188 | ds["temp_air"] = ds.t2m - 273.15 189 | 190 | ds["dirhi"] = (ds.fdir / 3600.0).assign_attrs(units="W/m^2") 191 | ds["ghi"] = (ds.ssrd / 3600.0).assign_attrs( 192 | units="W/m^2", long_name="global horizontal irradiation" 193 | ) 194 | ds["dhi"] = (ds.ghi - ds.dirhi).assign_attrs( 195 | units="W/m^2", long_name="direct irradiation" 196 | ) 197 | 198 | # drop not needed variables 199 | pvlib_vars = ["ghi", "dhi", "wind_speed", "temp_air"] 200 | ds_vars = list(ds.variables) 201 | drop_vars = [ 202 | _ 203 | for _ in ds_vars 204 | if _ not in pvlib_vars + ["latitude", "longitude", "time"] 205 | ] 206 | ds = ds.drop(drop_vars) 207 | 208 | # convert to dataframe 209 | df = ds.to_dataframe().reset_index() 210 | 211 | # the time stamp given by ERA5 for mean values (probably) corresponds to 212 | # the end of the valid time interval; the following sets the time stamp 213 | # to the middle of the valid time interval 214 | df["time"] = df.time - pd.Timedelta(minutes=30) 215 | 216 | df.set_index(["time", "latitude", "longitude"], inplace=True) 217 | df.sort_index(inplace=True) 218 | df = df.tz_localize("UTC", level=0) 219 | 220 | df = df[["wind_speed", "temp_air", "ghi", "dhi"]] 221 | df.dropna(inplace=True) 222 | 223 | return df 224 | 225 | 226 | def select_area(ds, lon, lat, g_step=0.25): 227 | """ 228 | Select data for given location or rectangular area from dataset. 229 | 230 | In case data for a single location is requested, the nearest data point 231 | for which weather data is given is returned. 232 | 233 | Parameters 234 | ----------- 235 | ds : xarray.Dataset 236 | Dataset with ERA5 weather data. 237 | lon : float or tuple 238 | Longitude of single location or area to select data for. In case 239 | longitude is provided as tuple first entry must be the west boundary 240 | and second entry the east boundary. 241 | lat : float or tuple 242 | Latitude of single location or area to select data for. In case 243 | latitude is provided as tuple first entry must be the south boundary 244 | and second entry the north boundary. 245 | g_step : float 246 | Grid resolution of weather data, needed to find nearest point in case 247 | a single location is requested. 248 | 249 | Returns 250 | ------- 251 | xarray.Dataset 252 | Dataset containing selection for specified location or area. 253 | 254 | """ 255 | select_point = True 256 | if np.size(lon) > 1: 257 | select_point = False 258 | lon_w, lon_e = lon 259 | else: 260 | lon_w = lon 261 | lon_e = lon + g_step 262 | 263 | if np.size(lat) > 1: 264 | select_point = False 265 | lat_s, lat_n = lat 266 | else: 267 | lat_s = lat 268 | lat_n = lat + g_step 269 | 270 | if select_point is True: 271 | answer = ds.sel(latitude=lat, longitude=lon, method="nearest") 272 | else: 273 | answer = ds.where( 274 | (lat_s < ds.latitude) 275 | & (ds.latitude <= lat_n) 276 | & (lon_w < ds.longitude) 277 | & (ds.longitude <= lon_e) 278 | ) 279 | 280 | return answer 281 | 282 | 283 | def select_geometry(ds, area): 284 | """ 285 | Select data for given geometry from dataset. 286 | 287 | Parameters 288 | ----------- 289 | ds : xarray.Dataset 290 | Dataset with ERA5 weather data. 291 | area : shapely's compatible geometry object (i.e. Polygon, Multipolygon, etc...) 292 | Area to select data for. 293 | 294 | Returns 295 | ------- 296 | xarray.Dataset 297 | Dataset containing selection for specified location or area. 298 | 299 | """ # noqa: E501 300 | geometry = [] 301 | lon_vals = [] 302 | lat_vals = [] 303 | 304 | df = pd.DataFrame([], columns=["lon", "lat"]) 305 | 306 | for i, x in enumerate(ds.longitude): 307 | for j, y in enumerate(ds.latitude): 308 | lon_vals.append(x.values) 309 | lat_vals.append(y.values) 310 | geometry.append(Point(x, y)) 311 | 312 | df["lon"] = lon_vals 313 | df["lat"] = lat_vals 314 | 315 | # create a geopandas to use the geometry functions 316 | crs = {"init": "epsg:4326"} 317 | geo_df = gpd.GeoDataFrame(df, crs=crs, geometry=geometry) 318 | 319 | inside_points = geo_df.within(area) 320 | # if no points lie within area, return None 321 | if not inside_points.any(): 322 | return None 323 | 324 | inside_lon = geo_df.loc[inside_points, "lon"].values 325 | inside_lat = geo_df.loc[inside_points, "lat"].values 326 | 327 | # prepare a list where the latitude and longitude of the points inside are 328 | # formatted as xarray of bools 329 | logical_list = [] 330 | for lon, lat in zip(inside_lon, inside_lat): 331 | logical_list.append( 332 | np.logical_and((ds.longitude == lon), (ds.latitude == lat)) 333 | ) 334 | 335 | # bind all conditions from the list 336 | cond = np.logical_or(*logical_list[:2]) 337 | for new_cond in logical_list[2:]: 338 | cond = np.logical_or(cond, new_cond) 339 | 340 | # apply the condition to where 341 | return ds.where(cond) 342 | 343 | 344 | def weather_df_from_era5( 345 | era5_netcdf_filename, lib, start=None, end=None, area=None 346 | ): 347 | """ 348 | Gets ERA5 weather data from netcdf file and converts it to a pandas 349 | dataframe as required by the spcified lib. 350 | 351 | Parameters 352 | ----------- 353 | era5_netcdf_filename : str 354 | Filename including path of netcdf file containing ERA5 weather data 355 | for specified time span and area. 356 | start : None or anything `pandas.to_datetime` can convert to a timestamp 357 | Get weather data starting from this date. Defaults to None in which 358 | case start is set to first time step in the dataset. 359 | end : None or anything `pandas.to_datetime` can convert to a timestamp 360 | Get weather data upto this date. Defaults to None in which 361 | case the end date is set to the last time step in the dataset. 362 | area : shapely compatible geometry object (i.e. Polygon, Multipolygon, etc...) or list(float) or list(tuple) 363 | Area specifies for which geographic area to return weather data. Area 364 | can either be a single location or an area. 365 | In case you want data for a single location provide a list in the 366 | form [lon, lat]. 367 | If you want data for an area you can provide a shape of this area or 368 | specify a rectangular area giving a list of the 369 | form [(lon west, lon east), (lat south, lat north)]. 370 | 371 | Returns 372 | ------- 373 | pd.DataFrame 374 | Dataframe with ERA5 weather data in format required by the lib. In 375 | case a single location is provided in parameter `area` index of the 376 | dataframe is a datetime index. Otherwise the index is a multiindex 377 | with time, latitude and longitude levels. 378 | 379 | """ # noqa: E501 380 | ds = xr.open_dataset(era5_netcdf_filename) 381 | 382 | if area is not None: 383 | if isinstance(area, list): 384 | ds = select_area(ds, area[0], area[1]) 385 | else: 386 | ds = select_geometry(ds, area) 387 | if ds is None: 388 | return pd.DataFrame() 389 | 390 | if lib == "windpowerlib": 391 | df = format_windpowerlib(ds) 392 | elif lib == "pvlib": 393 | df = format_pvlib(ds) 394 | else: 395 | raise ValueError( 396 | "Unknown value for `lib`. " 397 | "It must be either 'pvlib' or 'windpowerlib'." 398 | ) 399 | 400 | # drop latitude and longitude from index in case a single location 401 | # is given in parameter `area` 402 | if area is not None and isinstance(area, list): 403 | if np.size(area[0]) == 1 and np.size(area[1]) == 1: 404 | df.index = df.index.droplevel(level=[1, 2]) 405 | 406 | if start is None: 407 | start = df.index[0] 408 | if end is None: 409 | end = df.index[-1] 410 | return df[start:end] 411 | -------------------------------------------------------------------------------- /src/feedinlib/powerplants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | Power plant classes for specific weather dependent renewable energy resources. 5 | 6 | SPDX-FileCopyrightText: Birgit Schachler 7 | SPDX-FileCopyrightText: Uwe Krien 8 | SPDX-FileCopyrightText: Stephan Günther 9 | SPDX-FileCopyrightText: Lucas Schmeling 10 | SPDX-FileCopyrightText: Keno Oltmanns 11 | SPDX-FileCopyrightText: Patrik Schönfeldt 12 | 13 | SPDX-License-Identifier: MIT 14 | 15 | Power plant classes act as data holders for the attributes making up a 16 | power plant's specification. These classes should only contain little logic. 17 | Computing the actual feed-in provided by a power plant is done by the models 18 | (see models.py). The model the feed-in is calculated with is specified in 19 | the `model` attribute. 20 | """ 21 | 22 | from abc import ABC 23 | from abc import abstractmethod 24 | 25 | from .models.pvlib import Pvlib 26 | from .models.windpowerlib import WindpowerlibTurbine 27 | 28 | 29 | class Base(ABC): 30 | """ 31 | The base class of feedinlib power plants. 32 | 33 | The class mainly serves as a data container for power plant attributes. 34 | Actual calculation of feed-in provided by the power plant is done by the 35 | chosen model. See model.py module for implemented models. 36 | 37 | This base class is an abstract class serving as a blueprint for classes 38 | that implement weather dependent renewable energy power plants. It 39 | forces implementors to implement certain properties and methods. 40 | 41 | Parameters 42 | ---------- 43 | model : :class:`~feedinlib.models.Base` subclass or instance 44 | The `model` parameter defines the feed-in model used to calculate 45 | the power plant feed-in. 46 | 47 | If a class (or in general, any instance of :class:`type`) is 48 | provided, it is used to create the model instance encapsulating the 49 | actual mathematical model used to calculate the feed-in provided by 50 | this power plant. 51 | 52 | In any other case, the provided object is used directly. Note 53 | though, that a reference to this power plant is saved in the 54 | provided object, so sharing model instances between two power plant 55 | objects is not a good idea, as the second power plant will 56 | overwrite the reference to the first. 57 | 58 | The non-class version is only provided for users who need the extra 59 | flexibility of controlling model instantiation and who know what 60 | they are doing. In general, you'll want to provide a class for this 61 | parameter or just go with the default for the specific subclass you 62 | are using. 63 | 64 | **attributes : 65 | Besides `model` parameter provided attributes hold the technical 66 | specification used to define the power plant. See 67 | `power_plant_parameters` parameter in respective model's 68 | :meth:`feedin` method for further information on the model's 69 | required and optional plant parameters. 70 | 71 | Raises 72 | ------ 73 | AttributeError 74 | In case an attribute listed in the given model's required 75 | parameters is not present in the `parameters` parameter. 76 | 77 | """ 78 | 79 | def __init__(self, **attributes): 80 | """ 81 | """ 82 | model = attributes.pop("model") 83 | if isinstance(model, type): 84 | model = model(**attributes) 85 | self.model = model 86 | 87 | self.parameters = attributes 88 | 89 | # check if all power plant attributes required by the respective model 90 | # are provided 91 | self._check_models_power_plant_requirements(attributes.keys()) 92 | 93 | @abstractmethod 94 | def feedin(self, weather, **kwargs): 95 | """ 96 | Calculates power plant feed-in in Watt. 97 | 98 | This method delegates the actual computation to the model's 99 | :meth:`feedin` method while giving you the opportunity to override 100 | some of the inputs used to calculate the feed-in. 101 | 102 | If the respective model does calculate AC and DC feed-in, AC feed-in 103 | is returned by default. See the model's :meth:`feedin` method for 104 | information on how to overwrite this default behaviour. 105 | 106 | Parameters 107 | ---------- 108 | weather : 109 | Weather data to calculate feed-in. Check the `weather` parameter 110 | of the respective model's :meth:`feedin` method for required 111 | weather data parameters and format. 112 | **kwargs : 113 | Keyword arguments for respective model's feed-in calculation. 114 | Check the keyword arguments of the model's :meth:`feedin` for 115 | further information. 116 | 117 | Returns 118 | ------- 119 | feedin : :pandas:`pandas.Series` 120 | Series with power plant feed-in in Watt. 121 | 122 | """ 123 | model = kwargs.pop("model", self.model) 124 | # in case a different model used to calculate feed-in than originally 125 | # assigned is given, self.model is overwritten and required power plant 126 | # parameters for new model are checked 127 | if not model == self.model: 128 | model = model(**self.parameters) 129 | self.model = model 130 | self._check_models_power_plant_requirements(self.parameters.keys()) 131 | 132 | # check if all arguments required by the feed-in model are given 133 | keys = kwargs.keys() 134 | for k in model.requires: 135 | if k not in keys: 136 | raise AttributeError( 137 | "The specified model '{model}' requires model " 138 | "parameter '{k}' but it's not provided as an " 139 | "argument.".format(k=k, model=model) 140 | ) 141 | # call respective model's feed-in method 142 | return model.feedin( 143 | weather=weather, power_plant_parameters=self.parameters, **kwargs 144 | ) 145 | 146 | def _check_models_power_plant_requirements(self, parameters): 147 | """ 148 | Checks if given model's required power plant parameters are provided. 149 | 150 | An error is raised if the attributes required by the given model are 151 | not contained in the provided parameters in `parameters`. 152 | 153 | Parameters 154 | ----------- 155 | parameters : list(str) 156 | List of provided power plant parameters. 157 | 158 | Raises 159 | ------ 160 | AttributeError 161 | In case an attribute listed in the given model's required 162 | parameters is not present in the `parameters` parameter. 163 | 164 | """ 165 | try: 166 | # call the given model's check function if implemented 167 | self.model._power_plant_requires_check(parameters) 168 | except NotImplementedError: 169 | for k in self.required: 170 | if k not in parameters: 171 | raise AttributeError( 172 | "The specified model '{model}' requires power plant " 173 | "parameter '{k}' but it's not provided as an " 174 | "argument.".format(k=k, model=self.model) 175 | ) 176 | 177 | @property 178 | def required(self): 179 | """ 180 | The power plant parameters the specified model requires. 181 | 182 | Check the model's :attr:`power_plant_requires` attribute for further 183 | information. 184 | 185 | """ 186 | return self.model.power_plant_requires 187 | 188 | 189 | class Photovoltaic(Base): 190 | """ 191 | Class to define a standard set of PV system attributes. 192 | 193 | The Photovoltaic class serves as a data container for PV system attributes. 194 | Actual calculation of feed-in provided by the PV system is done by the 195 | chosen PV model. So far there is only one PV model, 196 | :class:`~.models.Pvlib`. 197 | 198 | Parameters 199 | ---------- 200 | model : A subclass or instance of subclass of \ 201 | :class:`~.models.PhotovoltaicModelBase` 202 | The `model` parameter defines the feed-in model used to calculate 203 | the PV system feed-in. It defaults to 204 | :class:`~feedinlib.models.Pvlib` which is currently the only 205 | implemented photovoltaic model. 206 | 207 | `model` is used as the `model` parameter for :class:`Base`. 208 | **attributes : 209 | PV system parameters. See `power_plant_parameters` parameter 210 | in respective model's :func:`feedin` method for further 211 | information on the model's required and optional plant parameters. 212 | 213 | As the :class:`~.models.Pvlib` model is currently the only 214 | implemented photovoltaic model see `power_plant_parameters` parameter 215 | :meth:`~.models.Pvlib.feedin` for further information. 216 | 217 | """ 218 | 219 | def __init__(self, model=Pvlib, **attributes): 220 | """ 221 | """ 222 | super().__init__(model=model, **attributes) 223 | 224 | def feedin(self, weather, scaling=None, **kwargs): 225 | """ 226 | Calculates PV system feed-in in Watt. 227 | 228 | The feed-in can further be scaled by PV system area or peak power using 229 | the `scaling` parameter. 230 | 231 | This method delegates the actual computation to the model's 232 | :meth:`feedin` method while giving you the opportunity to override 233 | some of the inputs used to calculate the feed-in. As the 234 | :class:`~.models.Pvlib` model is currently the only 235 | implemented photovoltaic model see 236 | :meth:`~.models.Pvlib.feedin` for further information on 237 | feed-in calculation. 238 | 239 | If the respective model does calculate AC and DC feed-in, AC feed-in 240 | is returned by default. See the model's :meth:`feedin` method for 241 | information on how to overwrite this default behaviour. 242 | 243 | Parameters 244 | ---------- 245 | weather : 246 | Weather data to calculate feed-in. Check the `weather` parameter 247 | of the respective model's :meth:`feedin` method for required 248 | weather data parameters and format. 249 | scaling : str 250 | Specifies what feed-in is scaled by. Possible options are 251 | 'peak_power' and 'area'. Defaults to None in which case feed-in is 252 | not scaled. 253 | **kwargs 254 | Keyword arguments for respective model's feed-in calculation. 255 | Check the keyword arguments of the model's :meth:`feedin` method 256 | for further information. 257 | 258 | Returns 259 | ------- 260 | :pandas:`pandas.Series` 261 | Series with PV system feed-in in Watt. 262 | 263 | """ 264 | # delegate feed-in calculation 265 | feedin = super().feedin(weather=weather, **kwargs) 266 | # scale feed-in 267 | if scaling: 268 | feedin_scaling = { 269 | "peak_power": lambda feedin: feedin / float(self.peak_power), 270 | "area": lambda feedin: feedin / float(self.area), 271 | } 272 | return feedin_scaling[scaling](feedin) 273 | return feedin 274 | 275 | @property 276 | def area(self): 277 | """ 278 | Area of PV system in :math:`m^2`. 279 | 280 | See :attr:`pv_system_area` attribute of your chosen model for further 281 | information on how the area is calculated. 282 | 283 | """ 284 | return self.model.pv_system_area 285 | 286 | @property 287 | def peak_power(self): 288 | """ 289 | Peak power of PV system in Watt. 290 | 291 | See :attr:`pv_system_peak_power` attribute of your chosen model for 292 | further information and specifications on how the peak power is 293 | calculated. 294 | 295 | """ 296 | return self.model.pv_system_peak_power 297 | 298 | 299 | class WindPowerPlant(Base): 300 | """ 301 | Class to define a standard set of wind power plant attributes. 302 | 303 | The WindPowerPlant class serves as a data container for wind power plant 304 | attributes. Actual calculation of feed-in provided by the wind power plant 305 | is done by the chosen wind power model. So far there are two wind power 306 | models, :class:`~.models.WindpowerlibTurbine` and 307 | :class:`~.models.WindpowerlibTurbineCluster`. The 308 | :class:`~.models.WindpowerlibTurbine` model should be used for 309 | single wind turbines, whereas the 310 | :class:`~.models.WindpowerlibTurbineCluster` model can be used 311 | for wind farm and wind turbine cluster calculations. 312 | 313 | Parameters 314 | ---------- 315 | model : A subclass or instance of subclass of \ 316 | :class:`feedinlib.models.WindpowerModelBase` 317 | The `model` parameter defines the feed-in model used to calculate 318 | the wind power plant feed-in. It defaults to 319 | :class:`~.models.WindpowerlibTurbine`. 320 | 321 | `model` is used as the `model` parameter for :class:`Base`. 322 | **attributes : 323 | Wind power plant parameters. See `power_plant_parameters` parameter 324 | in respective model's :meth:`feedin` method for further 325 | information on the model's required and optional plant parameters. 326 | 327 | """ 328 | 329 | def __init__(self, model=WindpowerlibTurbine, **attributes): 330 | """ 331 | """ 332 | super().__init__(model=model, **attributes) 333 | 334 | def feedin(self, weather, scaling=None, **kwargs): 335 | """ 336 | Calculates wind power plant feed-in in Watt. 337 | 338 | The feed-in can further be scaled by the nominal power of 339 | the wind power plant using the `scaling` parameter. 340 | 341 | This method delegates the actual computation to the model's 342 | meth:`feedin` method while giving you the opportunity to override 343 | some of the inputs used to calculate the feed-in. See model's 344 | :meth:`feedin` method for further information on feed-in 345 | calculation. 346 | 347 | Parameters 348 | ---------- 349 | weather : 350 | Weather data to calculate feed-in. Check the `weather` parameter 351 | of the respective model's :meth:`feedin` method for required 352 | weather data parameters and format. 353 | scaling : str 354 | Specifies what feed-in is scaled by. Possible option is 355 | 'nominal_power'. Defaults to None in which case feed-in is 356 | not scaled. 357 | **kwargs 358 | Keyword arguments for respective model's feed-in calculation. 359 | Check the keyword arguments of the model's :meth:`feedin` method 360 | for further information. 361 | 362 | Returns 363 | ------- 364 | :pandas:`pandas.Series` 365 | Series with wind power plant feed-in in Watt. 366 | 367 | """ 368 | # delegate feed-in calculation 369 | feedin = super().feedin(weather, **kwargs) 370 | # scale feed-in 371 | if scaling: 372 | feedin_scaling = { 373 | "nominal_power": lambda feedin: feedin 374 | / float(self.nominal_power) 375 | } 376 | return feedin_scaling[scaling](feedin) 377 | return feedin 378 | 379 | @property 380 | def nominal_power(self): 381 | """ 382 | Nominal power of wind power plant in Watt. 383 | 384 | See :attr:`nominal_power` attribute of your chosen model for further 385 | information on how the nominal power is derived. 386 | 387 | """ 388 | return self.model.nominal_power_wind_power_plant 389 | -------------------------------------------------------------------------------- /src/feedinlib/open_FRED.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from itertools import groupby 3 | from typing import Dict 4 | from typing import List 5 | from typing import Tuple 6 | from typing import Union 7 | 8 | import oedialect # noqa: F401 9 | import open_FRED.cli as ofr 10 | import pandas as pd 11 | import sqlalchemy as sqla 12 | from geoalchemy2.elements import WKTElement as WKTE 13 | from geoalchemy2.shape import to_shape 14 | from pandas import DataFrame as DF 15 | from pandas import Series 16 | from pandas import Timedelta as TD 17 | from pandas import to_datetime as tdt 18 | from shapely.geometry import Point 19 | from sqlalchemy.orm import sessionmaker 20 | 21 | from .dedup import deduplicate 22 | 23 | #: The type of variable selectors. A selector should always contain the 24 | #: name of the variable to select and optionally the height to select, 25 | #: if only a specific one is desired. 26 | Selector = Union[Tuple[str], Tuple[str, int]] 27 | 28 | 29 | TRANSLATIONS: Dict[str, Dict[str, List[Selector]]] = { 30 | "windpowerlib": { 31 | "wind_speed": [("VABS_AV",)], 32 | "temperature": [("T",)], 33 | "roughness_length": [("Z0",)], 34 | "pressure": [("P",)], 35 | }, 36 | "pvlib": { 37 | "wind_speed": [("VABS_AV", 10)], 38 | "temp_air": [("T", 10)], 39 | "pressure": [("P", 10)], 40 | "dhi": [("ASWDIFD_S", 0)], 41 | "ghi": [("ASWDIFD_S", 0), ("ASWDIR_S", 0)], 42 | "dni": [("ASWDIRN_S", 0)], 43 | }, 44 | } 45 | 46 | 47 | def defaultdb(): 48 | engine = getattr(defaultdb, "engine", None) or sqla.create_engine( 49 | "postgresql+oedialect://openenergy-platform.org" 50 | ) 51 | defaultdb.engine = engine 52 | session = ( 53 | getattr(defaultdb, "session", None) or sessionmaker(bind=engine)() 54 | ) 55 | defaultdb.session = session 56 | metadata = sqla.MetaData(schema="climate", bind=engine, reflect=False) 57 | return {"session": session, "db": ofr.mapped_classes(metadata)} 58 | 59 | 60 | class Weather: 61 | """ 62 | Load weather measurements from an openFRED conforming database. 63 | 64 | Note that you need a database storing weather data using the openFRED 65 | schema in order to use this class. There is one publicly available at 66 | 67 | https://openenergy-platform.org 68 | 69 | Now you can simply instantiate a `Weather` object via e.g.: 70 | 71 | Examples 72 | -------- 73 | 74 | >>> from shapely.geometry import Point 75 | >>> point = Point(9.7311, 53.3899) 76 | >>> weather = Weather( 77 | ... start="2007-04-05 06:00", 78 | ... stop="2007-04-05 07:31", 79 | ... locations=[point], 80 | ... heights=[10], 81 | ... variables="pvlib", 82 | ... **defaultdb() 83 | ... ) 84 | 85 | 86 | Instead of the special values `"pvlib"` and `"windpowerlib"` you can 87 | also supply a list of variables, like e.g. `["P", "T", "Z0"]`, to 88 | retrieve from the database. 89 | 90 | After initialization, you can use e.g. `weather.df(point, "pvlib")` 91 | to retrieve a `DataFrame` with weather data from the measurement 92 | location closest to the given `point`. 93 | 94 | Parameters 95 | ---------- 96 | start : Anything `pandas.to_datetime` can convert to a timestamp 97 | Load weather data starting from this date. 98 | stop : Anything `pandas.to_datetime` can convert to a timestamp 99 | Don't load weather data before this date. 100 | locations : list of :shapely:`Point` 101 | Weather measurements are collected from measurement locations closest 102 | to the the given points. 103 | location_ids : list of int 104 | Weather measurements are collected from measurement locations having 105 | primary keys, i.e. IDs, in this list. Use this e.g. if you know you're 106 | using the same location(s) for multiple queries and you don't want 107 | the overhead of doing the same nearest point query multiple times. 108 | heights : list of numeric 109 | Limit selected timeseries to these heights. If `variables` contains a 110 | variable which isn't height dependent, i.e. it has only one height, 111 | namely `0`, the corresponding timeseries is always 112 | selected. Don't select the correspoding variable, in order to avoid 113 | this. 114 | Defaults to `None` which means no restriction on height levels. 115 | variables : list of str or one of "pvlib" or "windpowerlib" 116 | Load the weather variables specified in the given list, or the 117 | variables necessary to calculate a feedin using `"pvlib"` or 118 | `"windpowerlib"`. 119 | Defaults to `None` which means no restriction on loaded variables. 120 | regions : list of :shapely:`Polygon` 121 | Weather measurements are collected from measurement locations 122 | contained within the given polygons. 123 | session : `sqlalchemy.orm.Session` 124 | db : dict of mapped classes 125 | 126 | """ 127 | 128 | def __init__( 129 | self, 130 | start, 131 | stop, 132 | locations, 133 | location_ids=None, 134 | heights=None, 135 | variables=None, 136 | regions=None, 137 | session=None, 138 | db=None, 139 | ): 140 | self.session = session 141 | self.db = db 142 | 143 | if self.session is None and self.db is None: 144 | return 145 | 146 | variables = { 147 | "windpowerlib": ["P", "T", "VABS_AV", "Z0"], 148 | "pvlib": [ 149 | "ASWDIFD_S", 150 | "ASWDIRN_S", 151 | "ASWDIR_S", 152 | "P", 153 | "T", 154 | "VABS_AV", 155 | ], 156 | None: variables, 157 | }[variables if variables in ["pvlib", "windpowerlib"] else None] 158 | 159 | self.locations = ( 160 | {(p.x, p.y): self.location(p) for p in locations} 161 | if locations is not None 162 | else {} 163 | ) 164 | 165 | self.regions = ( 166 | {WKTE(r, srid=4326): self.within(r) for r in regions} 167 | if regions is not None 168 | else {} 169 | ) 170 | if location_ids is None: 171 | location_ids = [] 172 | self.location_ids = set( 173 | [ 174 | d.id 175 | for d in chain(self.locations.values(), *self.regions.values()) 176 | ] 177 | + location_ids 178 | ) 179 | 180 | self.locations = { 181 | k: to_shape(self.locations[k].point) for k in self.locations 182 | } 183 | self.locations.update( 184 | { 185 | (p.x, p.y): p 186 | for p in chain( 187 | self.locations.values(), 188 | ( 189 | to_shape(location.point) 190 | for region in self.regions.values() 191 | for location in region 192 | ), 193 | ) 194 | } 195 | ) 196 | 197 | self.regions = { 198 | k: [to_shape(location.point) for location in self.regions[k]] 199 | for k in self.regions 200 | } 201 | 202 | series = sorted( 203 | session.query( 204 | db["Series"], db["Variable"], db["Timespan"], db["Location"] 205 | ) 206 | .join(db["Series"].variable) 207 | .join(db["Series"].timespan) 208 | .join(db["Series"].location) 209 | .filter((db["Series"].location_id.in_(self.location_ids))) 210 | .filter( 211 | True 212 | if variables is None 213 | else db["Variable"].name.in_(variables) 214 | ) 215 | .filter( 216 | True 217 | if heights is None 218 | else (db["Series"].height.in_(chain([0], heights))) 219 | ) 220 | .filter( 221 | (db["Timespan"].stop >= tdt(start)) 222 | & (db["Timespan"].start <= tdt(stop)) 223 | ) 224 | .all(), 225 | key=lambda p: ( 226 | p[3].id, 227 | p[1].name, 228 | p[0].height, 229 | p[2].start, 230 | p[2].stop, 231 | ), 232 | ) 233 | 234 | self.series = { 235 | k: [ 236 | ( 237 | segment_start.tz_localize("UTC") 238 | if segment_start.tz is None 239 | else segment_start, 240 | segment_stop.tz_localize("UTC") 241 | if segment_stop.tz is None 242 | else segment_stop, 243 | value, 244 | ) 245 | for (series, variable, timespan, location) in g 246 | for (segment, value) in zip(timespan.segments, series.values) 247 | for segment_start in [tdt(segment[0])] 248 | for segment_stop in [tdt(segment[1])] 249 | if segment_start >= tdt(start) and segment_stop <= tdt(stop) 250 | ] 251 | for k, g in groupby( 252 | series, 253 | key=lambda p: ( 254 | (to_shape(p[3].point).x, to_shape(p[3].point).y), 255 | p[1].name, 256 | p[0].height, 257 | ), 258 | ) 259 | } 260 | self.series = {k: deduplicate(self.series[k]) for k in self.series} 261 | self.variables = { 262 | k: sorted(set(h for _, h in g)) 263 | for k, g in groupby( 264 | sorted((name, height) for _, name, height in self.series), 265 | key=lambda p: p[0], 266 | ) 267 | } 268 | self.variables = {k: {"heights": v} for k, v in self.variables.items()} 269 | 270 | @classmethod 271 | def from_df(klass, df): 272 | assert isinstance(df.columns, pd.MultiIndex), ( 273 | "DataFrame's columns aren't a `pandas.indexes.multi.MultiIndex`.\n" 274 | "Got `{}` instead." 275 | ).format(type(df.columns)) 276 | assert len(df.columns.levels) == 2, ( 277 | "DataFrame's columns have more than two levels.\nGot: {}.\n" 278 | "Should be exactly two, the first containing variable names and " 279 | "the\n" 280 | "second containing matching height levels." 281 | ) 282 | variables = { 283 | variable: {"heights": [vhp[1] for vhp in variable_height_pairs]} 284 | for variable, variable_height_pairs in groupby( 285 | df.columns.values, 286 | key=lambda variable_height_pair: variable_height_pair[0], 287 | ) 288 | } 289 | locations = {xy: Point(xy[0], xy[1]) for xy in df.index.values} 290 | series = { 291 | (xy, *variable_height_pair): df.loc[xy, variable_height_pair] 292 | for xy in df.index.values 293 | for variable_height_pair in df.columns.values 294 | } 295 | instance = klass(start=None, stop=None, locations=None) 296 | instance.locations = locations 297 | instance.series = series 298 | instance.variables = variables 299 | return instance 300 | 301 | def location(self, point: Point): 302 | """ Get the measurement location closest to the given `point`. 303 | """ 304 | point = WKTE(point.to_wkt(), srid=4326) 305 | return ( 306 | self.session.query(self.db["Location"]) 307 | .order_by(self.db["Location"].point.distance_centroid(point)) 308 | .first() 309 | ) 310 | 311 | def within(self, region=None): 312 | """ Get all measurement locations within the given `region`. 313 | """ 314 | region = WKTE(region.to_wkt(), srid=4326) 315 | return ( 316 | self.session.query(self.db["Location"]) 317 | .filter(self.db["Location"].point.ST_Within(region)) 318 | .all() 319 | ) 320 | 321 | def to_csv(self, path): 322 | df = self.df() 323 | df = df.applymap( 324 | # Unzip, i.e. convert a list of tuples to a tuple of lists, the 325 | # list of triples in each DataFrame cell and convert the result to 326 | # a JSON string. Unzipping is necessary because the pandas' 327 | # `to_json` wouldn't format the `Timestamps` correctly otherwise. 328 | lambda s: pd.Series(pd.Series(xs) for xs in zip(*s)).to_json( 329 | date_format="iso" 330 | ) 331 | ) 332 | return df.to_csv(path, quotechar="'") 333 | 334 | @classmethod 335 | def from_csv(cls, path_or_buffer): 336 | df = pd.read_csv( 337 | path_or_buffer, 338 | # This is necessary because the automatic conversion isn't precise 339 | # enough. 340 | converters={0: float, 1: float}, 341 | header=[0, 1], 342 | index_col=[0, 1], 343 | quotechar="'", 344 | ) 345 | df.columns.set_levels( 346 | [df.columns.levels[0], [float(c) for c in df.columns.levels[1]]], 347 | inplace=True, 348 | ) 349 | df = df.applymap(lambda s: pd.read_json(s, typ="series")) 350 | # Reading the JSON string back in yields a weird format. Instead of a 351 | # nested `Series` we get a `Series` containing three dictionaries. The 352 | # `dict`s "emulate" a `Series` since their keys are string 353 | # representations of integers and their values are the actual values 354 | # that would be stored at the corresponding position in a `Series`. So 355 | # we have to manually reformat the data we get back. Since there's no 356 | # point in doing two conversions, we don't convert it back to nested 357 | # `Series`, but immediately to `list`s of `(start, stop, value)` 358 | # triples. 359 | df = df.applymap( 360 | lambda s: list( 361 | zip( 362 | *[ 363 | [ 364 | # The `Timestamp`s in the inner `Series`/`dict`s 365 | # where also not converted, so we have to do this 366 | # manually, too. 367 | pd.to_datetime(v, utc=True) if n in [0, 1] else v 368 | for k, v in sorted( 369 | s[n].items(), key=lambda kv: int(kv[0]) 370 | ) 371 | ] 372 | for n in s.index 373 | ] 374 | ) 375 | ) 376 | ) 377 | return cls.from_df(df) 378 | 379 | def df(self, location=None, lib=None): 380 | if lib is None and location is None: 381 | columns = sorted(set((n, h) for (xy, n, h) in self.series)) 382 | index = sorted(xy for xy in set(xy for (xy, n, h) in self.series)) 383 | data = { 384 | (n, h): [self.series[xy, n, h] for xy in index] 385 | for (n, h) in columns 386 | } 387 | return DF(index=pd.MultiIndex.from_tuples(index), data=data) 388 | 389 | if lib is None: 390 | raise NotImplementedError( 391 | "Arbitrary dataframes not supported yet.\n" 392 | 'Please use one of `lib="pvlib"` or `lib="windpowerlib"`.' 393 | ) 394 | 395 | xy = (location.x, location.y) 396 | location = ( 397 | self.locations[xy] 398 | if xy in self.locations 399 | else to_shape(self.location(location).point) 400 | if self.session is not None 401 | else min( 402 | self.locations.values(), 403 | key=lambda point: location.distance(point), 404 | ) 405 | ) 406 | point = (location.x, location.y) 407 | 408 | index = ( 409 | [ 410 | dhi[0] + (dhi[1] - dhi[0]) / 2 411 | for dhi in self.series[point, "ASWDIFD_S", 0] 412 | ] 413 | if lib == "pvlib" 414 | else [ 415 | wind_speed[0] 416 | for wind_speed in self.series[ 417 | point, "VABS_AV", self.variables["VABS_AV"]["heights"][0] 418 | ] 419 | ] 420 | if lib == "windpowerlib" 421 | else [] 422 | ) 423 | 424 | def to_series(v, h): 425 | s = self.series[point, v, h] 426 | return Series([p[2] for p in s], index=[p[0] for p in s]) 427 | 428 | series = { 429 | (k[0] if lib == "pvlib" else k): sum( 430 | to_series(*p, *k[1:]) for p in TRANSLATIONS[lib][k[0]] 431 | ) 432 | for k in ( 433 | [ 434 | ("dhi",), 435 | ("dni",), 436 | ("ghi",), 437 | ("pressure",), 438 | ("temp_air",), 439 | ("wind_speed",), 440 | ] 441 | if lib == "pvlib" 442 | else [ 443 | (v, h) 444 | for v in [ 445 | "pressure", 446 | "roughness_length", 447 | "temperature", 448 | "wind_speed", 449 | ] 450 | for h in self.variables[TRANSLATIONS[lib][v][0][0]][ 451 | "heights" 452 | ] 453 | ] 454 | if lib == "windpowerlib" 455 | else [(v,) for v in self.variables] 456 | ) 457 | } 458 | if lib == "pvlib": 459 | series["temp_air"] = ( 460 | (series["temp_air"] - 273.15) 461 | .resample("15min") 462 | .interpolate()[series["dhi"].index] 463 | ) 464 | series["pressure"] = ( 465 | series["pressure"] 466 | .resample("15min") 467 | .interpolate()[series["dhi"].index] 468 | ) 469 | ws = series["wind_speed"] 470 | for k in series["wind_speed"].keys(): 471 | ws[k + TD("15min")] = ws[k] 472 | ws.sort_index(inplace=True) 473 | if lib == "windpowerlib": 474 | roughness = TRANSLATIONS[lib]["roughness_length"][0][0] 475 | series.update( 476 | { 477 | ("roughness_length", h): series["roughness_length", h] 478 | .resample("30min") 479 | .interpolate()[index] 480 | for h in self.variables[roughness]["heights"] 481 | } 482 | ) 483 | series.update({k: series[k][index] for k in series}) 484 | return DF(index=index, data={k: series[k].values for k in series}) 485 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | import pandas as pd 4 | import pytest 5 | from pandas.util.testing import assert_frame_equal 6 | from windpowerlib import WindTurbine as WindpowerlibWindTurbine 7 | 8 | from feedinlib import GeometricSolar 9 | from feedinlib import Photovoltaic 10 | from feedinlib import Pvlib 11 | from feedinlib import WindpowerlibTurbine 12 | from feedinlib import WindpowerlibTurbineCluster 13 | from feedinlib import WindPowerPlant 14 | from feedinlib.models.geometric_solar import solar_angles 15 | 16 | 17 | class Fixtures: 18 | """ 19 | Class providing all fixtures for model tests. 20 | """ 21 | 22 | @pytest.fixture 23 | def pvlib_weather(self): 24 | """ 25 | Returns a test weather dataframe to use in tests for pvlib model. 26 | """ 27 | return pd.DataFrame( 28 | data={ 29 | "wind_speed": [5.0], 30 | "temp_air": [10.0], 31 | "dhi": [150.0], 32 | "ghi": [300], 33 | }, 34 | index=pd.date_range("1/1/1970 12:00", periods=1, tz="UTC"), 35 | ) 36 | 37 | @pytest.fixture 38 | def windpowerlib_weather(self): 39 | """ 40 | Returns a test weather dataframe to use in tests for windpowerlib 41 | model. 42 | """ 43 | return pd.DataFrame( 44 | data={ 45 | ("wind_speed", 10): [5.0], 46 | ("temperature", 2): [270.0], 47 | ("roughness_length", 0): [0.15], 48 | ("pressure", 0): [98400.0], 49 | }, 50 | index=pd.date_range("1/1/1970 12:00", periods=1, tz="UTC"), 51 | ) 52 | 53 | @pytest.fixture 54 | def pvlib_pv_system(self): 55 | """ 56 | Returns a test PV system setup to use in tests for pvlib model. 57 | """ 58 | return { 59 | "module_name": "Yingli_YL210__2008__E__", 60 | "inverter_name": "ABB__MICRO_0_25_I_OUTD_US_208__208V_", 61 | "azimuth": 180, 62 | "tilt": 30, 63 | "albedo": 0.2, 64 | } 65 | 66 | @pytest.fixture 67 | def windpowerlib_turbine(self): 68 | """ 69 | Returns a test wind power plant to use in tests for windpowerlib model. 70 | """ 71 | return {"turbine_type": "E-82/3000", "hub_height": 135} 72 | 73 | @pytest.fixture 74 | def windpowerlib_turbine_2(self): 75 | """ 76 | Returns a test wind power plant to use in tests for windpowerlib model. 77 | """ 78 | return { 79 | "turbine_type": "V90/2000", 80 | "hub_height": 120, 81 | "rotor_diameter": 80, 82 | } 83 | 84 | @pytest.fixture 85 | def windpowerlib_farm(self, windpowerlib_turbine, windpowerlib_turbine_2): 86 | """ 87 | Returns a test wind farm to use in tests for windpowerlib model. 88 | """ 89 | return { 90 | "wind_turbine_fleet": pd.DataFrame( 91 | { 92 | "wind_turbine": [ 93 | windpowerlib_turbine, 94 | windpowerlib_turbine_2, 95 | ], 96 | "number_of_turbines": [6, None], 97 | "total_capacity": [None, 3 * 2e6], 98 | } 99 | ) 100 | } 101 | 102 | @pytest.fixture 103 | def windpowerlib_farm_2(self, windpowerlib_turbine): 104 | """ 105 | Returns a test wind farm to use in tests for windpowerlib model. 106 | """ 107 | return { 108 | "wind_turbine_fleet": [ 109 | WindpowerlibWindTurbine(**windpowerlib_turbine).to_group(1) 110 | ] 111 | } 112 | 113 | @pytest.fixture 114 | def windpowerlib_farm_3( 115 | self, windpowerlib_turbine, windpowerlib_turbine_2 116 | ): 117 | """ 118 | Returns a test wind farm to use in tests for windpowerlib model. 119 | """ 120 | 121 | return { 122 | "wind_turbine_fleet": pd.DataFrame( 123 | { 124 | "wind_turbine": [ 125 | WindPowerPlant(**windpowerlib_turbine), 126 | WindPowerPlant(**windpowerlib_turbine_2), 127 | ], 128 | "number_of_turbines": [6, 3], 129 | } 130 | ) 131 | } 132 | 133 | @pytest.fixture 134 | def windpowerlib_turbine_cluster( 135 | self, windpowerlib_farm, windpowerlib_farm_2 136 | ): 137 | """ 138 | Returns a test wind turbine cluster to use in tests for windpowerlib 139 | model. 140 | """ 141 | return {"wind_farms": [windpowerlib_farm, windpowerlib_farm_2]} 142 | 143 | 144 | class TestPowerplants(Fixtures): 145 | """ 146 | Class to test some basic functionalities of the power plant classes. 147 | """ 148 | 149 | def test_powerplant_requirements(self, pvlib_pv_system, pvlib_weather): 150 | """ 151 | Test that attribute error is not raised in case a valid model is 152 | specified when calling feedin method. 153 | """ 154 | test_module = Photovoltaic(**pvlib_pv_system) 155 | feedin = test_module.feedin( 156 | weather=pvlib_weather, model=Pvlib, location=(52, 13) 157 | ) 158 | assert 143.39361 == pytest.approx(feedin.values[0], 1e-5) 159 | 160 | def test_powerplant_requirements_2(self, pvlib_pv_system, pvlib_weather): 161 | """ 162 | Test that attribute error is raised in case required power plant 163 | parameters are missing when feedin is called with a different model 164 | than initially specified. 165 | """ 166 | test_module = Photovoltaic(**pvlib_pv_system) 167 | msg = "The specified model 'windpowerlib_single_turbine' requires" 168 | with pytest.raises(AttributeError, match=msg): 169 | test_module.feedin( 170 | weather=pvlib_weather, 171 | model=WindpowerlibTurbine, 172 | location=(52, 13), 173 | ) 174 | 175 | def test_pv_feedin_scaling(self, pvlib_pv_system, pvlib_weather): 176 | """ 177 | Test that PV feedin timeseries are scaled correctly. 178 | """ 179 | test_module = Photovoltaic(**pvlib_pv_system) 180 | feedin = test_module.feedin( 181 | weather=pvlib_weather, location=(52, 13), scaling="peak_power" 182 | ) 183 | assert 0.67511 == pytest.approx(feedin.values[0], 1e-5) 184 | feedin = test_module.feedin( 185 | weather=pvlib_weather, location=(52, 13), scaling="area" 186 | ) 187 | assert 84.34918 == pytest.approx(feedin.values[0], 1e-5) 188 | 189 | def test_wind_feedin_scaling( 190 | self, windpowerlib_turbine, windpowerlib_weather 191 | ): 192 | """ 193 | Test that wind feedin timeseries are scaled correctly. 194 | """ 195 | test_turbine = WindPowerPlant(**windpowerlib_turbine) 196 | feedin = test_turbine.feedin( 197 | weather=windpowerlib_weather, scaling="nominal_power" 198 | ) 199 | assert 833050.32551 / 3e6 == pytest.approx(feedin.values[0], 1e-5) 200 | 201 | 202 | class TestGeometricSolar(Fixtures): 203 | """ 204 | Class to test GeometricSolar model and functions it depends on. 205 | """ 206 | 207 | def test_geometric_angles(self): 208 | # c.f. example 1.6.1 from DB13 209 | incidence_0, _ = solar_angles( 210 | datetime=pd.date_range("1970-02-13 10:30", periods=1, tz="UTC"), 211 | surface_azimuth=15, 212 | tilt=45, 213 | latitude=43, 214 | longitude=0, 215 | ) 216 | assert incidence_0 == pytest.approx(0.7838, 1e-4) 217 | 218 | plant1 = GeometricSolar( 219 | tilt=0, azimuth=0, longitude=0, latitude=0, system_efficiency=1 220 | ) 221 | incidence_a, solar_zenith_a = plant1.solar_angles( 222 | datetime=pd.date_range( 223 | "3/20/2017 09:00", periods=12, freq="0.5H", tz="UTC" 224 | ) 225 | ) 226 | # For tilt=0, both angles are the same (by day). 227 | assert incidence_a == pytest.approx(solar_zenith_a) 228 | 229 | plant2 = GeometricSolar( 230 | tilt=180, azimuth=0, longitude=180, latitude=0, system_efficiency=1 231 | ) 232 | incidence_b, solar_zenith_b = plant2.solar_angles( 233 | datetime=pd.date_range( 234 | "3/20/2017 09:00", periods=12, freq="0.5H", tz="UTC" 235 | ) 236 | ) 237 | # Zenith angles at other side of the world are inverted. 238 | assert solar_zenith_a == pytest.approx(-solar_zenith_b, 1e-5) 239 | 240 | # Blocking by the horizon is not considered for angle calculation. 241 | # Thus, incidence for a collector facing down at 242 | # the opposite side of the world are the same. 243 | assert incidence_a == pytest.approx(incidence_b, 1e-5) 244 | 245 | def test_geometric_radiation(self): 246 | # For calculation of radiation, direct radiation is blocked at night. 247 | # So if there is neither reflection (albedo) nor diffuse radiation, 248 | # total radiation should be 0. 249 | plant3 = GeometricSolar( 250 | tilt=60, 251 | azimuth=0, 252 | latitude=40, 253 | longitude=0, 254 | system_efficiency=0.9, 255 | albedo=0, 256 | nominal_peak_power=300, 257 | ) 258 | 259 | data_weather_night = pd.DataFrame( 260 | data={ 261 | "wind_speed": [0], 262 | "temp_air": [25], 263 | "dni": [100], 264 | "dhi": [0], 265 | }, 266 | index=pd.date_range( 267 | "1970-01-01 00:00:00", periods=1, freq="h", tz="UTC" 268 | ), 269 | ) 270 | 271 | assert plant3.geometric_radiation(data_weather_night)[ 272 | 0 273 | ] == pytest.approx(0, 1e-5) 274 | assert plant3.feedin(data_weather_night)[0] == pytest.approx(0, 1e-5) 275 | 276 | # c.f. example 1.16.1 from DB13 277 | plant4 = GeometricSolar( 278 | tilt=60, 279 | azimuth=0, 280 | latitude=40, 281 | longitude=0, 282 | system_efficiency=0.9, 283 | albedo=0.6, 284 | nominal_peak_power=300, 285 | ) 286 | 287 | data_weather_test = pd.DataFrame( 288 | data={ 289 | "wind_speed": [0], 290 | "temp_air": [25], 291 | "dni": [67.8], 292 | "dhi": [221.1], 293 | }, 294 | index=pd.date_range( 295 | "1970-02-20 09:43:44", periods=1, freq="h", tz="UTC" 296 | ), 297 | ) 298 | 299 | assert plant4.geometric_radiation(data_weather_test)[ 300 | 0 301 | ] == pytest.approx(302.86103, 1e-5) 302 | 303 | # extra test for feedin 304 | assert plant4.feedin(data_weather_test)[0] == pytest.approx( 305 | 78.67677, 1e-5 306 | ) 307 | 308 | # check giving same weather with temperature in Kelvin 309 | data_weather_kelvin = pd.DataFrame( 310 | data={ 311 | "wind_speed": [0], 312 | "temperature": [25 + 273.15], 313 | "dni": [67.8], 314 | "dhi": [221.1], 315 | }, 316 | index=pd.date_range( 317 | "1970-02-20 09:43:44", periods=1, freq="h", tz="UTC" 318 | ), 319 | ) 320 | 321 | assert ( 322 | plant4.feedin(data_weather_test)[0] 323 | == plant4.feedin(data_weather_kelvin)[0] 324 | ) 325 | 326 | # check if problematic data (dhi > ghi) is detected 327 | erroneous_weather = pd.DataFrame( 328 | data={ 329 | "wind_speed": [5.0], 330 | "temp_air": [10.0], 331 | "dhi": [500], 332 | "ghi": [300], 333 | }, 334 | index=pd.date_range( 335 | "1/1/1970 12:00", periods=1, freq="H", tz="UTC" 336 | ), 337 | ) 338 | 339 | with pytest.raises(ValueError): 340 | assert plant4.feedin(weather=erroneous_weather) 341 | 342 | def test_pvlib_feedin(self, pvlib_weather): 343 | test_module = GeometricSolar( 344 | tilt=60, 345 | azimuth=0, 346 | latitude=52, 347 | longitude=13, 348 | system_efficiency=0.9, 349 | albedo=0.6, 350 | nominal_peak_power=210, 351 | ) 352 | feedin = test_module.feedin(weather=pvlib_weather, location=(52, 0)) 353 | 354 | assert 214.225104 == pytest.approx(feedin.values[0], 1e-5) 355 | 356 | 357 | class TestPvlib(Fixtures): 358 | """ 359 | Class to test Pvlib model. 360 | """ 361 | 362 | def test_pvlib_feedin(self, pvlib_pv_system, pvlib_weather): 363 | """ 364 | Test basic feedin calculation using pvlib. 365 | It is also tested if dictionary with PV system parameters remains the 366 | same to make sure it could be further used to calculate feed-in with 367 | a different model. 368 | """ 369 | test_copy = deepcopy(pvlib_pv_system) 370 | test_module = Photovoltaic(**pvlib_pv_system) 371 | feedin = test_module.feedin(weather=pvlib_weather, location=(52, 13)) 372 | assert 143.39361 == pytest.approx(feedin.values[0], 1e-5) 373 | assert test_copy == pvlib_pv_system 374 | 375 | def test_pvlib_feedin_with_surface_type( 376 | self, pvlib_pv_system, pvlib_weather 377 | ): 378 | """ 379 | Test basic feedin calculation using pvlib and providing surface type 380 | instead of albedo. 381 | """ 382 | del pvlib_pv_system["albedo"] 383 | pvlib_pv_system["surface_type"] = "grass" 384 | test_module = Photovoltaic(**pvlib_pv_system) 385 | feedin = test_module.feedin(weather=pvlib_weather, location=(52, 13)) 386 | assert 143.39361 == pytest.approx(feedin.values[0], 1e-5) 387 | 388 | def test_pvlib_feedin_with_optional_pp_parameter( 389 | self, pvlib_pv_system, pvlib_weather 390 | ): 391 | """ 392 | Test basic feedin calculation using pvlib and providing an optional 393 | PV system parameter. 394 | """ 395 | pvlib_pv_system["strings_per_inverter"] = 2 396 | test_module = Photovoltaic(**pvlib_pv_system) 397 | feedin = test_module.feedin(weather=pvlib_weather, location=(52, 13)) 398 | # power output is in this case limited by the inverter, which is why 399 | # power output with 2 strings is not twice as high as power output of 400 | # one string 401 | assert 250.0 == pytest.approx(feedin.values[0], 1e-5) 402 | 403 | def test_pvlib_feedin_with_optional_model_parameters( 404 | self, pvlib_pv_system, pvlib_weather 405 | ): 406 | """ 407 | Test basic feedin calculation using pvlib and providing an optional 408 | PV system parameter. 409 | """ 410 | pvlib_pv_system["strings_per_inverter"] = 2 411 | test_module = Photovoltaic(**pvlib_pv_system) 412 | feedin = test_module.feedin( 413 | weather=pvlib_weather, location=(52, 13), mode="dc" 414 | ) 415 | # power output is in this case limited by the inverter, which is why 416 | # power output with 2 strings is not twice as high as power output of 417 | # one string 418 | assert 298.27921 == pytest.approx(feedin.values[0], 1e-5) 419 | 420 | def test_pvlib_missing_powerplant_parameter(self, pvlib_pv_system): 421 | """ 422 | Test if initialization of powerplant fails in case of missing power 423 | plant parameter. 424 | """ 425 | del pvlib_pv_system["albedo"] 426 | msg = "The specified model 'pvlib' requires" 427 | with pytest.raises(AttributeError, match=msg): 428 | Photovoltaic(**pvlib_pv_system) 429 | 430 | 431 | class TestWindpowerlibSingleTurbine(Fixtures): 432 | """ 433 | Class to test WindpowerlibTurbine model. 434 | """ 435 | 436 | def test_windpowerlib_single_turbine_feedin( 437 | self, windpowerlib_turbine, windpowerlib_weather 438 | ): 439 | """ 440 | Test basic feedin calculation using windpowerlib single turbine. 441 | It is also tested if dictionary with turbine parameters remains the 442 | same to make sure it could be further used to calculate feed-in with 443 | a different model. 444 | """ 445 | test_copy = deepcopy(windpowerlib_turbine) 446 | test_turbine = WindPowerPlant(**windpowerlib_turbine) 447 | feedin = test_turbine.feedin(weather=windpowerlib_weather) 448 | assert 833050.32551 == pytest.approx(feedin.values[0], 1e-5) 449 | assert test_copy == windpowerlib_turbine 450 | 451 | def test_windpowerlib_single_turbine_feedin_with_optional_pp_parameter( 452 | self, windpowerlib_turbine, windpowerlib_weather 453 | ): 454 | """ 455 | Test basic feedin calculation using windpowerlib single turbine and 456 | using optional parameters for power plant and modelchain. 457 | """ 458 | windpowerlib_turbine["rotor_diameter"] = 82 459 | test_turbine = WindPowerPlant(**windpowerlib_turbine) 460 | feedin = test_turbine.feedin( 461 | weather=windpowerlib_weather, 462 | power_output_model="power_coefficient_curve", 463 | ) 464 | assert 847665.85209 == pytest.approx(feedin.values[0], 1e-5) 465 | 466 | def test_windpowerlib_missing_powerplant_parameter( 467 | self, windpowerlib_turbine 468 | ): 469 | """ 470 | Test if initialization of powerplant fails in case of missing power 471 | plant parameter. 472 | """ 473 | del windpowerlib_turbine["turbine_type"] 474 | msg = "The specified model 'windpowerlib_single_turbine' requires" 475 | with pytest.raises(AttributeError, match=msg): 476 | WindPowerPlant(**windpowerlib_turbine) 477 | 478 | 479 | class TestWindpowerlibCluster(Fixtures): 480 | """ 481 | Class to test WindpowerlibTurbineCluster model. 482 | """ 483 | 484 | def test_windpowerlib_windfarm_feedin( 485 | self, windpowerlib_farm, windpowerlib_weather 486 | ): 487 | """ 488 | Test basic feedin calculation using windpowerlib wind turbine cluster 489 | modelchain for a wind farm where wind turbine data is provided in a 490 | dictionary. 491 | It is also tested if dataframe with wind turbine fleet remains the 492 | same to make sure it could be further used to calculate feed-in with 493 | a different model. 494 | """ 495 | test_copy = deepcopy(windpowerlib_farm) 496 | farm = WindPowerPlant( 497 | **windpowerlib_farm, model=WindpowerlibTurbineCluster 498 | ) 499 | feedin = farm.feedin( 500 | weather=windpowerlib_weather, wake_losses_model=None 501 | ) 502 | assert 7658841.386277 == pytest.approx(feedin.values[0], 1e-5) 503 | assert_frame_equal( 504 | test_copy["wind_turbine_fleet"], 505 | windpowerlib_farm["wind_turbine_fleet"], 506 | ) 507 | 508 | @pytest.mark.skip(reason="We have to fix the circular import to use a" 509 | "feedinlib WindPowerPlant object in clusters.") 510 | def test_windpowerlib_windfarm_feedin_2( 511 | self, windpowerlib_farm_3, windpowerlib_weather 512 | ): 513 | """ 514 | Test basic feedin calculation using windpowerlib wind turbine cluster 515 | modelchain for a wind farm where wind turbines are provided as 516 | feedinlib WindPowerPlant objects. 517 | It is also tested if dataframe with wind turbine fleet remains the 518 | same to make sure it could be further used to calculate feed-in with 519 | a different model. 520 | """ 521 | test_copy = deepcopy(windpowerlib_farm_3) 522 | farm = WindPowerPlant( 523 | **windpowerlib_farm_3, model=WindpowerlibTurbineCluster 524 | ) 525 | feedin = farm.feedin( 526 | weather=windpowerlib_weather, wake_losses_model=None 527 | ) 528 | assert 7658841.386277 == pytest.approx(feedin.values[0], 1e-5) 529 | assert_frame_equal( 530 | test_copy["wind_turbine_fleet"], 531 | windpowerlib_farm_3["wind_turbine_fleet"], 532 | ) 533 | 534 | def test_windpowerlib_turbine_cluster_feedin( 535 | self, windpowerlib_turbine_cluster, windpowerlib_weather 536 | ): 537 | """ 538 | Test basic feedin calculation using windpowerlib wind turbine cluster 539 | modelchain for a wind turbine cluster. 540 | """ 541 | test_cluster = WindPowerPlant( 542 | **windpowerlib_turbine_cluster, model=WindpowerlibTurbineCluster 543 | ) 544 | feedin = test_cluster.feedin(weather=windpowerlib_weather) 545 | assert 7285008.02048 == pytest.approx(feedin.values[0], 1e-5) 546 | 547 | def test_windpowerlib_windfarm_feedin_with_optional_parameters( 548 | self, windpowerlib_farm, windpowerlib_weather 549 | ): 550 | """ 551 | Test basic feedin calculation using windpowerlib wind turbine cluster 552 | modelchain and supplying an optional power plant and modelchain 553 | parameter. 554 | """ 555 | 556 | # test optional parameter 557 | test_farm = windpowerlib_farm 558 | test_farm["efficiency"] = 0.9 559 | farm = WindPowerPlant(**test_farm, model=WindpowerlibTurbineCluster) 560 | feedin = farm.feedin( 561 | weather=windpowerlib_weather, 562 | wake_losses_model="wind_farm_efficiency", 563 | ) 564 | assert 6892957.24764 == pytest.approx(feedin.values[0], 1e-5) 565 | 566 | def test_windpowerlib_turbine_equals_windfarm( 567 | self, windpowerlib_turbine, windpowerlib_farm_2, windpowerlib_weather 568 | ): 569 | """ 570 | Test if wind turbine feedin calculation yields the same as wind farm 571 | calculation with one turbine. 572 | """ 573 | # turbine feedin 574 | test_turbine = WindPowerPlant(**windpowerlib_turbine) 575 | feedin = test_turbine.feedin(weather=windpowerlib_weather) 576 | # farm feedin 577 | test_farm = WindPowerPlant( 578 | **windpowerlib_farm_2, model=WindpowerlibTurbineCluster 579 | ) 580 | feedin_farm = test_farm.feedin( 581 | weather=windpowerlib_weather, wake_losses_model=None 582 | ) 583 | assert feedin.values[0] == pytest.approx(feedin_farm.values[0], 1e-5) 584 | 585 | def test_windpowerlib_windfarm_equals_cluster( 586 | self, windpowerlib_farm, windpowerlib_weather 587 | ): 588 | """ 589 | Test if windfarm feedin calculation yields the same as turbine cluster 590 | calculation with one wind farm. 591 | """ 592 | # farm feedin 593 | test_farm = WindPowerPlant( 594 | **windpowerlib_farm, model=WindpowerlibTurbineCluster 595 | ) 596 | feedin_farm = test_farm.feedin(weather=windpowerlib_weather) 597 | # turbine cluster 598 | test_cluster = {"wind_farms": [windpowerlib_farm]} 599 | test_cluster = WindPowerPlant( 600 | **test_cluster, model=WindpowerlibTurbineCluster 601 | ) 602 | feedin_cluster = test_cluster.feedin(weather=windpowerlib_weather) 603 | assert feedin_farm.values[0] == pytest.approx( 604 | feedin_cluster.values[0], 1e-5 605 | ) 606 | -------------------------------------------------------------------------------- /src/feedinlib/models/windpowerlib.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | """ 3 | Feed-in model class using windpowerlib. 4 | 5 | SPDX-FileCopyrightText: Birgit Schachler 6 | SPDX-FileCopyrightText: Uwe Krien 7 | SPDX-FileCopyrightText: Stephan Günther 8 | SPDX-FileCopyrightText: Stephen Bosch 9 | SPDX-FileCopyrightText: Patrik Schönfeldt 10 | 11 | SPDX-License-Identifier: MIT 12 | 13 | This module holds implementations of feed-in models using the python library 14 | windpowerlib to calculate wind power feed-in. 15 | """ 16 | 17 | from copy import deepcopy 18 | 19 | import pandas as pd 20 | from windpowerlib import ModelChain as WindpowerlibModelChain 21 | from windpowerlib import ( 22 | TurbineClusterModelChain as WindpowerlibClusterModelChain, 23 | ) 24 | from windpowerlib import WindFarm as WindpowerlibWindFarm 25 | from windpowerlib import WindTurbine as WindpowerlibWindTurbine 26 | from windpowerlib import WindTurbineCluster as WindpowerlibWindTurbineCluster 27 | 28 | from .base import WindpowerModelBase 29 | 30 | # from feedinlib import WindPowerPlant 31 | 32 | 33 | class WindpowerlibTurbine(WindpowerModelBase): 34 | r""" 35 | Model to determine the feed-in of a wind turbine using the windpowerlib. 36 | 37 | The windpowerlib [1]_ is a python library for simulating the performance of 38 | wind turbines and farms. For more information about the model check the 39 | documentation of the windpowerlib [2]_. 40 | 41 | Notes 42 | ------ 43 | In order to use this model various power plant and model parameters have to 44 | be provided. See :attr:`~.power_plant_requires` as well as 45 | :attr:`~.requires` for further information. Furthermore, the weather 46 | data used to calculate the feed-in has to have a certain format. See 47 | :meth:`~.feedin` for further information. 48 | 49 | References 50 | ---------- 51 | .. [1] `windpowerlib on github `_ 52 | .. [2] `windpowerlib documentation `_ 53 | 54 | See Also 55 | -------- 56 | :class:`~.models.Base` 57 | :class:`~.models.WindpowerModelBase` 58 | 59 | """ # noqa: E501 60 | 61 | def __init__(self, **kwargs): 62 | """ """ 63 | super().__init__(**kwargs) 64 | self.power_plant = None 65 | 66 | def __repr__(self): 67 | return "windpowerlib_single_turbine" 68 | 69 | @property 70 | def power_plant_requires(self): 71 | r""" 72 | The power plant parameters this model requires to calculate a feed-in. 73 | 74 | The required power plant parameters are: 75 | 76 | `hub_height`, `power_curve/power_coefficient_curve/turbine_type` 77 | 78 | hub_height (float) 79 | Hub height in m. 80 | 81 | See also :wind_turbine:`WindTurbine.hub_height ` in windpowerlib 83 | documentation. 84 | power_curve (:pandas:`pandas.DataFrame` or dict) 85 | DataFrame/dictionary with wind speeds in m/s and corresponding 86 | power curve value in W. 87 | 88 | See also :wind_turbine:`WindTurbine.power_curve ` in windpowerlib 90 | documentation. 91 | power_coefficient_curve (:pandas:`pandas.DataFrame` or dict) 92 | DataFrame/dictionary with wind speeds in m/s and corresponding 93 | power coefficient. 94 | 95 | See also :wind_turbine:`WindTurbine.power_coefficient_curve \ 96 | ` 97 | in windpowerlib documentation. 98 | turbine_type (str) 99 | Name of the wind turbine type as in the oedb turbine library. Use 100 | :func:`~.get_power_plant_data` with `dataset` = 101 | 'oedb_turbine_library' to get an overview of all provided turbines. 102 | See the data set metadata [3]_ for further information on provided 103 | parameters. 104 | 105 | References 106 | ---------- 107 | .. [3] `oedb wind turbine library `_ 108 | 109 | """ # noqa: E501 110 | required = [ 111 | "hub_height", 112 | ["power_curve", "power_coefficient_curve", "turbine_type"], 113 | ] 114 | if super().power_plant_requires is not None: 115 | required.extend(super().power_plant_requires) 116 | return required 117 | 118 | @property 119 | def requires(self): 120 | r""" 121 | The parameters this model requires to calculate a feed-in. 122 | 123 | This model does not require any additional model parameters. 124 | 125 | """ 126 | required = [] 127 | if super().requires is not None: 128 | required.extend(super().requires) 129 | return required 130 | 131 | @property 132 | def nominal_power_wind_power_plant(self): 133 | """ 134 | Nominal power of wind turbine in Watt. 135 | 136 | See :wind_turbine:`WindTurbine.nominal_power ` in windpowerlib for further 138 | information. 139 | 140 | """ 141 | if self.power_plant: 142 | return self.power_plant.nominal_power 143 | else: 144 | return None 145 | 146 | def _power_plant_requires_check(self, parameters): 147 | """ 148 | Function to check if all required power plant parameters are provided. 149 | 150 | Power plant parameters this model requires are specified in 151 | :attr:`~.power_plant_requires`. 152 | 153 | Parameters 154 | ----------- 155 | parameters : list(str) 156 | List of provided power plant parameters. 157 | 158 | """ 159 | for k in self.power_plant_requires: 160 | if not isinstance(k, list): 161 | if k not in parameters: 162 | raise AttributeError( 163 | "The specified model '{model}' requires power plant " 164 | "parameter '{k}' but it's not provided as an " 165 | "argument.".format(k=k, model=self) 166 | ) 167 | else: 168 | # in case one of several parameters can be provided 169 | if not list(filter(lambda x: x in parameters, k)): 170 | raise AttributeError( 171 | "The specified model '{model}' requires one of the " 172 | "following power plant parameters '{k}' but neither " 173 | "is provided as an argument.".format(k=k, model=self) 174 | ) 175 | 176 | def instantiate_turbine(self, **kwargs): 177 | """ 178 | Instantiates a :windpowerlib:`windpowerlib.WindTurbine \ 179 | ` object. 180 | 181 | Parameters 182 | ----------- 183 | **kwargs 184 | See `power_plant_parameters` parameter in :meth:`~.feedin` for more 185 | information. 186 | 187 | Returns 188 | -------- 189 | :windpowerlib:`windpowerlib.WindTurbine \ 190 | ` 191 | Wind turbine to calculate feed-in for. 192 | 193 | """ 194 | return WindpowerlibWindTurbine(**kwargs) 195 | 196 | def feedin(self, weather, power_plant_parameters, **kwargs): 197 | r""" 198 | Calculates power plant feed-in in Watt. 199 | 200 | This function uses the windpowerlib's :windpowerlib:`ModelChain \ 201 | ` to calculate the feed-in for the 202 | given weather time series and wind turbine. 203 | 204 | Parameters 205 | ---------- 206 | weather : :pandas:`pandas.DataFrame` 207 | Weather time series used to calculate feed-in. See `weather_df` 208 | parameter in windpowerlib's Modelchain :windpowerlib:`run_model \ 209 | ` method for 210 | more information on required variables, units, etc. 211 | power_plant_parameters : dict 212 | Dictionary with power plant specifications. Keys of the dictionary 213 | are the power plant parameter names, values of the dictionary hold 214 | the corresponding value. The dictionary must at least contain the 215 | required power plant parameters (see 216 | :attr:`~.power_plant_requires`) and may further contain optional 217 | power plant parameters (see :windpowerlib:`windpowerlib.\ 218 | WindTurbine `). 219 | **kwargs : 220 | Keyword arguments can be used to overwrite the windpowerlib's 221 | :windpowerlib:`ModelChain ` 222 | parameters. 223 | 224 | Returns 225 | ------- 226 | :pandas:`pandas.Series` 227 | Power plant feed-in time series in Watt. 228 | 229 | """ 230 | self.power_plant = self.instantiate_turbine(**power_plant_parameters) 231 | mc = WindpowerlibModelChain(self.power_plant, **kwargs) 232 | return mc.run_model(weather).power_output 233 | 234 | 235 | class WindpowerlibTurbineCluster(WindpowerModelBase): 236 | """ 237 | Model to determine the feed-in of a wind turbine cluster using the 238 | windpowerlib. 239 | 240 | The windpowerlib [1]_ is a python library for simulating the performance of 241 | wind turbines and farms. For more information about the model check the 242 | documentation of the windpowerlib [2]_. 243 | 244 | Notes 245 | ------ 246 | In order to use this model various power plant and model parameters have to 247 | be provided. See :attr:`~.power_plant_requires` as well as 248 | :attr:`~.requires` for further information. Furthermore, the weather 249 | data used to calculate the feed-in has to have a certain format. See 250 | :meth:`~.feedin` for further information. 251 | 252 | See Also 253 | -------- 254 | :class:`~.models.Base` 255 | :class:`~.models.WindpowerModelBase` 256 | 257 | References 258 | ---------- 259 | .. [1] `windpowerlib on github `_ 260 | .. [2] `windpowerlib documentation `_ 261 | 262 | """ # noqa: E501 263 | 264 | def __init__(self, **kwargs): 265 | """ """ 266 | super().__init__(**kwargs) 267 | self.power_plant = None 268 | 269 | def __repr__(self): 270 | return "WindpowerlibTurbineCluster" 271 | 272 | @property 273 | def power_plant_requires(self): 274 | r""" 275 | The power plant parameters this model requires to calculate a feed-in. 276 | 277 | The required power plant parameters are: 278 | 279 | `wind_turbine_fleet/wind_farms` 280 | 281 | The windpowerlib differentiates between wind farms as a group of wind 282 | turbines (of the same or different type) in the same location and 283 | wind turbine clusters as wind farms and turbines that are assigned the 284 | same weather data point to obtain weather data for feed-in calculations 285 | and can therefore be clustered to speed up calculations. 286 | The `WindpowerlibTurbineCluster` class can be used for both 287 | :windpowerlib:`windpowerlib.WindFarm ` and :windpowerlib:`windpowerlib.WindTurbineCluster \ 289 | ` calculations. 290 | To set up a :windpowerlib:`windpowerlib.WindFarm ` please provide a `wind_turbine_fleet` 292 | and to set up a :windpowerlib:`windpowerlib.WindTurbineCluster \ 293 | ` please 294 | provide a list of `wind_farms`. See below for further information. 295 | 296 | wind_turbine_fleet (:pandas:`pandas.DataFrame`) 297 | The wind turbine fleet specifies the turbine types and their 298 | corresponding number or total installed capacity in the wind farm. 299 | DataFrame must have columns 'wind_turbine' and either 300 | 'number_of_turbines' (number of wind turbines of the same turbine 301 | type in the wind farm, can be a float) or 'total_capacity' 302 | (installed capacity of wind turbines of the same turbine type in 303 | the wind farm in Watt). 304 | 305 | The wind turbine in column 'wind_turbine' can be provided as a 306 | :class:`~.powerplants.WindPowerPlant` object, a dictionary with 307 | power plant parameters (see 308 | :attr:`~.models.WindpowerlibTurbine.power_plant_requires` 309 | for required parameters) or a :windpowerlib:`windpowerlib.\ 310 | WindTurbine `. 311 | 312 | See also `wind_turbine_fleet` parameter of 313 | :windpowerlib:`windpowerlib.WindFarm `. 315 | 316 | The wind turbine fleet may also be provided as a list of 317 | :windpowerlib:`windpowerlib.WindTurbineGroup ` as described there. 319 | wind_farms (list(dict) or list(:windpowerlib:`windpowerlib.WindFarm `)) 320 | List of wind farms in cluster. Wind farms in the list can either 321 | be provided as :windpowerlib:`windpowerlib.WindFarm \ 322 | ` or as dictionaries 323 | where the keys of the dictionary are the wind farm parameter names 324 | and the values of the dictionary hold the corresponding value. 325 | The dictionary must at least contain a wind turbine fleet (see 326 | 'wind_turbine_fleet' parameter specifications above) and may 327 | further contain optional wind farm parameters (see 328 | :windpowerlib:`windpowerlib.WindFarm `). 330 | 331 | """ # noqa: E501 332 | required = ["wind_turbine_fleet", "wind_farms"] 333 | if super().power_plant_requires is not None: 334 | required.extend(super().power_plant_requires) 335 | return required 336 | 337 | @property 338 | def requires(self): 339 | r""" 340 | The parameters this model requires to calculate a feed-in. 341 | 342 | This model does not require any additional model parameters. 343 | 344 | """ 345 | required = [] 346 | if super().requires is not None: 347 | required.extend(super().requires) 348 | return required 349 | 350 | @property 351 | def nominal_power_wind_power_plant(self): 352 | """ 353 | Nominal power of wind turbine cluster in Watt. 354 | 355 | The nominal power is the sum of the nominal power of all turbines. 356 | See `nominal_power` of :windpowerlib:`windpowerlib.WindFarm \ 357 | ` or 358 | :windpowerlib:`windpowerlib.WindTurbineCluster \ 359 | ` for further 360 | information. 361 | 362 | """ 363 | if self.power_plant: 364 | return self.power_plant.nominal_power 365 | else: 366 | return None 367 | 368 | def _power_plant_requires_check(self, parameters): 369 | r""" 370 | Function to check if all required power plant parameters are provided. 371 | 372 | Power plant parameters this model requires are specified in 373 | :attr:`~.power_plant_requires`. 374 | 375 | Parameters 376 | ----------- 377 | parameters : list(str) 378 | List of provided power plant parameters. 379 | 380 | """ 381 | if not any([_ in parameters for _ in self.power_plant_requires]): 382 | raise KeyError( 383 | "The specified model '{model}' requires one of the following " 384 | "power plant parameters: {parameters}".format( 385 | model=self, parameters=self.power_plant_requires 386 | ) 387 | ) 388 | 389 | def instantiate_turbine(self, **kwargs): 390 | """ 391 | Instantiates a :windpowerlib:`windpowerlib.WindTurbine \ 392 | ` object. 393 | 394 | Parameters 395 | ----------- 396 | **kwargs 397 | Dictionary with wind turbine specifications. Keys of the dictionary 398 | are the power plant parameter names, values of the dictionary hold 399 | the corresponding value. The dictionary must at least contain the 400 | required turbine parameters (see 401 | :attr:`~.models.WindpowerlibTurbine.power_plant_requires`) and 402 | may further contain optional power plant parameters (see 403 | :windpowerlib:`windpowerlib.WindTurbine \ 404 | `). 405 | 406 | Returns 407 | -------- 408 | :windpowerlib:`windpowerlib.WindTurbine \ 409 | ` 410 | Wind turbine in wind farm or turbine cluster. 411 | 412 | """ 413 | power_plant = WindpowerlibWindTurbine(**kwargs) 414 | return power_plant 415 | 416 | def instantiate_windfarm(self, **kwargs): 417 | r""" 418 | Instantiates a :windpowerlib:`windpowerlib.WindFarm ` object. 420 | 421 | Parameters 422 | ---------- 423 | **kwargs 424 | Dictionary with wind farm specifications. Keys of the dictionary 425 | are the parameter names, values of the dictionary hold the 426 | corresponding value. The dictionary must at least contain a wind 427 | turbine fleet (see 'wind_turbine_fleet' specifications in 428 | :attr:`~.power_plant_requires`) and may further contain optional 429 | wind farm parameters (see :windpowerlib:`windpowerlib.WindFarm \ 430 | `). 431 | 432 | Returns 433 | -------- 434 | :windpowerlib:`windpowerlib.WindFarm ` 435 | 436 | """ 437 | # deepcopy turbine fleet to not alter original turbine fleet 438 | wind_turbine_fleet = deepcopy(kwargs.pop("wind_turbine_fleet")) 439 | 440 | # if turbine fleet is provided as list, it is assumed that list 441 | # contains WindTurbineGroups and WindFarm can be directly instantiated 442 | if isinstance(wind_turbine_fleet, list): 443 | return WindpowerlibWindFarm(wind_turbine_fleet, **kwargs) 444 | 445 | # if turbine fleet is provided as DataFrame wind turbines in 446 | # 'wind_turbine' column have to be converted to windpowerlib 447 | # WindTurbine object 448 | elif isinstance(wind_turbine_fleet, pd.DataFrame): 449 | for ix, row in wind_turbine_fleet.iterrows(): 450 | turbine = row["wind_turbine"] 451 | if not isinstance(turbine, WindpowerlibWindTurbine): 452 | # if isinstance( 453 | # turbine, WindPowerPlant 454 | # ): 455 | # turbine_data = turbine.parameters 456 | if isinstance(turbine, dict): 457 | turbine_data = turbine 458 | else: 459 | raise TypeError( 460 | "The WindpowerlibTurbineCluster model requires " 461 | "that wind turbines must either be provided as " 462 | "WindPowerPlant objects, windpowerlib.WindTurbine " 463 | "objects or as dictionary containing all turbine " 464 | "parameters required by the WindpowerlibTurbine " 465 | "model but type of `wind_turbine` " 466 | "is {}.".format(type(row["wind_turbine"])) 467 | ) 468 | # initialize WindpowerlibTurbine instead of directly 469 | # initializing windpowerlib.WindTurbine to check required 470 | # power plant parameters 471 | wind_turbine = WindpowerlibTurbine() 472 | wind_turbine._power_plant_requires_check( 473 | turbine_data.keys() 474 | ) 475 | wind_turbine_fleet.loc[ 476 | ix, "wind_turbine" 477 | ] = wind_turbine.instantiate_turbine(**turbine_data) 478 | kwargs["wind_turbine_fleet"] = wind_turbine_fleet 479 | return WindpowerlibWindFarm(**kwargs) 480 | else: 481 | raise TypeError( 482 | "The WindpowerlibTurbineCluster model requires that the " 483 | "`wind_turbine_fleet` parameter is provided as a list or " 484 | "pandas.DataFrame but type of `wind_turbine_fleet` is " 485 | "{}.".format(type(wind_turbine_fleet)) 486 | ) 487 | 488 | def instantiate_turbine_cluster(self, **kwargs): 489 | r""" 490 | Instantiates a :windpowerlib:`windpowerlib.WindTurbineCluster \ 491 | ` object. 492 | 493 | Parameters 494 | ---------- 495 | **kwargs 496 | Dictionary with turbine cluster specifications. Keys of the 497 | dictionary are the parameter names, values of the dictionary hold 498 | the corresponding value. The dictionary must at least contain a 499 | list of wind farms (see 'wind_farms' specifications in 500 | :attr:`~.power_plant_requires`) and may further contain optional 501 | wind turbine cluster parameters (see 502 | :windpowerlib:`windpowerlib.WindTurbineCluster \ 503 | `). 504 | 505 | Returns 506 | -------- 507 | :windpowerlib:`windpowerlib.WindTurbineCluster ` 508 | 509 | """ # noqa: E501 510 | wind_farm_list = [] 511 | for wind_farm in kwargs.pop("wind_farms"): 512 | if not isinstance(wind_farm, WindpowerlibWindFarm): 513 | wind_farm_list.append(self.instantiate_windfarm(**wind_farm)) 514 | else: 515 | wind_farm_list.append(wind_farm) 516 | kwargs["wind_farms"] = wind_farm_list 517 | return WindpowerlibWindTurbineCluster(**kwargs) 518 | 519 | def feedin(self, weather, power_plant_parameters, **kwargs): 520 | r""" 521 | Calculates power plant feed-in in Watt. 522 | 523 | This function uses the windpowerlib's 524 | :windpowerlib:`TurbineClusterModelChain ` to calculate the 526 | feed-in for the given weather time series and wind farm or cluster. 527 | 528 | Parameters 529 | ---------- 530 | weather : :pandas:`pandas.DataFrame` 531 | Weather time series used to calculate feed-in. See `weather_df` 532 | parameter in windpowerlib's TurbineClusterModelChain 533 | :windpowerlib:`run_model ` method for more information on 535 | required variables, units, etc. 536 | power_plant_parameters : dict 537 | Dictionary with either wind farm or wind turbine cluster 538 | specifications. For more information on wind farm parameters see 539 | `kwargs` in :meth:`~.instantiate_windfarm`. 540 | For information on turbine cluster parameters see `kwargs` 541 | in :meth:`~.instantiate_turbine_cluster`. 542 | **kwargs : 543 | Keyword arguments can be used to overwrite the windpowerlib's 544 | :windpowerlib:`TurbineClusterModelChain ` parameters. 546 | 547 | Returns 548 | ------- 549 | :pandas:`pandas.Series` 550 | Power plant feed-in time series in Watt. 551 | 552 | """ # noqa: E501 553 | # wind farm calculation 554 | if "wind_turbine_fleet" in power_plant_parameters.keys(): 555 | self.power_plant = self.instantiate_windfarm( 556 | **power_plant_parameters 557 | ) 558 | # wind cluster calculation 559 | else: 560 | self.power_plant = self.instantiate_turbine_cluster( 561 | **power_plant_parameters 562 | ) 563 | mc = WindpowerlibClusterModelChain(self.power_plant, **kwargs) 564 | return mc.run_model(weather).power_output 565 | --------------------------------------------------------------------------------