diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 717b01d7a..5ddb6b69c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,82 +1,82 @@ ---- -name: Bug report -about: Report a bug to help us improve -title: 'Bug report' -labels: "Type: Bug" ---- - - - -# Add meaningful title here - - -## How to reproduce - - -## Relevant output - - -## HOPP version - - -## System Information - - - OS: - - Python version: - - Library versions - - Results of `pip freeze`, for example - - FLORIS - - matplotlib - - NREL-PySAM - - numpy - - numexpr - - orbit-nrel - - pandas - - scipy - - shapely +--- +name: Bug report +about: Report a bug to help us improve +title: 'Bug report' +labels: "Type: Bug" +--- + + + +# Add meaningful title here + + +## How to reproduce + + +## Relevant output + + +## HOPP version + + +## System Information + + - OS: + - Python version: + - Library versions + - Results of `pip freeze`, for example + - FLORIS + - matplotlib + - NREL-PySAM + - numpy + - numexpr + - orbit-nrel + - pandas + - scipy + - shapely diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml index 3ef98b52d..99be18d5a 100644 --- a/.github/ISSUE_TEMPLATE/config.yaml +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -1,5 +1,5 @@ -blank_issues_enabled: false -contact_links: - - name: Usage question - url: https://github.com/NREL/hopp/discussions - about: Have any questions about using HOPP? Post in Discussions to engage with the NREL team and HOPP community. +blank_issues_enabled: false +contact_links: + - name: Usage question + url: https://github.com/NREL/hopp/discussions + about: Have any questions about using HOPP? Post in Discussions to engage with the NREL team and HOPP community. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 221ab53c5..7580e338b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,57 +1,57 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: 'Feature request' -labels: 'Type: Enhancement' ---- - - - -# Add meaningful title here - - -## Proposed solution - - -## Alternatives considered - - -## Additional context - +--- +name: Feature request +about: Suggest an idea for this project +title: 'Feature request' +labels: 'Type: Enhancement' +--- + + + +# Add meaningful title here + + +## Proposed solution + + +## Alternatives considered + + +## Additional context + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8000bab77..fbab9dabb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,76 +1,76 @@ - - - - -# Add meaningful title here - -Describe your feature here. - -## Related issue - - -## Impacted areas of the software - - -## Additional supporting information - - -## Test results, if applicable - - - + + + + +# Add meaningful title here + +Describe your feature here. + +## Related issue + + +## Impacted areas of the software + + +## Additional supporting information + + +## Test results, if applicable + + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07d64536a..7291c50d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,16 +6,22 @@ jobs: build: runs-on: ubuntu-latest + defaults: + run: + shell: bash -el {0} strategy: matrix: python-version: ["3.10", "3.11"] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + - uses: actions/checkout@v4 + - name: Set up Miniconda Python ${{ matrix.python-version }} + uses: conda-incubator/setup-miniconda@v3 with: + auto-update-conda: true python-version: ${{ matrix.python-version }} + channels: conda-forge + activate-environment: hopp-test-${{ matrix.python-version }} - name: Install dependencies env: SKLEARN_ALLOW_DEPRECATED_SKLEARN_PACKAGE_INSTALL: True @@ -28,9 +34,18 @@ jobs: touch .env # echo NREL_API_KEY=${{ secrets.NREL_API_KEY }} >> .env # cat .env + - name: Save environment build details + run: | + mkdir ~/artifacts + conda env export --file ~/artifacts/environment.yml + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.python-version }}-environment + path: ~/artifacts/environment.yml - name: Run tests run: | - PYTHONPATH=. pytest tests + pytest tests - name: Lint with flake8 run: | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 1cbb7fe18..15a48a4e3 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -30,4 +30,4 @@ python: - method: pip path: . extra_requirements: - - develop + - develop \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c0207b7e..c958d08ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,50 +1,50 @@ -# Contributing - -We welcome contributions in the form of bug reports, bug fixes, improvements to the documentation, ideas for enhancements (or the enhancements themselves!). - -You can find a [list of current issues](https://github.com/NREL/HOPP/issues) in the project's GitHub repo. Feel free to tackle any existing bugs or enhancement ideas by submitting a [pull request](https://github.com/NREL/HOPP/pulls). - -## Bug Reports - - * Please include a short (but detailed) Python snippet or explanation for reproducing the problem. Attach or include a link to any input files that will be needed to reproduce the error. - * Explain the behavior you expected, and how what you got differed. - -## Pull Requests - - * Please reference relevant GitHub issues in your commit message using `GH123` or `#123`. - * Changes should be [PEP8](http://www.python.org/dev/peps/pep-0008/) compatible. - * Keep style fixes to a separate commit to make your pull request more readable. - * Docstrings are required and should follow the [Google style](https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html). - * When you start working on a pull request, start by creating a new branch pointing at the latest commit on [main](https://github.com/NREL/HOPP). - * The HOPP copyright policy is detailed in the [`LICENSE`](https://github.com/NREL/HOPP/blob/main/LICENSE). - -## Documentation - -All newly introduced code should be documented in Google format as described in the previous section. To generate the docs: - -``` -cd docs -make html -``` - -Then open the docs in your browser: - -``` -open _build/html/index.html -``` - -## Tests - -The test suite can be run using `pytest tests/hopp`. Individual test files can be run by specifying them: - -``` -pytest tests/hopp/test_hybrid.py -``` - -and individual tests can be run within those files - -``` -pytest tests/hopp/test_hybrid.py::test_hybrid_wind_only -``` - -When you push to your fork, or open a PR, your tests will be run against the [Continuous Integration (CI)](https://github.com/NREL/HOPP/actions) suite. This will start a build that runs all tests on your branch against multiple Python versions, and will also test documentation builds. +# Contributing + +We welcome contributions in the form of bug reports, bug fixes, improvements to the documentation, ideas for enhancements (or the enhancements themselves!). + +You can find a [list of current issues](https://github.com/NREL/HOPP/issues) in the project's GitHub repo. Feel free to tackle any existing bugs or enhancement ideas by submitting a [pull request](https://github.com/NREL/HOPP/pulls). + +## Bug Reports + + * Please include a short (but detailed) Python snippet or explanation for reproducing the problem. Attach or include a link to any input files that will be needed to reproduce the error. + * Explain the behavior you expected, and how what you got differed. + +## Pull Requests + + * Please reference relevant GitHub issues in your commit message using `GH123` or `#123`. + * Changes should be [PEP8](http://www.python.org/dev/peps/pep-0008/) compatible. + * Keep style fixes to a separate commit to make your pull request more readable. + * Docstrings are required and should follow the [Google style](https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html). + * When you start working on a pull request, start by creating a new branch pointing at the latest commit on [main](https://github.com/NREL/HOPP). + * The HOPP copyright policy is detailed in the [`LICENSE`](https://github.com/NREL/HOPP/blob/main/LICENSE). + +## Documentation + +All newly introduced code should be documented in Google format as described in the previous section. To generate the docs: + +``` +cd docs +make html +``` + +Then open the docs in your browser: + +``` +open _build/html/index.html +``` + +## Tests + +The test suite can be run using `pytest tests/hopp`. Individual test files can be run by specifying them: + +``` +pytest tests/hopp/test_hybrid.py +``` + +and individual tests can be run within those files + +``` +pytest tests/hopp/test_hybrid.py::test_hybrid_wind_only +``` + +When you push to your fork, or open a PR, your tests will be run against the [Continuous Integration (CI)](https://github.com/NREL/HOPP/actions) suite. This will start a build that runs all tests on your branch against multiple Python versions, and will also test documentation builds. diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml index a892e0330..ae1e00a04 100644 --- a/conda.recipe/meta.yaml +++ b/conda.recipe/meta.yaml @@ -1,57 +1,57 @@ -package: - name: hopp - version: {{ environ.get('GIT_DESCRIBE_TAG','').replace('v', '', 1) }} - -source: - git_url: ../ - -build: - number: 0 - noarch: python - script: python setup.py install --single-version-externally-managed --record=record.txt - -requirements: - host: - - python - - pip - - setuptools - - matplotlib - - nrel-pysam>=2.1.4 - - numpy>=1.16 - - pandas - - pillow - - pvmismatch - - pysolar - - python-dotenv - - pytz - - requests - - scipy - - shapely - - timezonefinder - - urllib3 - run: - - python - - pip - - matplotlib - - nrel-pysam>=2.1.4 - - {{ pin_compatible('numpy') }} - - pandas - - pillow - - pvmismatch - - pysolar - - python-dotenv - - pytz - - requests - - scipy - - shapely - - timezonefinder - - urllib3 - run-constrained: - - global_land_mask - -about: - home: "https://github.com/NREL/HOPP" - license: BSD 3-Clause - summary: "Hybrid Systems Optimization and Performance Platform" - doc_url: "https://www.nrel.gov/wind/hybrid-energy-systems-research.html" - dev_url: "https://github.com/NREL/HOPP" +package: + name: hopp + version: {{ environ.get('GIT_DESCRIBE_TAG','').replace('v', '', 1) }} + +source: + git_url: ../ + +build: + number: 0 + noarch: python + script: python setup.py install --single-version-externally-managed --record=record.txt + +requirements: + host: + - python + - pip + - setuptools + - matplotlib + - nrel-pysam>=2.1.4 + - numpy>=1.16 + - pandas + - pillow + - pvmismatch + - pysolar + - python-dotenv + - pytz + - requests + - scipy + - shapely + - timezonefinder + - urllib3 + run: + - python + - pip + - matplotlib + - nrel-pysam>=2.1.4 + - {{ pin_compatible('numpy') }} + - pandas + - pillow + - pvmismatch + - pysolar + - python-dotenv + - pytz + - requests + - scipy + - shapely + - timezonefinder + - urllib3 + run-constrained: + - global_land_mask + +about: + home: "https://github.com/NREL/HOPP" + license: BSD 3-Clause + summary: "Hybrid Systems Optimization and Performance Platform" + doc_url: "https://www.nrel.gov/wind/hybrid-energy-systems-research.html" + dev_url: "https://github.com/NREL/HOPP" diff --git a/conda_build.sh b/conda_build.sh index cc3a973cf..7855e78f9 100644 --- a/conda_build.sh +++ b/conda_build.sh @@ -1,10 +1,10 @@ -#!/bin/bash - -set -e - -conda build conda.recipe/ -c nrel -c conda-forge -c sunpower - -anaconda upload -u nrel $(conda build conda.recipe/ -c nrel -c conda-forge -c sunpower --output) - -echo "Building and uploading conda package done!" - +#!/bin/bash + +set -e + +conda build conda.recipe/ -c nrel -c conda-forge -c sunpower + +anaconda upload -u nrel $(conda build conda.recipe/ -c nrel -c conda-forge -c sunpower --output) + +echo "Building and uploading conda package done!" + diff --git a/hopp/simulation/hopp_interface.py b/hopp/simulation/hopp_interface.py index ba565f722..88df556cf 100644 --- a/hopp/simulation/hopp_interface.py +++ b/hopp/simulation/hopp_interface.py @@ -65,10 +65,19 @@ def parse_output(self): self.hybrid_installed_cost = self.hopp.system.grid.total_installed_cost def print_output(self): - print("Wind Installed Cost: {}".format(self.wind_installed_cost)) - print("Solar Installed Cost: {}".format(self.solar_installed_cost)) - print("Hybrid Installed Cost: {}".format(self.hybrid_installed_cost)) + print("Wind Installed Cost: {}".format(self.system.wind.total_installed_cost)) + print("Solar Installed Cost: {}".format(self.system.pv.total_installed_cost)) + print("Wave Installed Cost: {}".format(self.system.wave.total_installed_cost)) + print("Battery Installed Cost: {}".format(self.system.battery.total_installed_cost)) + print("Hybrid Installed Cost: {}".format(self.system.grid.total_installed_cost)) print("Wind NPV: {}".format(self.hopp.system.net_present_values.wind)) print("Solar NPV: {}".format(self.hopp.system.net_present_values.pv)) - print("Hybrid NPV: {}".format(self.hopp.system.net_present_values.hybrid)) - print("Wind + Solar Expected NPV: {}".format(self.wind_plus_solar_npv)) + print("Wave NPV: {}".format(self.hopp.system.net_present_values.wave)) + print("Battery NPV: {}".format(self.hopp.system.net_present_values.battery)) + print("Wave NPV: {}".format(self.hopp.system.net_present_values.hybrid*1E-2)) + print("Wind LCOE (USD/kWh): {}".format(self.hopp.system.lcoe_nom.wind*1E-2)) + print("Solar LCOE (USD/kWh): {}".format(self.hopp.system.lcoe_nom.pv*1E-2)) + print("Wave LCOE (USD/kWh): {}".format(self.hopp.system.lcoe_nom.wave*1E-2)) + print("Battery LCOE (USD/kWh): {}".format(self.hopp.system.lcoe_nom.battery*1E-2)) + print("Hybrid LCOE (USD/kWh): {}".format(self.hopp.system.lcoe_nom.hybrid*1E-2)) + # print("Wind + Solar Expected NPV: {}".format(self.wind_plus_solar_npv)) diff --git a/hopp/simulation/hybrid_simulation.py b/hopp/simulation/hybrid_simulation.py index 446c0cfe4..4a59dabd3 100644 --- a/hopp/simulation/hybrid_simulation.py +++ b/hopp/simulation/hybrid_simulation.py @@ -374,6 +374,7 @@ def set_om_costs(self, pv_om_per_kw=None, wind_om_per_kw=None, if self.battery: if battery_om_per_kw: self.battery.om_capacity = battery_om_per_kw + self.battery.om_batt_capacity_cost = battery_om_per_kw if battery_om_per_mwh: self.battery.om_production = battery_om_per_mwh @@ -903,7 +904,7 @@ def _aggregate_financial_output(self, name, start_index=None, end_index=None) -> out = self.outputs_factory.create() for k, v in self.technologies.items(): if k in self.sim_options.keys(): - if 'skip_financial' in self.sim_options[k].keys(): + if 'skip_financial' in self.sim_options[k].keys() and self.sim_options[k]['skip_financial']: continue val = getattr(v, name) if start_index and end_index: diff --git a/hopp/simulation/technologies/battery/battery.py b/hopp/simulation/technologies/battery/battery.py index ea70801b7..12945da7e 100644 --- a/hopp/simulation/technologies/battery/battery.py +++ b/hopp/simulation/technologies/battery/battery.py @@ -98,7 +98,8 @@ class BatteryConfig(BaseClass): minimum_SOC: float = field(default=10, validator=range_val(0, 100)) maximum_SOC: float = field(default=90, validator=range_val(0, 100)) initial_SOC: float = field(default=10, validator=range_val(0, 100)) - fin_model: Optional[Union[dict, FinancialModelType]] = field(default=None) + fin_model: Optional[Union[str, dict, FinancialModelType]] = field(default=None) + name: str = field(default="Battery") @define class Battery(PowerSource): @@ -124,7 +125,7 @@ def __attrs_post_init__(self): system_model = BatteryModel.default(self.config.chemistry) if isinstance(self.config.fin_model, dict): - financial_model = CustomFinancialModel(self.config.fin_model) + financial_model = CustomFinancialModel(self.config.fin_model, name=self.config.name) else: financial_model = self.config.fin_model diff --git a/hopp/simulation/technologies/battery/battery_stateless.py b/hopp/simulation/technologies/battery/battery_stateless.py index d16986ce3..76e3ffcc8 100644 --- a/hopp/simulation/technologies/battery/battery_stateless.py +++ b/hopp/simulation/technologies/battery/battery_stateless.py @@ -3,7 +3,8 @@ from attrs import define, field -from hopp.simulation.technologies.financial.custom_financial_model import CustomFinancialModel +from hopp.simulation.technologies.financial import CustomFinancialModel +from hopp.simulation.technologies.financial import FinancialModelType from hopp.simulation.technologies.sites import SiteInfo from hopp.simulation.technologies.power_source import PowerSource from hopp.utilities.log import hybrid_logger as logger @@ -63,7 +64,8 @@ class BatteryStatelessConfig(BaseClass): minimum_SOC: float = field(default=10, validator=range_val(0, 100)) maximum_SOC: float = field(default=90, validator=range_val(0, 100)) initial_SOC: float = field(default=10, validator=range_val(0, 100)) - fin_model: Union[dict, CustomFinancialModel] = field(default=None) + fin_model: Optional[Union[str, dict, FinancialModelType]] = field(default=None) + name: str = field(default="BatteryStateless") @define @@ -95,7 +97,7 @@ def __attrs_post_init__(self): system_model = self if isinstance(self.config.fin_model, dict): - financial_model = CustomFinancialModel(self.config.fin_model) + financial_model = CustomFinancialModel(self.config.fin_model, name=self.config.name) else: financial_model = self.config.fin_model diff --git a/hopp/simulation/technologies/csp/csp_plant.py b/hopp/simulation/technologies/csp/csp_plant.py index f49a44914..713fac0c1 100644 --- a/hopp/simulation/technologies/csp/csp_plant.py +++ b/hopp/simulation/technologies/csp/csp_plant.py @@ -125,7 +125,7 @@ def __attrs_post_init__(self): raise AttributeError("Financial model must be set in `config.fin_model`") if isinstance(self.config.fin_model, dict): - financial_model = CustomFinancialModel(self.config.fin_model) + financial_model = CustomFinancialModel(self.config.fin_model, name=self.config.name) else: financial_model = self.config.fin_model diff --git a/hopp/simulation/technologies/dispatch/hybrid_dispatch_builder_solver.py b/hopp/simulation/technologies/dispatch/hybrid_dispatch_builder_solver.py index e115d1380..eabac4fc3 100644 --- a/hopp/simulation/technologies/dispatch/hybrid_dispatch_builder_solver.py +++ b/hopp/simulation/technologies/dispatch/hybrid_dispatch_builder_solver.py @@ -682,6 +682,8 @@ def battery_heuristic(self): tot_gen += self.power_sources[power_source].dispatch.available_generation + tot_gen = tot_gen.tolist() + grid_limit = self.power_sources["grid"].dispatch.generation_transmission_limit if "one_cycle" in self.options.battery_dispatch: diff --git a/hopp/simulation/technologies/financial/custom_financial_model.py b/hopp/simulation/technologies/financial/custom_financial_model.py index 5295998a2..2ce59c6c1 100644 --- a/hopp/simulation/technologies/financial/custom_financial_model.py +++ b/hopp/simulation/technologies/financial/custom_financial_model.py @@ -1,11 +1,11 @@ -from attrs import define, field +from attrs import define, field, validators from dataclasses import dataclass, asdict import inspect from typing import Sequence, List import numpy as np from hopp.tools.utils import flatten_dict, equal from hopp.simulation.base import BaseClass - +import ProFAST @dataclass class FinancialData(BaseClass): @@ -67,19 +67,73 @@ class SystemCosts(FinancialData): @define class Revenue(FinancialData): - ppa_price_input: float = field(default=None) - ppa_escalation: float = field(default=1) + """ + Represents the revenue parameters. + + Attributes: + ppa_price_input (list): List of PPA prices in cents per kWh. Can be hourly or a single value to set flat rate for a year. + ppa_escalation (float): Annual escalation rate of the PPA price in percentage (default is 1%). + ppa_multiplier_model (float): Multiplier model applied to adjust the PPA revenue. + dispatch_factors_ts (Sequence): + + Args: + FinancialData (class): Parent class representing base financial data. + """ + ppa_price_input: list = field(default=None) # cents/kWh + ppa_escalation: float = field(default=1) # percent (%) ppa_multiplier_model: float = field(default=None) dispatch_factors_ts: Sequence = field(default=(0,)) @define class FinancialParameters(FinancialData): + """ + Represents a set of financial parameters. + + This class extends the `FinancialData` class and is designed to support financial analyses with various parameters + related to costs, tax rates, debt, and other financial metrics, specifically for use in ProFAST and the CustomFinancialModel + (not all parameters correspond directly to pySAM). + + Attributes: + construction_financing_cost (float): Cost of financing for construction. + analysis_period (float): Number of years the financial analysis is to be conducted. + inflation_rate (float): Annual inflation rate (%). + real_discount_rate (float): Discount rate adjusted for inflation (%). + federal_tax_rate (float): Federal income tax rate (%). + state_tax_rate (float): State income tax rate (%). + property_tax_rate (float): Annual property tax rate (%). + insurance_rate (float): Annual insurance rate (%). + debt_percent (float): Percentage of the project financed by debt. + term_int_rate (float): Interest rate on the debt (%). + months_working_reserve (float): Number of months of working capital reserve. + analysis_start_year (int): Start year of the financial analysis (ProFAST only, not in pySAM). + installation_months (int): Duration in months for the installation process (ProFAST only, not in pySAM). + sales_tax_rate_state (float): State sales tax rate (%) (ProFAST only, not in pySAM). + admin_expense_percent_of_sales (float): Administrative expenses as a percentage of sales (%) (ProFAST only, not in pySAM). + capital_gains_tax_rate (float): Capital gains tax rate (%) (ProFAST only, not in pySAM). + debt_type (str): Type of debt financing used; options are "Revolving debt" or "One time loan" (ProFAST only, not in pySAM). + depreciation_method (str): Depreciation method used for tax purposes; options are "MACRS" or "Straight line" (ProFAST only, not in pySAM because pySAM handles depreciation options differently). + depreciation_period (int): Duration, in years, over which assets are depreciated (ProFAST only, not in pySAM - handled differently). + """ construction_financing_cost: float = field(default=None) analysis_period: float = field(default=None) inflation_rate: float = field(default=None) real_discount_rate: float = field(default=None) - + federal_tax_rate: float = field(default=None) + state_tax_rate: float = field(default=None) + property_tax_rate: float = field(default=None) + insurance_rate: float = field(default=None) + debt_percent: float = field(default=None) + term_int_rate: float = field(default=None) + months_working_reserve: float = field(default=None) + analysis_start_year: int = field(default=None) # ProFAST only, no corresponding parameter in pySAM + installation_months: int = field(default=None) # ProFAST only, no corresponding parameter in pySAM + sales_tax_rate_state: float = field(default=None) # ProFAST only, no corresponding parameter in pySAM + admin_expense_percent_of_sales: float = field(default=None) # ProFAST only, no corresponding parameter in pySAM + capital_gains_tax_rate: float = field(default=None) # ProFAST only, no corresponding parameter in pySAM + debt_type: str = field(default=None, validator=validators.in_(["Revolving debt", "One time loan"])) # ProFAST only, no corresponding parameter in pySAM + depreciation_method: str = field(default=None, validator=validators.in_(["MACRS", "Straight line"])) # ProFAST only, no corresponding parameter in pySAM - handled differently + depreciation_period: int = field(default=None) # ProFAST only, no corresponding parameter in pySAM - handled differently @define class Outputs(FinancialData): @@ -116,12 +170,11 @@ class Outputs(FinancialData): project_return_aftertax_npv: float=None cf_project_return_aftertax: Sequence=(0,) - @define class SystemOutput(FinancialData): gen: Sequence = field(default=(0,)) system_capacity: float= field(default=None) - annual_energy: float= field(default=None) + annual_energy_kwh: float= field(default=None) degradation: Sequence= field(default=(0,)) system_pre_curtailment_kwac: float= field(default=None) annual_energy_pre_curtailment_ac: float= field(default=None) @@ -147,7 +200,7 @@ class CustomFinancialModel(): :param fin_config: dictionary of financial parameters """ def __init__(self, - fin_config: dict) -> None: + fin_config: dict, name: str) -> None: # super().__init__(fname, lname) # Input parameters @@ -158,6 +211,7 @@ def __init__(self, self.battery_total_cost_lcos = None # not currently used but referenced self.system_use_lifetime_output = 0 # Lifetime self.cp_capacity_credit_percent = [0] # CapacityPayments + self.name = "UnnamedFinancailModel" # Input parameters within dataclasses if 'battery_system' in fin_config: @@ -176,6 +230,7 @@ def __init__(self, self.Revenue: Revenue = Revenue.from_dict(fin_config['revenue']) else: self.Revenue: Revenue = Revenue() + if 'financial_parameters' in fin_config: self.FinancialParameters: FinancialParameters = FinancialParameters.from_dict( fin_config['financial_parameters'] @@ -220,6 +275,8 @@ def set_financial_inputs(self, system_model=None): def execute(self, n=0): self.set_financial_inputs() # update inputs from system model + + npv = self.npv( rate=self.nominal_discount_rate( inflation_rate=self.value('inflation_rate'), @@ -228,8 +285,143 @@ def execute(self, n=0): net_cash_flow=self.net_cash_flow(self.value('analysis_period')) ) self.value('project_return_aftertax_npv', npv) + + # TODO since we are using ProFAST for LCOE, I think it would make sense to use ProFAST for all other metrics as well + + pf_real = self.setup_profast(gen_inflation=0.0) + sol_real = pf_real.solve_price() + lcoe_real = sol_real['price'] + + pf_nominal = self.setup_profast(gen_inflation=self.value('inflation_rate')/100.0) + sol_nominal = pf_nominal.solve_price() + lcoe_nominal = sol_nominal['price'] + + usd_per_kwh_to_cents_per_kwh = 100 + self.value('levelized_cost_of_energy_real', lcoe_real*usd_per_kwh_to_cents_per_kwh) + self.value('levelized_cost_of_energy_nominal', lcoe_nominal*usd_per_kwh_to_cents_per_kwh) + return + + def setup_profast(self, gen_inflation) -> ProFAST.ProFAST: + """This method sets up a cash-flow financial model based on the input financial parameters. + + Args: + gen_inflation (float): inflation is left as an input and should be a float between 0 and 1, not a percentage. + Leaving inflation as an input to the method allows for easily setting up a ProFAST model for either real or nominal calculations. + + Returns: + ProFAST.ProFAST: an instance of the ProFAST class set up according to the input financial parameters + """ + + nominal_discount_rate = self.nominal_discount_rate( + inflation_rate=self.value('inflation_rate'), + real_discount_rate=self.value('real_discount_rate') + ) / 100 + + pf = ProFAST.ProFAST() + pf.set_params( + "commodity", + { + "name": "Electricity", + "unit": "kWh", + "initial price": 10, + "escalation": gen_inflation, + }, + ) + + if "Battery" in self.name: + pf.set_params( + "capacity", + max([1E-6, self.value("batt_annual_discharge_energy")[0]/365.0]), + ) # kWh/day + else: + pf.set_params( + "capacity", + max([1E-6, self.value("annual_energy_kwh")/365.0]), + ) # kWh/day + + pf.set_params("maintenance", {"value": self.o_and_m_cost(), "escalation": gen_inflation}) + + pf.set_params( + "analysis start year", self.value('analysis_start_year'), # no explicit year in single owner, # Add financial analysis start year + ) + pf.set_params( + "operating life", self.value('analysis_period') + ) + pf.set_params( + "installation months", + self.value('installation_months'), # Add installation time to yaml default=0 + ) + pf.set_params( + "installation cost", + { + "value": 0, + "depr type": "Straight line", + "depr period": 4, + "depreciable": False, + }, + ) + pf.set_params("demand rampup", 0) + pf.set_params("long term utilization", 1) # TODO should use utilization + pf.set_params("credit card fees", 0) + pf.set_params( + "sales tax", self.value('sales_tax_rate_state')/100.0 + ) + pf.set_params("license and permit", {"value": 00, "escalation": gen_inflation}) + pf.set_params("rent", {"value": 0, "escalation": gen_inflation}) + # TODO how to handle property tax and insurance for fully offshore? + pf.set_params( + "property tax and insurance", + self.value('property_tax_rate')/100.0 + self.value('insurance_rate')/100.0, + ) + pf.set_params( + "admin expense", + self.value("admin_expense_percent_of_sales")/100.0, + ) + pf.set_params( + "total income tax rate", + self.value('federal_tax_rate')/100.0 + self.value('state_tax_rate')/100.0, + ) + pf.set_params( + "capital gains tax rate", + self.value('capital_gains_tax_rate')/100.0, + ) + pf.set_params("sell undepreciated cap", True) + pf.set_params("tax losses monetized", True) + pf.set_params("general inflation rate", gen_inflation) + pf.set_params( + "leverage after tax nominal discount rate", + nominal_discount_rate, + ) + + pf.set_params( + "debt equity ratio of initial financing", + ( + self.value('debt_percent') + / (100 - self.value('debt_percent')) + ), + ) + + pf.set_params("debt type", self.value('debt_type')) + pf.set_params( + "debt interest rate", + self.value('term_int_rate')/100.0, + ) + pf.set_params( + "cash onhand", self.value('months_working_reserve') + ) + + # ----------------------------------- Add capital and fixed items to ProFAST ---------------- + pf.add_capital_item( + name="Total installed cost", + cost=self.value('total_installed_cost'), + depr_type=self.value('depreciation_method'), + depr_period=self.value('depreciation_period'), + refurb=[0], + ) + + return pf @staticmethod def npv(rate: float, net_cash_flow: List[float]): @@ -299,7 +491,7 @@ def net_cash_flow(self, project_life=25): ( - self.cf_operating_expenses[i] - self.cf_utility_bill[i] - + self.value('annual_energy') + + self.value('annual_energy_kwh') * degrad_fraction * self.value('ppa_price_input')[0] * (1 + self.value('ppa_escalation') / 100)**(year - 1) @@ -315,7 +507,7 @@ def o_and_m_cost(self): return self.value('om_fixed')[0] \ + self.value('om_capacity')[0] * self.value('system_capacity') \ - + self.value('om_production')[0] * self.value('annual_energy') * 1e-3 + + self.value('om_production')[0] * self.value('annual_energy_kwh') * 1e-3 def value(self, var_name, var_value=None): attr_obj = None @@ -390,7 +582,7 @@ def export_battery_values(self): } @property - def annual_energy(self) -> float: + def annual_energy_kwh(self) -> float: return self.value('annual_energy_pre_curtailment_ac') @property @@ -406,4 +598,5 @@ def lcoe_real(self) -> float: @property def lcoe_nom(self) -> float: return self.value('levelized_cost_of_energy_nominal') + \ No newline at end of file diff --git a/hopp/simulation/technologies/grid.py b/hopp/simulation/technologies/grid.py index 1491f5e75..d654f0dab 100644 --- a/hopp/simulation/technologies/grid.py +++ b/hopp/simulation/technologies/grid.py @@ -36,6 +36,7 @@ class GridConfig(BaseClass): interconnect_kw: float = field(validator=gt_zero) fin_model: Optional[Union[str, dict, FinancialModelType]] = None ppa_price: Optional[Union[Iterable, float]] = None + name: str = field(default="Grid") @define @@ -49,6 +50,7 @@ class Grid(PowerSource): schedule_curtailed: NDArrayFloat = field(init=False) schedule_curtailed_percentage: float = field(init=False, default=0.) total_gen_max_feasible_year1: NDArrayFloat = field(init=False) + config_name: Optional[str] = field(default="GenericSystemSingleOwner") def __attrs_post_init__(self): """ @@ -59,21 +61,24 @@ def __attrs_post_init__(self): site: Power source site information config: dict, used to instantiate a `GridConfig` instance """ - system_model = GridModel.default("GenericSystemSingleOwner") - + + system_model = GridModel.default(self.config_name) + # parse user input for financial model if isinstance(self.config.fin_model, str): financial_model = Singleowner.default(self.config.fin_model) elif isinstance(self.config.fin_model, dict): - financial_model = CustomFinancialModel(self.config.fin_model) + financial_model = CustomFinancialModel(self.config.fin_model, name=self.config.name) else: financial_model = self.config.fin_model - # default if financial_model is None: - financial_model = Singleowner.from_existing(system_model, "GenericSystemSingleOwner") + # default + financial_model = Singleowner.from_existing(system_model, self.config_name) financial_model.value("add_om_num_types", 1) - + else: + financial_model = self.import_financial_model(financial_model, system_model, self.config_name) + super().__init__("Grid", self.site, system_model, financial_model) if self.config.ppa_price is not None: @@ -164,9 +169,9 @@ def simulate_grid_connection( final_power_array = np.array(final_power_production) power_met = np.where(final_power_array > schedule, schedule, final_power_array) self.capacity_factor_load = np.sum(power_met) / np.sum(schedule) * 100 - - logger.info('Percent of time firm power requirement is met: ', np.round(self.time_load_met,2)) - logger.info('Percent total firm power requirement is satisfied: ', np.round(self.capacity_factor_load,2)) + + logger.info('Percent of time firm power requirement is met: %s', np.round(self.time_load_met,2)) + logger.info('Percent total firm power requirement is satisfied: %s', np.round(self.capacity_factor_load,2)) ERS_keys = ['min_regulation_hours', 'min_regulation_power'] if dispatch_options is not None and dispatch_options.use_higher_hours: diff --git a/hopp/simulation/technologies/power_source.py b/hopp/simulation/technologies/power_source.py index 2397c27e4..5caf1a227 100644 --- a/hopp/simulation/technologies/power_source.py +++ b/hopp/simulation/technologies/power_source.py @@ -73,6 +73,7 @@ def check_if_callable(obj, func_name): check_if_callable(financial_model, "unassign") check_if_callable(financial_model, "execute") financial_model_new = financial_model + financial_model_new.name = config_name return financial_model_new def initialize_financial_values(self): diff --git a/hopp/simulation/technologies/pv/detailed_pv_plant.py b/hopp/simulation/technologies/pv/detailed_pv_plant.py index 3f3b7615e..d59c2e167 100644 --- a/hopp/simulation/technologies/pv/detailed_pv_plant.py +++ b/hopp/simulation/technologies/pv/detailed_pv_plant.py @@ -55,6 +55,7 @@ class DetailedPVConfig(BaseClass): layout_model: Optional[Union[dict, PVLayout]] = field(default=None) fin_model: Optional[Union[str, dict, FinancialModelType]] = field(default=None) dc_degradation: Optional[List[float]] = field(default=None) + name: str = field(default="DetailedPVPlant") @define class DetailedPVPlant(PowerSource): @@ -77,7 +78,7 @@ def __attrs_post_init__(self): if isinstance(self.config.fin_model, str): financial_model = Singleowner.default(self.config.fin_model) elif isinstance(self.config.fin_model, dict): - financial_model = CustomFinancialModel(self.config.fin_model) + financial_model = CustomFinancialModel(self.config.fin_model, name=self.config.name) else: financial_model = self.config.fin_model diff --git a/hopp/simulation/technologies/pv/pv_plant.py b/hopp/simulation/technologies/pv/pv_plant.py index 9c2f3103b..5f15e4aea 100644 --- a/hopp/simulation/technologies/pv/pv_plant.py +++ b/hopp/simulation/technologies/pv/pv_plant.py @@ -53,6 +53,7 @@ class PVConfig(BaseClass): dc_degradation: Optional[List[float]] = field(default=None) approx_nominal_efficiency: Optional[float] = field(default=0.19) module_unit_mass: Optional[float] = field(default=11.092) + name: str = field(default="PVPlant") @define class PVPlant(PowerSource): @@ -76,7 +77,7 @@ def __attrs_post_init__(self): if isinstance(self.config.fin_model, str): financial_model = Singleowner.default(self.config.fin_model) elif isinstance(self.config.fin_model, dict): - financial_model = CustomFinancialModel(self.config.fin_model) + financial_model = CustomFinancialModel(self.config.fin_model, name=self.config.name) else: financial_model = self.config.fin_model diff --git a/hopp/simulation/technologies/sites/site_info.py b/hopp/simulation/technologies/sites/site_info.py index fadb14b74..b1c40ed4d 100644 --- a/hopp/simulation/technologies/sites/site_info.py +++ b/hopp/simulation/technologies/sites/site_info.py @@ -56,7 +56,7 @@ class SiteInfo(BaseClass): Options "interconnect_kw" or "desired_schedule". Defaults to "interconnect_kw". solar: Whether to set solar data for this site. Defaults to True. wind: Whether to set wind data for this site. Defaults to True. - wave: Whether to set wave data for this site. Defaults to True. + wave: Whether to set wave data for this site. Defaults to False. wind_resource_origin: Which wind resource API to use, defaults to WIND Toolkit """ # User provided diff --git a/hopp/simulation/technologies/wave/mhk_wave_plant.py b/hopp/simulation/technologies/wave/mhk_wave_plant.py index 5ecc48dc2..0af69c911 100644 --- a/hopp/simulation/technologies/wave/mhk_wave_plant.py +++ b/hopp/simulation/technologies/wave/mhk_wave_plant.py @@ -44,6 +44,7 @@ class MHKConfig(BaseClass): loss_transmission: float = field(default=0., validator=range_val(0, 100)) loss_downtime: float = field(default=0., validator=range_val(0, 100)) loss_additional: float = field(default=0., validator=range_val(0, 100)) + name: str = field(default="MHKWavePlant") @define @@ -69,7 +70,7 @@ def __attrs_post_init__(self): system_model = MhkWave.new() if isinstance(self.config.fin_model, dict): - financial_model = CustomFinancialModel(self.config.fin_model) + financial_model = CustomFinancialModel(self.config.fin_model, name=self.config.name) else: financial_model = self.config.fin_model diff --git a/hopp/simulation/technologies/wind/wind_plant.py b/hopp/simulation/technologies/wind/wind_plant.py index 0ed5382ba..6b512439b 100644 --- a/hopp/simulation/technologies/wind/wind_plant.py +++ b/hopp/simulation/technologies/wind/wind_plant.py @@ -58,6 +58,7 @@ class WindConfig(BaseClass): operational_losses: float = field(default = 12.83, validator=range_val(0, 100)) timestep: Optional[Tuple[int, int]] = field(default=None) fin_model: Optional[Union[dict, FinancialModelType]] = field(default=None) + name: str = field(default="WindPlant") def __attrs_post_init__(self): if self.model_name == 'floris' and self.timestep is None: @@ -85,14 +86,26 @@ def __attrs_post_init__(self): """ self._rating_range_kw = self.config.rating_range_kw + # Parse input for a financial model + if isinstance(self.config.fin_model, str): + financial_model = Singleowner.default(self.config_name) + elif isinstance(self.config.fin_model, dict): + financial_model = CustomFinancialModel(self.config.fin_model, name=self.config.name) + else: + financial_model = self.config.fin_model + if self.config.model_name == 'floris': print('FLORIS is the system model...') system_model = Floris(self.site, self.config) - financial_model = Singleowner.default(self.config_name) + + if financial_model is None: + # default + financial_model = Singleowner.default(self.config_name) + else: + financial_model = self.import_financial_model(financial_model, system_model, self.config_name) else: if self.config.model_input_file is None: system_model = Windpower.default(self.config_name) - financial_model = Singleowner.from_existing(system_model, self.config_name) else: # initialize system using pysam input file input_file_path = resource_file_converter(self.config.model_input_file) @@ -106,15 +119,13 @@ def __attrs_post_init__(self): system_model.value("wind_resource_data", self.site.wind_resource.data) # turbine power curve (array of kW power outputs) - self.wind_turbine_powercurve_powerout = [1] * nTurbs + self.wind_turbine_powercurve_powerout = [1] * nTurbs + if financial_model is None: + # default financial_model = Singleowner.from_existing(system_model, self.config_name) - - # Parse user input for financial model - if isinstance(self.config.fin_model, str): - financial_model = Singleowner.default(self.config.fin_model) - elif isinstance(self.config.fin_model, dict): - financial_model = CustomFinancialModel(self.config.fin_model) + else: + financial_model = self.import_financial_model(financial_model, system_model, self.config_name) if isinstance(self.config.layout_params, dict): layout_params = WindBoundaryGridParameters(**self.config.layout_params) diff --git a/pyproject.toml b/pyproject.toml index 181e162c0..4fa7b8968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ dependencies = [ "openpyxl", "attrs", "utm", + "pyyaml-include", + "profast" ] keywords = [ "python3", diff --git a/tests/hopp/test_battery_dispatch.py b/tests/hopp/test_battery_dispatch.py index 577b45a06..c3cf34dbb 100644 --- a/tests/hopp/test_battery_dispatch.py +++ b/tests/hopp/test_battery_dispatch.py @@ -13,32 +13,12 @@ from hopp.simulation.technologies.dispatch.hybrid_dispatch_builder_solver import HybridDispatchBuilderSolver, HybridDispatchOptions from hopp.simulation.technologies.financial.custom_financial_model import CustomFinancialModel from hopp import ROOT_DIR +from tests.hopp.utils import DEFAULT_FIN_CONFIG solar_resource_file = ROOT_DIR / "simulation" / "resource_files" / "solar" / "35.2018863_-101.945027_psmv3_60_2012.csv" wind_resource_file = ROOT_DIR / "simulation" / "resource_files" / "wind" / "35.2018863_-101.945027_windtoolkit_2012_60min_80m_100m.srw" site = SiteInfo(flatirons_site, solar_resource_file=solar_resource_file, wind_resource_file=wind_resource_file) -default_fin_config = { - 'batt_computed_bank_capacity': 0, - 'batt_replacement_schedule_percent': [0], - 'batt_bank_replacement': [0], - 'batt_replacement_option': 0, - 'batt_meter_position': 0, - 'om_fixed': [1], - 'om_production': [2], - 'om_capacity': (0,), - 'om_batt_fixed_cost': 0, - 'om_batt_variable_cost': [0.75], - 'om_batt_capacity_cost': 0, - 'om_batt_replacement_cost': [0], - 'om_replacement_cost_escal': 0, - 'system_use_lifetime_output': 0, - 'inflation_rate': 2.5, - 'real_discount_rate': 6.4, - 'cp_capacity_credit_percent': [0], - 'degradation': [0], -} - interconnect_mw = 50 technologies_input = { 'pv': { @@ -48,7 +28,7 @@ 'system_capacity_kwh': 200 * 1000, 'system_capacity_kw': 50 * 1000, 'tracking': False, - 'fin_model': CustomFinancialModel(default_fin_config) + 'fin_model': CustomFinancialModel(DEFAULT_FIN_CONFIG, name="Test") }, 'grid': { 'interconnect_kw': interconnect_mw * 1000 diff --git a/tests/hopp/test_custom_financial.py b/tests/hopp/test_custom_financial.py index cae1743ac..5913975f7 100644 --- a/tests/hopp/test_custom_financial.py +++ b/tests/hopp/test_custom_financial.py @@ -4,10 +4,28 @@ from hopp import ROOT_DIR from hopp.simulation import HoppInterface from hopp.simulation.technologies.financial.custom_financial_model import CustomFinancialModel + from tests.hopp.utils import create_default_site_info, DEFAULT_FIN_CONFIG +import copy + +DEFAULT_FIN_CONFIG_LOCAL = copy.deepcopy(DEFAULT_FIN_CONFIG) +DEFAULT_FIN_CONFIG_LOCAL.pop("revenue") # these tests were written before the revenue section was added to the default financial config + +from hopp.utilities import load_yaml + +from hopp.simulation.technologies.financial.mhk_cost_model import MHKCostModelInputs pvsamv1_defaults_file = ROOT_DIR.parent / "tests" / "hopp" / "pvsamv1_basic_params.json" +mhk_yaml_path = ( + ROOT_DIR.parent / "tests" / "hopp" / "inputs" / "wave" / "wave_device.yaml" +) +mhk_config = load_yaml(mhk_yaml_path) + +wave_resource_file = ( + ROOT_DIR / "simulation" / "resource_files" / "wave" / "Wave_resource_timeseries.csv" +) + @fixture def site(): return create_default_site_info() @@ -49,12 +67,12 @@ def test_detailed_pv(site, subtests): "s_buffer": 2, "x_buffer": 2 }, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, 'dc_degradation': [0] * 25, }, "grid": { 'interconnect_kw': interconnect_kw, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, 'ppa_price': 0.01 } }, @@ -124,7 +142,7 @@ def test_hybrid_simple_pv_with_wind(site, subtests): "s_buffer": 2, "x_buffer": 2 }, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, 'dc_degradation': [0] * 25 }, 'wind': { @@ -138,11 +156,11 @@ def test_hybrid_simple_pv_with_wind(site, subtests): "grid_aspect_power": 0.5, "row_phase_offset": 0.5 }, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, }, 'grid': { 'interconnect_kw': interconnect_kw, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, 'ppa_price': 0.01 }, } @@ -202,7 +220,7 @@ def test_hybrid_detailed_pv_with_wind(site, subtests): 'use_pvwatts': False, 'tech_config': tech_config, 'layout_params': layout_params, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, 'dc_degradation': [0] * 25 }, 'wind': { @@ -216,11 +234,11 @@ def test_hybrid_detailed_pv_with_wind(site, subtests): "grid_aspect_power": 0.5, "row_phase_offset": 0.5 }, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, }, 'grid': { 'interconnect_kw': interconnect_kw, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, 'ppa_price': 0.01 } } @@ -249,21 +267,35 @@ def test_hybrid_detailed_pv_with_wind(site, subtests): assert npvs.wind == approx(npv_expected_wind, 1e-3) assert npvs.hybrid == approx(npv_expected_hybrid, 1e-3) -def test_hybrid_simple_pv_with_wind_storage_dispatch(site, subtests): +def test_hybrid_simple_pv_with_wind_wave_storage_dispatch(subtests): + + site_internal = create_default_site_info(wave=True, wave_resource_file=wave_resource_file) # Test wind + simple PV (pvwattsv8) + storage with dispatch hybrid plant with custom financial model annual_energy_expected_pv = 9857584 annual_energy_expected_wind = 31951719 - annual_energy_expected_battery = -96912 - annual_energy_expected_hybrid = 41709692 + annual_energy_expected_wave = 12132526 + annual_energy_expected_battery = -98292 + annual_energy_expected_hybrid = 53840357 + npv_expected_pv = -1905544 npv_expected_wind = -5159400 + npv_expected_wave = -50006845 npv_expected_battery = -8183543 - npv_expected_hybrid = -15249189 + npv_expected_hybrid = -65256581 - interconnect_kw = 15000 + lcoe_expected_pv = 3.370275050662196 + lcoe_expected_wind = 3.162940789633178 + lcoe_expected_wave = 28.83013114281512 + lcoe_expected_battery = 13.29435118093791 + lcoe_expected_hybrid = 9.971870828005137 + + total_installed_cost_expected = 81063378.16191691 + + interconnect_kw = 20000 pv_kw = 5000 wind_kw = 10000 batt_kw = 5000 + wave_kw = 2860 power_sources = { 'pv': { @@ -276,7 +308,7 @@ def test_hybrid_simple_pv_with_wind_storage_dispatch(site, subtests): "s_buffer": 2, "x_buffer": 2 }, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, 'dc_degradation': [0] * 25 }, 'wind': { @@ -290,47 +322,109 @@ def test_hybrid_simple_pv_with_wind_storage_dispatch(site, subtests): "grid_aspect_power": 0.5, "row_phase_offset": 0.5 }, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, + }, + "wave": { + "device_rating_kw": wave_kw/10, + "num_devices": 10, + "wave_power_matrix": mhk_config["wave_power_matrix"], + "fin_model": DEFAULT_FIN_CONFIG_LOCAL, }, 'battery': { 'system_capacity_kwh': batt_kw * 4, 'system_capacity_kw': batt_kw, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, }, 'grid': { 'interconnect_kw': interconnect_kw, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, 'ppa_price': 0.03 } } + config = { + "simulation_options": { + "wind": { + "skip_financial": False # test that setting this to false allows financial calculations to run + } + } + } hopp_config = { - "site": site, - "technologies": power_sources + "site": site_internal, + "technologies": power_sources, + "config": config } + + mhk_cost_model_inputs = MHKCostModelInputs.from_dict( + { + "reference_model_num": 3, + "water_depth": 100, + "distance_to_shore": 80, + "number_rows": 10, + "device_spacing": 600, + "row_spacing": 600, + "cable_system_overbuild": 20, + } + ) + hi = HoppInterface(hopp_config) hybrid_plant = hi.system hybrid_plant.layout.plot() hybrid_plant.battery.dispatch.lifecycle_cost_per_kWh_cycle = 0.01 hybrid_plant.battery._financial_model.om_batt_variable_cost = [0.75] + hybrid_plant.wave.create_mhk_cost_calculator(mhk_cost_model_inputs) hybrid_plant.simulate() sizes = hybrid_plant.system_capacity_kw aeps = hybrid_plant.annual_energies npvs = hybrid_plant.net_present_values - with subtests.test("with minimal params"): + lcoes = hybrid_plant.lcoe_nom # cents/kWh + + with subtests.test("with minimal params pv size"): assert sizes.pv == approx(pv_kw, 1e-3) + with subtests.test("with minimal params wind size"): assert sizes.wind == approx(wind_kw, 1e-3) + with subtests.test("with minimal params wave size"): + assert sizes.wave == approx(wave_kw, 1e-3) + with subtests.test("with minimal params batt kw size"): assert sizes.battery == approx(batt_kw, 1e-3) + + with subtests.test("with minimal params pv aep"): assert aeps.pv == approx(annual_energy_expected_pv, 1e-3) + with subtests.test("with minimal params wind aep"): assert aeps.wind == approx(annual_energy_expected_wind, 1e-3) + with subtests.test("with minimal params wave aep"): + assert aeps.wave == approx(annual_energy_expected_wave, 1e-3) + with subtests.test("with minimal params battery aep"): assert aeps.battery == approx(annual_energy_expected_battery, 1e-3) + with subtests.test("with minimal params hybrid aep"): assert aeps.hybrid == approx(annual_energy_expected_hybrid, 1e-3) + + with subtests.test("with minimal params pv npv"): assert npvs.pv == approx(npv_expected_pv, 1e-3) + with subtests.test("with minimal params wind npv"): assert npvs.wind == approx(npv_expected_wind, 1e-3) + with subtests.test("with minimal params wave npv"): + assert npvs.wave == approx(npv_expected_wave, 1e-3) + with subtests.test("with minimal params batt npv"): assert npvs.battery == approx(npv_expected_battery, 1e-3) + with subtests.test("with minimal params hybrid npv"): assert npvs.hybrid == approx(npv_expected_hybrid, 1e-3) + with subtests.test("lcoe pv"): + assert lcoes.pv == approx(lcoe_expected_pv, 1e-3) + with subtests.test("lcoe wind"): + assert lcoes.wind == approx(lcoe_expected_wind, 1e-3) + with subtests.test("lcoe wave"): + assert lcoes.wave == approx(lcoe_expected_wave, 1e-3) + with subtests.test("lcoe battery"): ############## left commented since I'm not sure calculating LCOE for battery this way makes sense + assert lcoes.battery == approx(lcoe_expected_battery, 1e-3) + with subtests.test("lcoe hybrid"): + assert lcoes.hybrid == approx(lcoe_expected_hybrid, 1e-3) + + with subtests.test("total installed cost"): + assert hybrid_plant.grid.total_installed_cost == approx(total_installed_cost_expected, 1E-6) + def test_hybrid_detailed_pv_with_wind_storage_dispatch(site, subtests): # Test wind + detailed PV (pvsamv1) + storage with dispatch hybrid plant with custom financial model @@ -367,7 +461,7 @@ def test_hybrid_detailed_pv_with_wind_storage_dispatch(site, subtests): "s_buffer": 2, "x_buffer": 2 }, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, 'dc_degradation': [0] * 25 }, 'wind': { @@ -381,16 +475,16 @@ def test_hybrid_detailed_pv_with_wind_storage_dispatch(site, subtests): "grid_aspect_power": 0.5, "row_phase_offset": 0.5 }, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, }, 'battery': { 'system_capacity_kwh': batt_kw * 4, 'system_capacity_kw': batt_kw, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, }, 'grid': { 'interconnect_kw': interconnect_kw, - 'fin_model': DEFAULT_FIN_CONFIG, + 'fin_model': DEFAULT_FIN_CONFIG_LOCAL, 'ppa_price': 0.03 } } diff --git a/tests/hopp/test_detailed_pv_plant.py b/tests/hopp/test_detailed_pv_plant.py index 824bc91a6..7841b7940 100644 --- a/tests/hopp/test_detailed_pv_plant.py +++ b/tests/hopp/test_detailed_pv_plant.py @@ -101,7 +101,7 @@ def test_custom_financial(site): config = DetailedPVConfig.from_dict( { "system_capacity_kw": 100, - "fin_model": CustomFinancialModel(DEFAULT_FIN_CONFIG), + "fin_model": CustomFinancialModel(DEFAULT_FIN_CONFIG, name="Test"), } ) pv_plant = DetailedPVPlant(site=site, config=config) diff --git a/tests/hopp/test_dispatch.py b/tests/hopp/test_dispatch.py index b5dbfeb7e..2f9648df4 100644 --- a/tests/hopp/test_dispatch.py +++ b/tests/hopp/test_dispatch.py @@ -26,7 +26,7 @@ from hopp.simulation.technologies.dispatch.power_sources.pv_dispatch import PvDispatch from hopp.simulation.technologies.dispatch.power_sources.wind_dispatch import WindDispatch -from tests.hopp.utils import create_default_site_info +from tests.hopp.utils import create_default_site_info, DEFAULT_FIN_CONFIG from hopp.utilities import load_yaml from hopp import ROOT_DIR @@ -66,27 +66,6 @@ def site(): } } -default_fin_config = { - 'batt_computed_bank_capacity': 0, - 'batt_replacement_schedule_percent': [0], - 'batt_bank_replacement': [0], - 'batt_replacement_option': 0, - 'batt_meter_position': 0, - 'om_fixed': [1], - 'om_production': [2], - 'om_capacity': (0,), - 'om_batt_fixed_cost': 0, - 'om_batt_variable_cost': [0.75], - 'om_batt_capacity_cost': 0, - 'om_batt_replacement_cost': [0], - 'om_replacement_cost_escal': 0, - 'system_use_lifetime_output': 0, - 'inflation_rate': 2.5, - 'real_discount_rate': 6.4, - 'cp_capacity_credit_percent': [0], - 'degradation': [0], -} - def test_solar_dispatch(site): expected_objective = 23890.6768 @@ -378,30 +357,7 @@ def test_wave_dispatch(): mhk_yaml_path = Path(__file__).absolute().parent.parent.parent / "tests" / "hopp" / "inputs" / "wave" / "wave_device.yaml" mhk_config = load_yaml(mhk_yaml_path) - default_fin_config = { - 'batt_replacement_schedule_percent': [0], - 'batt_bank_replacement': [0], - 'batt_replacement_option': 0, - 'batt_computed_bank_capacity': 0, - 'batt_meter_position': 0, - 'om_fixed': [1], - 'om_production': [2], - 'om_capacity': (0,), - 'om_batt_fixed_cost': 0, - 'om_batt_variable_cost': [0], - 'om_batt_capacity_cost': 0, - 'om_batt_replacement_cost': 0, - 'om_replacement_cost_escal': 0, - 'system_use_lifetime_output': 0, - 'inflation_rate': 2.5, - 'real_discount_rate': 6.4, - 'cp_capacity_credit_percent': [0], - 'degradation': [0], - 'ppa_price_input': [25], - 'ppa_escalation': 2.5 - } - - financial_model = {'fin_model': CustomFinancialModel(default_fin_config)} + financial_model = {'fin_model': DEFAULT_FIN_CONFIG} mhk_config.update(financial_model) config = MHKConfig.from_dict(mhk_config) @@ -1051,7 +1007,7 @@ def test_dispatch_load_following_heuristic_with_wave(site, subtests): wave_battery = {key: technologies[key] for key in ['wave', 'battery', 'grid']} for tech in wave_battery.keys(): - wave_battery[tech]["fin_model"] = default_fin_config + wave_battery[tech]["fin_model"] = DEFAULT_FIN_CONFIG wave_resource_file = ROOT_DIR / "simulation" / "resource_files" / "wave" / "Wave_resource_timeseries.csv" diff --git a/tests/hopp/test_hybrid.py b/tests/hopp/test_hybrid.py index 6c168bab7..ce9a8586b 100644 --- a/tests/hopp/test_hybrid.py +++ b/tests/hopp/test_hybrid.py @@ -2,6 +2,7 @@ from copy import deepcopy from pytest import approx, fixture, raises +# import pytest import numpy as np import json @@ -44,7 +45,7 @@ def site(): @fixture -def wavesite(): +def wavesite(): # TODO this should be used, but there were problems getting it working so tests duplicate the work each time right now data = {"lat": 44.6899, "lon": 124.1346, "year": 2010, "tz": -7} return SiteInfo( data, wave_resource_file=wave_resource_file, solar=False, wind=False, wave=True @@ -181,7 +182,7 @@ def wavesite(): ] -def test_hybrid_wave_only(hybrid_config, wavesite, subtests): +def test_hybrid_wave_only(hybrid_config, subtests): hybrid_config["site"]["wave"] = True hybrid_config["site"]["wave_resource_file"] = wave_resource_file wave_only_technologies = { @@ -306,9 +307,9 @@ def test_hybrid_wave_only(hybrid_config, wavesite, subtests): assert hybrid_plant.wave._financial_model.value("inflation_rate") == approx( hybrid_plant.grid._financial_model.value("inflation_rate") ) - with subtests.test("annual_energy"): - assert hybrid_plant.wave._financial_model.value("annual_energy") == approx( - hybrid_plant.grid._financial_model.value("annual_energy") + with subtests.test("annual_energy_kwh"): + assert hybrid_plant.wave.value("annual_energy_kwh") == approx( + hybrid_plant.grid.value("annual_energy_kwh") ) with subtests.test("ppa_price_input"): assert hybrid_plant.wave._financial_model.value("ppa_price_input") == approx( @@ -330,12 +331,12 @@ def test_hybrid_wave_only(hybrid_config, wavesite, subtests): assert cf.hybrid == approx(cf.wave) with subtests.test("wave npv"): # TODO check/verify this test value somehow, not sure how to do it right now - assert npvs.wave == approx(-53731805.52113224) + assert npvs.wave == approx(-53714525.2968821, 5.e-2) with subtests.test("hybrid wave only npv"): assert npvs.hybrid == approx(npvs.wave) -def test_hybrid_wave_battery(hybrid_config, wavesite, subtests): +def test_hybrid_wave_battery(hybrid_config, subtests): hybrid_config["site"]["wave"] = True hybrid_config["site"]["wave_resource_file"] = wave_resource_file wave_only_technologies = { @@ -1490,7 +1491,7 @@ def reinstate_orig_values(): assert tc.battery[1] == approx(2201850, rel=5e-2) assert tc.hybrid[1] == approx(4338902, rel=5e-2) -def test_hybrid_financials(hybrid_config): +def test_hybrid_financials(hybrid_config, subtests): """ Performance from Wind is slightly different from wind-only case because the solar presence modified the wind layout """ @@ -1504,7 +1505,10 @@ def test_hybrid_financials(hybrid_config): hybrid_plant hi.simulate() - assert hi.system.pv._financial_model.SystemCosts.om_production == hi.system.pv.om_production - assert hi.system.om_total_expenses['pv'][1] == approx(248536, rel=5e-2) - assert hi.system.om_total_expenses['wind'][1] == approx(493903.4397049556, rel=5e-2) + with subtests.test("pv om_production"): + assert hi.system.pv._financial_model.SystemCosts.om_production == hi.system.pv.om_production + with subtests.test("pv om total"): + assert hi.system.om_total_expenses['pv'][1] == approx(248536, rel=5e-2) + with subtests.test("wind om total"): + assert hi.system.om_total_expenses['wind'][1] == approx(493903.4397049556, rel=5e-2) diff --git a/tests/hopp/test_wave.py b/tests/hopp/test_wave.py index b4f692f76..feeedce0a 100644 --- a/tests/hopp/test_wave.py +++ b/tests/hopp/test_wave.py @@ -8,30 +8,8 @@ from hopp.simulation.technologies.financial.custom_financial_model import CustomFinancialModel from hopp.utilities import load_yaml from hopp import ROOT_DIR +from tests.hopp.utils import DEFAULT_FIN_CONFIG -# TODO: I'm seeing this copied around in tests, let's refactor to a module -default_fin_config = { - 'batt_replacement_schedule_percent': [0], - 'batt_bank_replacement': [0], - 'batt_replacement_option': 0, - 'batt_computed_bank_capacity': 0, - 'batt_meter_position': 0, - 'om_fixed': [1], - 'om_production': [2], - 'om_capacity': (0,), - 'om_batt_fixed_cost': 0, - 'om_batt_variable_cost': [0], - 'om_batt_capacity_cost': 0, - 'om_batt_replacement_cost': 0, - 'om_replacement_cost_escal': 0, - 'system_use_lifetime_output': 0, - 'inflation_rate': 2.5, - 'real_discount_rate': 6.4, - 'cp_capacity_credit_percent': [0], - 'degradation': [0], - 'ppa_price_input': [25], - 'ppa_escalation': 2.5 -} @fixture def site(): @@ -55,7 +33,7 @@ def mhk_config(): @fixture def waveplant(mhk_config, site): - financial_model = {'fin_model': CustomFinancialModel(default_fin_config)} + financial_model = {'fin_model': DEFAULT_FIN_CONFIG} mhk_config.update(financial_model) config = MHKConfig.from_dict(mhk_config) @@ -73,7 +51,7 @@ def waveplant(mhk_config, site): def test_mhk_config(mhk_config, subtests): with subtests.test("with basic params"): - financial_model = {'fin_model': CustomFinancialModel(default_fin_config)} + financial_model = {'fin_model': DEFAULT_FIN_CONFIG} mhk_config.update(financial_model) config = MHKConfig.from_dict(mhk_config) diff --git a/tests/hopp/test_wind.py b/tests/hopp/test_wind.py index 3d3b316f2..00caa7784 100644 --- a/tests/hopp/test_wind.py +++ b/tests/hopp/test_wind.py @@ -1,9 +1,7 @@ -import pytest -from pytest import fixture +from pytest import fixture, approx import math import PySAM.Windpower as windpower -from floris import FlorisModel, TimeSeries from hopp.simulation.technologies.wind.wind_plant import WindPlant, WindConfig from tests.hopp.utils import create_default_site_info @@ -76,7 +74,7 @@ def test_changing_n_turbines_pysam(site): for n in range(1, 20): model.num_turbines = n assert model.num_turbines == n, "n turbs should be " + str(n) - assert model.system_capacity_kw == pytest.approx(20000, 1), "system capacity different when n turbs " + str(n) + assert model.system_capacity_kw == approx(20000, 1), "system capacity different when n turbs " + str(n) # test with row layout @@ -111,8 +109,8 @@ def test_changing_powercurve_pysam(site): for n in range(1000, 3001, 500): d = math.ceil(n * d_to_r * 1) model.modify_powercurve(d, n) - assert model.turb_rating == pytest.approx(n, 0.1), "turbine rating should be " + str(n) - assert model.system_capacity_kw == pytest.approx(model.turb_rating * n_turbs, 0.1), "size error when rating is " + str(n) + assert model.turb_rating == approx(n, 0.1), "turbine rating should be " + str(n) + assert model.system_capacity_kw == approx(model.turb_rating * n_turbs, 0.1), "size error when rating is " + str(n) def test_changing_system_capacity_pysam(site): @@ -129,7 +127,7 @@ def test_changing_system_capacity_pysam(site): model = WindPlant(site, config=config) for n in range(40000, 60000, 1000): model.system_capacity_by_rating(n) - assert model.system_capacity_kw == pytest.approx(n) + assert model.system_capacity_kw == approx(n) #################### FLORIS tests ################ @@ -173,4 +171,4 @@ def test_changing_system_capacity_floris(site): model = WindPlant(site, config=config) for n in range(40000, 60000, 1000): model.system_capacity_by_rating(n) - assert model.system_capacity_kw == pytest.approx(n) \ No newline at end of file + assert model.system_capacity_kw == approx(n) \ No newline at end of file diff --git a/tests/hopp/utils.py b/tests/hopp/utils.py index 00d65e92f..1778b637a 100644 --- a/tests/hopp/utils.py +++ b/tests/hopp/utils.py @@ -19,18 +19,36 @@ 'system_costs': { 'om_fixed': [1], 'om_production': [2], - 'om_capacity': (0,), + 'om_capacity': [0], 'om_batt_fixed_cost': 0, - 'om_batt_variable_cost': [0], + 'om_batt_variable_cost': [0.75], 'om_batt_capacity_cost': 0, 'om_batt_replacement_cost': 0, 'om_replacement_cost_escal': 0, }, - 'revenue': {}, + 'revenue': { + 'ppa_price_input': [25], # cents/kWh + 'ppa_escalation': 2.5 # % + }, 'system_use_lifetime_output': 0, 'financial_parameters': { 'inflation_rate': 2.5, 'real_discount_rate': 6.4, + 'federal_tax_rate': 21.0, + 'state_tax_rate': 4.0, + 'property_tax_rate': 1.0, + 'insurance_rate': 0.5, + 'debt_percent': 68.5, + 'term_int_rate': 6.0, + 'months_working_reserve': 1, + 'analysis_start_year': 2025, + 'installation_months': 12, + 'sales_tax_rate_state': 4.5, + 'admin_expense_percent_of_sales': 1.0, + 'capital_gains_tax_rate': 15.0, + 'debt_type': "Revolving debt", + 'depreciation_method': "MACRS", + 'depreciation_period': 5, }, 'cp_capacity_credit_percent': [0], 'degradation': [0],