diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7bcc5622a..bba3d7a64 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -49,7 +49,7 @@ the problem. All code and full tracebacks should be properly markdown formatted. - OS: -- Python version: <3.10.4> +- Python version: <3.11.1> - HOPP version: <0.1.1> | Package | Version | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7291c50d1..7e5afebf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: shell: bash -el {0} strategy: matrix: - python-version: ["3.10", "3.11"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/conda_build.yml b/.github/workflows/conda_build.yml deleted file mode 100644 index 8d5400d9d..000000000 --- a/.github/workflows/conda_build.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Conda Build and Upload - -on: - release: - types: [published] - -jobs: - build: - name: Conda - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: conda-incubator/setup-miniconda@v3 - with: - auto-update-conda: true - python-version: 3.11 - - name: Build and upload conda package - shell: bash -l {0} - env: - ANACONDA_TOKEN: ${{ secrets.ANACONDA_TOKEN }} - run: | - conda install --yes --quiet conda-build conda-verify anaconda-client - conda build conda.recipe/ -c nrel -c conda-forge -c sunpower - anaconda -t $ANACONDA_TOKEN upload -u nrel $(conda build conda.recipe/ -c nrel -c conda-forge -c sunpower --output) diff --git a/README.md b/README.md index 75e8eed60..a3b227264 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,19 @@ As part of NREL's [Hybrid Energy Systems Research](https://www.nrel.gov/wind/hyb software assesses optimal designs for the deployment of distributed, commercial, and utility-scale hybrid energy plants, particularly considering wind, solar and storage. + +## Part of the WETO Stack + +HOPP is primarily developed with the support of the U.S. Department of Energy and is part of the [WETO Software Stack](https://nrel.github.io/WETOStack). For more information and other integrated modeling software, see: +- [Portfolio Overview](https://nrel.github.io/WETOStack/portfolio_analysis/overview.html) +- [Entry Guide](https://nrel.github.io/WETOStack/_static/entry_guide/index.html) +- [Techno-Economic Modeling Workshop](https://nrel.github.io/WETOStack/workshops/user_workshops_2024.html#tea-and-cost-modeling) +- [Systems Engineering Workshop](https://nrel.github.io/WETOStack/workshops/user_workshops_2024.html#systems-engineering) + + ## Software requirements -- Python version 3.10, and 3.11 only +- Python version 3.11 or higher ## Installing from Package Repositories diff --git a/RELEASE.md b/RELEASE.md index 59f3e898e..0c09788cc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,4 +1,46 @@ # Release Notes +## Unreleased +* Add `overwrite_fin_values` from H2Integrate to sync cost input methods +* Bump minimum NREL-PySAM version to 7.0.0 +* Clarify that the `nominal_discount_rate` method of the `CustomFinancialModel` uses the Fisher equation +* Bug-fix in `WindPlant` for handling model_input_file for PySAM simulations. +* Remove `tidal_resource` as a required input to the tidal model because it's no longer required in the SSC model ([NREL/SSC PR #1305](https://github.com/NREL/ssc/pull/1305)) +* Add load following heuristic dispatch test +* Consolidated utilities, renamed MHKConfig to MHKWaveConfig +* Updated solar resource download to use GOES Aggregated PSM v4 download + +## Version 3.3.0, April 30, 2025 + +* Added GenericPlant model ([PR #472](https://github.com/NREL/HOPP/pull/472)) which may be used to: + - simulate grid and battery performance without resimulating generation of other technologies + - represent the physics-based performance of a generation technology that is not included in HOPP +* Loosened strictness of comparison for wind turbine config checking and added tests +* Loosened strictness of comparison for wind turbine hub-height and wind resource hub-height +* Updated workflow for specifying wind turbine parameters without specifying a turbine name with PySAM. +* Added ability to download wind resource data from WTK-LED for Alaska ([PR #461](https://github.com/NREL/HOPP/pull/461)) +* Added ability to download wind resource data from BC-HRRR CONUS 60-minute (NOAA + NREL) for 2015-2023 ([PR #474](https://github.com/NREL/HOPP/pull/474)) +* Updated HOPP for pySAM 7.0.0 release ([PR #477](https://github.com/NREL/HOPP/pull/477)) +* Add long-duration energy storage (LDES) ([PR #471](https://github.com/NREL/HOPP/pull/471)) +* Bugfix for cycle counting in the minimum operating cost objective function - no longer throws an error +* Bugfix for flicker mismatch; cases with a single `Point` now correctly work + + + +## Version 3.3.0, April 30, 2025 + +* Added GenericPlant model ([PR #472](https://github.com/NREL/HOPP/pull/472)) which may be used to: + - simulate grid and battery performance without resimulating generation of other technologies + - represent the physics-based performance of a generation technology that is not included in HOPP +* Loosened strictness of comparison for wind turbine config checking and added tests +* Loosened strictness of comparison for wind turbine hub-height and wind resource hub-height +* Updated workflow for specifying wind turbine parameters without specifying a turbine name with PySAM. +* Added ability to download wind resource data from WTK-LED for Alaska ([PR #461](https://github.com/NREL/HOPP/pull/461)) +* Added ability to download wind resource data from BC-HRRR CONUS 60-minute (NOAA + NREL) for 2015-2023 ([PR #474](https://github.com/NREL/HOPP/pull/474)) +* Updated HOPP for pySAM 7.0.0 release ([PR #477](https://github.com/NREL/HOPP/pull/477)) +* Add long-duration energy storage (LDES) ([PR #471](https://github.com/NREL/HOPP/pull/471)) +* Bugfix for cycle counting in the minimum operating cost objective function - no longer throws an error +* Bugfix for flicker mismatch; cases with a single `Point` now correctly work + ## Version 3.3.0, April 30, 2025 diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml deleted file mode 100644 index ae1e00a04..000000000 --- a/conda.recipe/meta.yaml +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 7855e78f9..000000000 --- a/conda_build.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/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/docs/api/hopp_interface.md b/docs/api/hopp_interface.md index da8e4c233..83bf291c2 100644 --- a/docs/api/hopp_interface.md +++ b/docs/api/hopp_interface.md @@ -1,6 +1,6 @@ # HOPP Simulation Interface -This class is the main interaction point for HOPP simulations. See [Examples](https://github.com/NREL/HOPP/tree/main/examples/workshop) for in-depth notebooks and configuration files. +This class is the main interaction point for HOPP simulations. See [Examples](https://github.com/NREL/HOPP/tree/main/examples) for in-depth notebooks and configuration files. ```{eval-rst} .. currentmodule:: hopp.simulation.hopp_interface diff --git a/docs/api/technology/mhk_wave_plant.md b/docs/api/technology/mhk_wave_plant.md index 09990a44f..cac035a76 100644 --- a/docs/api/technology/mhk_wave_plant.md +++ b/docs/api/technology/mhk_wave_plant.md @@ -14,7 +14,7 @@ MHK Wave Generator class ## Wave Plant Configuration ```{eval-rst} -.. autoclass:: hopp.simulation.technologies.wave.mhk_wave_plant.MHKConfig +.. autoclass:: hopp.simulation.technologies.wave.mhk_wave_plant.MHKWaveConfig :members: :undoc-members: ``` diff --git a/examples/10-tidal-battery.ipynb b/examples/10-tidal-battery.ipynb index 10f6c4afd..aaddbb0dc 100644 --- a/examples/10-tidal-battery.ipynb +++ b/examples/10-tidal-battery.ipynb @@ -19,9 +19,17 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 1, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/Users/kbrunik/github/HOPP/examples/log/hybrid_systems_2025-07-23T14.55.37.333918.log\n" + ] + } + ], "source": [ "from hopp.simulation import HoppInterface" ] @@ -35,8 +43,7 @@ "\n", "For the site information, the tidal resource data **must be pre-loaded** in the format given in the `Tidal_resource_timeseries.csv`.\n", "\n", - "The tidal technology configuration requires the device rating (kw), power curve of tidal energy device as function of stream speeds (kW), and number of devices. Additionally there's a variable called `tidal_resource`, which is required for model instantiation but doesn't impact a timeseries simulation.\n", - "\n", + "The tidal technology configuration requires the device rating (kw), power curve of tidal energy device as function of stream speeds (kW), and number of devices.\n", "Note that the tidal model doesn't come with a default financial model. To address this, you must establish the `CustomFinancialModel` from HOPP.\n", "\n", "The `default_fin_config` contains all of the necessary parameters for the financial calculations.\n", @@ -46,7 +53,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -144,7 +151,7 @@ ], "metadata": { "kernelspec": { - "display_name": "pysam6", + "display_name": "pysam7", "language": "python", "name": "python3" }, diff --git a/examples/inputs/10-tidal-battery.yaml b/examples/inputs/10-tidal-battery.yaml index 60149d306..b8d6f3db4 100644 --- a/examples/inputs/10-tidal-battery.yaml +++ b/examples/inputs/10-tidal-battery.yaml @@ -72,41 +72,41 @@ technologies: # this is a dummy resource profile and does not # impact simulation when using timeseries data # TODO: Remove once PySAM Pypi updates - tidal_resource: - - [0.000000, 0.009000] - - [0.100000, 0.031000] - - [0.200000, 0.042000] - - [0.300000, 0.044000] - - [0.400000, 0.048000] - - [0.500000, 0.049000] - - [0.600000, 0.053000] - - [0.700000, 0.051000] - - [0.800000, 0.052000] - - [0.900000, 0.056000] - - [1.000000, 0.050000] - - [1.100000, 0.052000] - - [1.200000, 0.050000] - - [1.300000, 0.048000] - - [1.400000, 0.047000] - - [1.500000, 0.043000] - - [1.600000, 0.042000] - - [1.700000, 0.040000] - - [1.800000, 0.034000] - - [1.900000, 0.031000] - - [2.000000, 0.026000] - - [2.100000, 0.023000] - - [2.200000, 0.020000] - - [2.300000, 0.016000] - - [2.400000, 0.013000] - - [2.500000, 0.011000] - - [2.600000, 0.007000] - - [2.700000, 0.005000] - - [2.800000, 0.004000] - - [2.900000, 0.002000] - - [3.000000, 0.001000] - - [3.100000, 0.000000] - - [3.200000, 0.000000] - - [3.300000, 0.000000] + # tidal_resource: + # - [0.000000, 0.009000] + # - [0.100000, 0.031000] + # - [0.200000, 0.042000] + # - [0.300000, 0.044000] + # - [0.400000, 0.048000] + # - [0.500000, 0.049000] + # - [0.600000, 0.053000] + # - [0.700000, 0.051000] + # - [0.800000, 0.052000] + # - [0.900000, 0.056000] + # - [1.000000, 0.050000] + # - [1.100000, 0.052000] + # - [1.200000, 0.050000] + # - [1.300000, 0.048000] + # - [1.400000, 0.047000] + # - [1.500000, 0.043000] + # - [1.600000, 0.042000] + # - [1.700000, 0.040000] + # - [1.800000, 0.034000] + # - [1.900000, 0.031000] + # - [2.000000, 0.026000] + # - [2.100000, 0.023000] + # - [2.200000, 0.020000] + # - [2.300000, 0.016000] + # - [2.400000, 0.013000] + # - [2.500000, 0.011000] + # - [2.600000, 0.007000] + # - [2.700000, 0.005000] + # - [2.800000, 0.004000] + # - [2.900000, 0.002000] + # - [3.000000, 0.001000] + # - [3.100000, 0.000000] + # - [3.200000, 0.000000] + # - [3.300000, 0.000000] fin_model: !include default_fin_config.yaml battery: system_capacity_kwh: 80000 diff --git a/examples/legacy/analysis/main_usa_new.py b/examples/legacy/analysis/main_usa_new.py index 1d24efa90..05e482cfc 100644 --- a/examples/legacy/analysis/main_usa_new.py +++ b/examples/legacy/analysis/main_usa_new.py @@ -23,6 +23,7 @@ from hopp.tools.analysis import create_cost_calculator from hopp.tools.resource import * +from hopp.tools.resource.resource_tools import get_offset from hopp import ROOT_DIR diff --git a/examples/legacy/analysis/multi_location.py b/examples/legacy/analysis/multi_location.py index 7c32e1c8c..9a83b1aed 100644 --- a/examples/legacy/analysis/multi_location.py +++ b/examples/legacy/analysis/multi_location.py @@ -27,6 +27,7 @@ from hopp.simulation.hybrid_simulation import HybridSimulation from hopp.tools.analysis import create_cost_calculator from hopp.tools.resource import * +from hopp.tools.resource.resource_tools import get_offset from hopp.tools.resource.resource_loader import site_details_creator from hopp import ROOT_DIR resource_dir = ROOT_DIR / "simulation" / "resource_files" diff --git a/examples/legacy/analysis/single_location.py b/examples/legacy/analysis/single_location.py index 4ae27f81f..ba57bfbb6 100644 --- a/examples/legacy/analysis/single_location.py +++ b/examples/legacy/analysis/single_location.py @@ -24,6 +24,7 @@ from hopp.simulation.hybrid_simulation import HybridSimulation from hopp.tools.analysis import create_cost_calculator from hopp.tools.resource import * +from hopp.tools.resource.resource_tools import get_offset from hopp import ROOT_DIR resource_dir = ROOT_DIR / "simulation" / "resource_files" diff --git a/hopp/__init__.py b/hopp/__init__.py index cdd3d4ba6..4816ef04f 100644 --- a/hopp/__init__.py +++ b/hopp/__init__.py @@ -1,7 +1,7 @@ from pathlib import Path -__version__ = "3.3.0" +__version__ = "3.4.0" ROOT_DIR = Path(__file__).resolve().parent diff --git a/hopp/simulation/hopp.py b/hopp/simulation/hopp.py index 6f6e46e78..5a07df428 100644 --- a/hopp/simulation/hopp.py +++ b/hopp/simulation/hopp.py @@ -1,9 +1,10 @@ from __future__ import annotations import yaml +import warnings from attrs import define, field from pathlib import Path from typing import Optional, Union - +import numpy as np from hopp.simulation.base import BaseClass from hopp.simulation.hybrid_simulation import HybridSimulation, TechnologiesConfig from hopp.simulation.technologies.sites import SiteInfo @@ -69,6 +70,8 @@ def from_file(cls, input_file_path: Union[str, Path], filetype: Optional[str] = input_dict = load_yaml(input_file_path) else: raise ValueError("Supported import filetype is YAML") + + input_dict = overwrite_fin_values(input_dict) return Hopp.from_dict(input_dict) def to_file(self, output_file_path: str, filetype: str="YAML") -> None: @@ -83,3 +86,192 @@ def to_file(self, output_file_path: str, filetype: str="YAML") -> None: yaml.dump(self.as_dict(), f, default_flow_style=False) else: raise ValueError("Supported export filetype is YAML") + + + +def overwrite_fin_values(hopp_config): + """ + Overrides specific financial model values in the HOPP configuration with values from the `cost_info` section. + + This function ensures that the financial model values for technologies (e.g., wind, PV, battery) are updated + with the corresponding values provided in the `cost_info` section of the HOPP configuration. If discrepancies + are found between the values in the financial model and the `cost_info`, the financial model values are + overwritten, and a warning is issued to notify the user. + + Args: + hopp_config (dict): The HOPP configuration dictionary containing information about technologies, + financial models, and cost information. + + Expected structure: + - `technologies`: Contains technology-specific financial models (e.g., wind, PV, battery). + - `config`: Contains the `cost_info` section with updated cost values. + + Returns: + dict: The updated HOPP configuration dictionary with overwritten financial model values. + + Raises: + UserWarning: If a financial model value is overwritten due to a mismatch with the `cost_info` value. + + Notes: + - This function supports the following technologies: wind, PV (solar), and battery. + - The following financial model values can be overwritten in individual technology financial models: + - `om_capacity`: Fixed O&M costs per unit capacity [$/kW]. + - `om_production`: Variable O&M costs per unit production [$/MWh]. + + Example: + If the `cost_info` section specifies a new value for `wind_om_per_kw`, and this value differs from the + existing `om_capacity` value in the wind financial model, the `om_capacity` value will be updated, and + a warning will be issued. + """ + # override individual fin_model values with cost_info values + if ("config" in hopp_config.keys()) and ("cost_info" in hopp_config["config"]) and (hopp_config["config"]["cost_info"] is not None): + if "wind" in hopp_config["technologies"]: + if ("wind_om_per_kw" in hopp_config["config"]["cost_info"]) and ( + np.any(hopp_config["technologies"]["wind"]["fin_model"]["system_costs"]["om_capacity"] + != hopp_config["config"]["cost_info"]["wind_om_per_kw"]) + ): + for i in range( + len(hopp_config["technologies"]["wind"]["fin_model"]["system_costs"]["om_capacity"]) + ): + hopp_config["technologies"]["wind"]["fin_model"]["system_costs"]["om_capacity"][ + i + ] = hopp_config["config"]["cost_info"]["wind_om_per_kw"] + + om_fixed_wind_fin_model = hopp_config["technologies"]["wind"]["fin_model"][ + "system_costs" + ]["om_capacity"][i] + wind_om_per_kw = hopp_config["config"]["cost_info"]["wind_om_per_kw"] + msg = ( + f"'om_capacity[{i}]' in the wind 'fin_model' was {om_fixed_wind_fin_model}," + f" but 'wind_om_per_kw' in 'cost_info' was {wind_om_per_kw}. The 'om_capacity'" + " value in the wind 'fin_model' is being overwritten with the value from the" + " 'cost_info'" + ) + warnings.warn(msg, UserWarning) + if ("wind_om_per_mwh" in hopp_config["config"]["cost_info"]) and ( + hopp_config["technologies"]["wind"]["fin_model"]["system_costs"]["om_production"][0] + != hopp_config["config"]["cost_info"]["wind_om_per_mwh"] + ): + # Use this to set the Production-based O&M amount [$/MWh] + for i in range( + len( + hopp_config["technologies"]["wind"]["fin_model"]["system_costs"][ + "om_production" + ] + ) + ): + hopp_config["technologies"]["wind"]["fin_model"]["system_costs"]["om_production"][ + i + ] = hopp_config["config"]["cost_info"]["wind_om_per_mwh"] + om_wind_variable_cost = hopp_config["technologies"]["wind"]["fin_model"][ + "system_costs" + ]["om_production"][i] + wind_om_per_mwh = hopp_config["config"]["cost_info"]["wind_om_per_mwh"] + msg = ( + f"'om_production' in the wind 'fin_model' was {om_wind_variable_cost}, but" + f" 'wind_om_per_mwh' in 'cost_info' was {wind_om_per_mwh}. The 'om_production'" + " value in the wind 'fin_model' is being overwritten with the value from the" + " 'cost_info'" + ) + warnings.warn(msg, UserWarning) + + if "pv" in hopp_config["technologies"]: + if ("pv_om_per_kw" in hopp_config["config"]["cost_info"]) and ( + hopp_config["technologies"]["pv"]["fin_model"]["system_costs"]["om_capacity"][0] + != hopp_config["config"]["cost_info"]["pv_om_per_kw"] + ): + for i in range( + len(hopp_config["technologies"]["pv"]["fin_model"]["system_costs"]["om_capacity"]) + ): + hopp_config["technologies"]["pv"]["fin_model"]["system_costs"]["om_capacity"][i] = ( + hopp_config["config"]["cost_info"]["pv_om_per_kw"] + ) + + om_fixed_pv_fin_model = hopp_config["technologies"]["pv"]["fin_model"][ + "system_costs" + ]["om_capacity"][i] + pv_om_per_kw = hopp_config["config"]["cost_info"]["pv_om_per_kw"] + msg = ( + f"'om_capacity[{i}]' in the pv 'fin_model' was {om_fixed_pv_fin_model}, but" + f" 'pv_om_per_kw' in 'cost_info' was {pv_om_per_kw}. The 'om_capacity' value" + " in the pv 'fin_model' is being overwritten with the value from the" + " 'cost_info'" + ) + warnings.warn(msg, UserWarning) + if ("pv_om_per_mwh" in hopp_config["config"]["cost_info"]) and ( + hopp_config["technologies"]["pv"]["fin_model"]["system_costs"]["om_production"][0] + != hopp_config["config"]["cost_info"]["pv_om_per_mwh"] + ): + # Use this to set the Production-based O&M amount [$/MWh] + for i in range( + len(hopp_config["technologies"]["pv"]["fin_model"]["system_costs"]["om_production"]) + ): + hopp_config["technologies"]["pv"]["fin_model"]["system_costs"]["om_production"][ + i + ] = hopp_config["config"]["cost_info"]["pv_om_per_mwh"] + om_pv_variable_cost = hopp_config["technologies"]["pv"]["fin_model"]["system_costs"][ + "om_production" + ][i] + pv_om_per_mwh = hopp_config["config"]["cost_info"]["pv_om_per_mwh"] + msg = ( + f"'om_production' in the pv 'fin_model' was {om_pv_variable_cost}, but" + f" 'pv_om_per_mwh' in 'cost_info' was {pv_om_per_mwh}. The 'om_production' value" + " in the pv 'fin_model' is being overwritten with the value from the 'cost_info'" + ) + warnings.warn(msg, UserWarning) + + if "battery" in hopp_config["technologies"]: + if ("battery_om_per_kw" in hopp_config["config"]["cost_info"]) \ + and (hopp_config["technologies"]["battery"]["fin_model"]["system_costs"]["om_capacity"][0] + != hopp_config["config"]["cost_info"]["battery_om_per_kw"] + ): + for i in range( + len( + hopp_config["technologies"]["battery"]["fin_model"]["system_costs"][ + "om_capacity" + ] + ) + ): + hopp_config["technologies"]["battery"]["fin_model"]["system_costs"]["om_capacity"][ + i + ] = hopp_config["config"]["cost_info"]["battery_om_per_kw"] + + om_batt_fixed_cost = hopp_config["technologies"]["battery"]["fin_model"][ + "system_costs" + ]["om_capacity"][i] + battery_om_per_kw = hopp_config["config"]["cost_info"]["battery_om_per_kw"] + msg = ( + f"'om_capacity' in the battery 'fin_model' was {om_batt_fixed_cost}, but" + f" 'battery_om_per_kw' in 'cost_info' was {battery_om_per_kw}. The" + " 'om_capacity' value in the battery 'fin_model' is being overwritten with the" + " value from the 'cost_info'" + ) + warnings.warn(msg, UserWarning) + if ("battery_om_per_mwh" in hopp_config["config"]["cost_info"]) and ( + hopp_config["technologies"]["battery"]["fin_model"]["system_costs"]["om_production"][0] + != hopp_config["config"]["cost_info"]["battery_om_per_mwh"] + ): + # Use this to set the Production-based O&M amount [$/MWh] + for i in range( + len( + hopp_config["technologies"]["battery"]["fin_model"]["system_costs"][ + "om_production" + ] + ) + ): + hopp_config["technologies"]["battery"]["fin_model"]["system_costs"][ + "om_production" + ][i] = hopp_config["config"]["cost_info"]["battery_om_per_mwh"] + om_batt_variable_cost = hopp_config["technologies"]["battery"]["fin_model"][ + "system_costs" + ]["om_production"][i] + battery_om_per_mwh = hopp_config["config"]["cost_info"]["battery_om_per_mwh"] + msg = ( + f"'om_production' in the battery 'fin_model' was {om_batt_variable_cost}, but" + f" 'battery_om_per_mwh' in 'cost_info' was {battery_om_per_mwh}. The" + " 'om_production' value in the battery 'fin_model' is being overwritten with the" + " value from the 'cost_info'", + ) + warnings.warn(msg, UserWarning) + + return hopp_config diff --git a/hopp/simulation/hopp_interface.py b/hopp/simulation/hopp_interface.py index 3e0cab729..271f509aa 100644 --- a/hopp/simulation/hopp_interface.py +++ b/hopp/simulation/hopp_interface.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Union, TYPE_CHECKING -from hopp.simulation.hopp import Hopp, SiteInfo +from hopp.simulation.hopp import Hopp, overwrite_fin_values # avoid potential circular dep if TYPE_CHECKING: @@ -43,6 +43,7 @@ def reinitialize(self, configuration: Union[dict, str, Path]): self.hopp = Hopp.from_file(self.configuration) elif isinstance(self.configuration, dict): + self.configuration = overwrite_fin_values(self.configuration) self.hopp = Hopp.from_dict(self.configuration) def simulate(self, project_life: int = 25, lifetime_sim: bool = False): diff --git a/hopp/simulation/hybrid_simulation.py b/hopp/simulation/hybrid_simulation.py index 014aa1936..6c6e3b93f 100644 --- a/hopp/simulation/hybrid_simulation.py +++ b/hopp/simulation/hybrid_simulation.py @@ -14,7 +14,7 @@ from hopp.simulation.technologies.wind.wind_plant import WindPlant, WindConfig from hopp.simulation.technologies.csp.tower_plant import TowerConfig, TowerPlant from hopp.simulation.technologies.csp.trough_plant import TroughConfig, TroughPlant -from hopp.simulation.technologies.wave.mhk_wave_plant import MHKWavePlant, MHKConfig +from hopp.simulation.technologies.wave.mhk_wave_plant import MHKWavePlant, MHKWaveConfig from hopp.simulation.technologies.tidal.mhk_tidal_plant import MHKTidalPlant, MHKTidalConfig from hopp.simulation.technologies.generic.generic_plant import GenericConfig, GenericPlant from hopp.simulation.technologies.battery import Battery, BatteryConfig, BatteryStateless, BatteryStatelessConfig @@ -111,7 +111,7 @@ class TechnologiesConfig(BaseClass): """ pv: Optional[Union[PVConfig, DetailedPVConfig]] = field(default=None) wind: Optional[WindConfig] = field(default=None) - wave: Optional[MHKConfig] = field(default=None) + wave: Optional[MHKWaveConfig] = field(default=None) tidal: Optional[MHKTidalConfig] = field(default=None) generic: Optional[Union[GenericConfig,list[GenericConfig]]] = field(default=None) tower: Optional[TowerConfig] = field(default=None) @@ -139,7 +139,7 @@ def from_dict(cls, data: dict): config["wind"] = WindConfig.from_dict(data["wind"]) if "wave" in data: - config["wave"] = MHKConfig.from_dict(data["wave"]) + config["wave"] = MHKWaveConfig.from_dict(data["wave"]) if "tidal" in data: config["tidal"] = MHKTidalConfig.from_dict(data["tidal"]) diff --git a/hopp/simulation/technologies/dispatch/power_storage/heuristic_load_following_dispatch.py b/hopp/simulation/technologies/dispatch/power_storage/heuristic_load_following_dispatch.py index ebb006c0e..a753d679f 100644 --- a/hopp/simulation/technologies/dispatch/power_storage/heuristic_load_following_dispatch.py +++ b/hopp/simulation/technologies/dispatch/power_storage/heuristic_load_following_dispatch.py @@ -1,4 +1,4 @@ -from typing import Optional, List +from typing import Optional, List, Dict, Union, TYPE_CHECKING import pyomo.environ as pyomo from pyomo.environ import units as u @@ -8,7 +8,10 @@ from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch_heuristic import ( SimpleBatteryDispatchHeuristic, ) - +if TYPE_CHECKING: + from hopp.simulation.technologies.dispatch.hybrid_dispatch_builder_solver import ( + HybridDispatchOptions + ) class HeuristicLoadFollowingDispatch(SimpleBatteryDispatchHeuristic): """Operates the battery based on heuristic rules to meet the demand profile based power available from power generation profiles and @@ -26,7 +29,7 @@ def __init__( financial_model: Singleowner.Singleowner, fixed_dispatch: Optional[List] = None, block_set_name: str = "heuristic_load_following_battery", - dispatch_options: Optional[dict] = None, + dispatch_options: Optional[Union[Dict, "HybridDispatchOptions"]] = None, ): """Initialize HeuristicLoadFollowingDispatch. diff --git a/hopp/simulation/technologies/financial/custom_financial_model.py b/hopp/simulation/technologies/financial/custom_financial_model.py index 124152ba8..c7de16039 100644 --- a/hopp/simulation/technologies/financial/custom_financial_model.py +++ b/hopp/simulation/technologies/financial/custom_financial_model.py @@ -3,7 +3,7 @@ import inspect from typing import Sequence, List import numpy as np -from hopp.tools.utils import flatten_dict, equal +from hopp.utilities import flatten_dict, equal from hopp.simulation.base import BaseClass import ProFAST @@ -450,7 +450,7 @@ def npv(rate: float, net_cash_flow: List[float]): @staticmethod def nominal_discount_rate(inflation_rate: float, real_discount_rate: float): """ - Computes the nominal discount rate [%] + Computes the nominal discount rate [%] using the Fisher equation. :param inflation_rate: inflation rate [%] :param real_discount_rate: real discount rate [%] @@ -470,7 +470,6 @@ def nominal_discount_rate(inflation_rate: float, real_discount_rate: float): return ( (1 + real_discount_rate / 100) * (1 + inflation_rate / 100) - 1 ) * 100 - def net_cash_flow(self, project_life=25): """ Computes the net cash flow timeseries of annual values over lifetime @@ -508,7 +507,6 @@ def o_and_m_cost(self): """ Computes the annual O&M cost from the fixed, per capacity and per production costs """ - return self.value('om_fixed')[0] \ + self.value('om_capacity')[0] * self.value('system_capacity') \ + self.value('om_production')[0] * self.value('annual_energy_kwh') * 1e-3 diff --git a/hopp/simulation/technologies/financial/mhk_cost_model.py b/hopp/simulation/technologies/financial/mhk_cost_model.py index 40ae1cccd..221d279b4 100644 --- a/hopp/simulation/technologies/financial/mhk_cost_model.py +++ b/hopp/simulation/technologies/financial/mhk_cost_model.py @@ -8,7 +8,7 @@ # avoid circular dep if TYPE_CHECKING: - from hopp.simulation.technologies.wave.mhk_wave_plant import MHKConfig + from hopp.simulation.technologies.wave.mhk_wave_plant import MHKWaveConfig @define class MHKCostModelInputs(BaseClass): @@ -65,7 +65,7 @@ class MHKCosts(BaseClass): ValueError: If any of the required keys in `mhk_config` or `cost_model_inputs` are missing. """ - mhk_config: "MHKConfig" + mhk_config: "MHKWaveConfig" cost_model_inputs: MHKCostModelInputs _device_rated_power: float = field(init=False) diff --git a/hopp/simulation/technologies/layout/pv_inverter.py b/hopp/simulation/technologies/layout/pv_inverter.py index 930876b6f..350dbf1be 100644 --- a/hopp/simulation/technologies/layout/pv_inverter.py +++ b/hopp/simulation/technologies/layout/pv_inverter.py @@ -2,7 +2,7 @@ import PySAM.Pvsamv1 as pv_detailed import PySAM.Pvwattsv8 as pv_simple -from hopp.tools.utils import flatten_dict +from hopp.utilities import flatten_dict def get_inverter_attribs(model: Union[pv_simple.Pvwattsv8, pv_detailed.Pvsamv1, dict], only_ref_values=True) -> dict: """ diff --git a/hopp/simulation/technologies/layout/pv_module.py b/hopp/simulation/technologies/layout/pv_module.py index d2fdd7064..d26da2048 100644 --- a/hopp/simulation/technologies/layout/pv_module.py +++ b/hopp/simulation/technologies/layout/pv_module.py @@ -4,7 +4,7 @@ import PySAM.Pvsamv1 as pv_detailed import PySAM.Pvwattsv8 as pv_simple -from hopp.tools.utils import flatten_dict +from hopp.utilities import flatten_dict # PVWatts default module # pvmismatch standard module description diff --git a/hopp/simulation/technologies/layout/shadow_flicker.py b/hopp/simulation/technologies/layout/shadow_flicker.py index 30a13b44b..7b8d7443a 100644 --- a/hopp/simulation/technologies/layout/shadow_flicker.py +++ b/hopp/simulation/technologies/layout/shadow_flicker.py @@ -8,9 +8,9 @@ from shapely.geometry import Point from shapely.geometry import Polygon, MultiPolygon, MultiPoint from shapely.ops import unary_union -import timezonefinder from pysolar.solar import * from pvmismatch import * +import timezonefinder from hopp.simulation.technologies.layout.pv_module import * diff --git a/hopp/simulation/technologies/layout/wind_layout.py b/hopp/simulation/technologies/layout/wind_layout.py index 54436aec9..a1aa3d47c 100644 --- a/hopp/simulation/technologies/layout/wind_layout.py +++ b/hopp/simulation/technologies/layout/wind_layout.py @@ -246,9 +246,12 @@ def __attrs_post_init__(self): if isinstance(self._system_model, Floris): self.turb_pos_x, self.turb_pos_y = self._system_model.wind_farm_layout else: - self.turb_pos_x = self._system_model.value("wind_farm_xCoordinates") - self.turb_pos_y = self._system_model.value("wind_farm_yCoordinates") - + if 'wind_farm_xCoordinates' in self._system_model.Farm.export(): + self.turb_pos_x = self._system_model.value("wind_farm_xCoordinates") + self.turb_pos_y = self._system_model.value("wind_farm_yCoordinates") + else: + self.turb_pos_x = [0] + self.turb_pos_y = [0] if isinstance(self.parameters, dict): if self.layout_mode == 'boundarygrid': self.parameters = WindBoundaryGridParameters.from_dict(self.parameters) diff --git a/hopp/simulation/technologies/power_source.py b/hopp/simulation/technologies/power_source.py index bf31f91db..6d99b1ffb 100644 --- a/hopp/simulation/technologies/power_source.py +++ b/hopp/simulation/technologies/power_source.py @@ -7,7 +7,7 @@ from hopp.simulation.technologies.sites.site_info import SiteInfo from hopp.utilities.log import hybrid_logger as logger from hopp.simulation.technologies.dispatch.power_sources.power_source_dispatch import PowerSourceDispatch -from hopp.tools.utils import array_not_scalar, equal +from hopp.utilities import array_not_scalar, equal from hopp.utilities.log import hybrid_logger as logger from hopp.simulation.base import BaseClass diff --git a/hopp/simulation/technologies/pv/detailed_pv_plant.py b/hopp/simulation/technologies/pv/detailed_pv_plant.py index 91f45253d..90db16ec5 100644 --- a/hopp/simulation/technologies/pv/detailed_pv_plant.py +++ b/hopp/simulation/technologies/pv/detailed_pv_plant.py @@ -19,7 +19,7 @@ ) from hopp.simulation.base import BaseClass -from hopp.tools.utils import flatten_dict +from hopp.utilities import flatten_dict @define diff --git a/hopp/simulation/technologies/resource/solar_resource.py b/hopp/simulation/technologies/resource/solar_resource.py index ac16a3c36..8fd6299c5 100644 --- a/hopp/simulation/technologies/resource/solar_resource.py +++ b/hopp/simulation/technologies/resource/solar_resource.py @@ -12,7 +12,7 @@ from hopp import ROOT_DIR -BASE_URL = "https://developer.nrel.gov/api/nsrdb/v2/solar/psm3-2-2-download.csv" +BASE_URL = "https://developer.nrel.gov/api/nsrdb/v2/solar/nsrdb-GOES-aggregated-v4-0-0-download.csv" class SolarResource(Resource): diff --git a/hopp/simulation/technologies/tidal/mhk_tidal_plant.py b/hopp/simulation/technologies/tidal/mhk_tidal_plant.py index ae6d8fd3a..899249bdf 100644 --- a/hopp/simulation/technologies/tidal/mhk_tidal_plant.py +++ b/hopp/simulation/technologies/tidal/mhk_tidal_plant.py @@ -19,10 +19,6 @@ class MHKTidalConfig(BaseClass): device_rating_kw (float): Rated power of the MHK device [kW] num_devices (int): Number of MHK tidal devices in the system tidal_power_curve (List[List[float]]): Power curve of tidal energy device as function of stream speeds [kW] - tidal_resource (List[List[float]]): Required by the PySAM MhkTidal module for initialization. Although this parameter - is not actively used in HOPP's timeseries simulation mode, it must still be provided to fully - instantiate the PySAM MhkTidal model. - Frequency distribution of resource as a function of stream speeds. fin_model (obj | dict): Optional financial model. Can be any of the following: - a dict representing a `CustomFinancialModel` - an object representing a `CustomFinancialModel` instance @@ -36,7 +32,6 @@ class MHKTidalConfig(BaseClass): device_rating_kw: float = field(validator=gt_zero) num_devices: int = field(validator=gt_zero) tidal_power_curve: List[List[float]] - tidal_resource: List[List[float]] fin_model: Union[dict, CustomFinancialModel] loss_array_spacing: float = field(default=0., validator=range_val(0, 100)) loss_resource_overprediction: float = field(default=0., validator=range_val(0, 100)) @@ -93,7 +88,6 @@ def __attrs_post_init__(self): self._system_model.device_rated_power = self.config.device_rating_kw self._system_model.value("number_devices", self.config.num_devices) self._system_model.value("tidal_power_curve", self.config.tidal_power_curve) - self._system_model.value("tidal_resource", self.config.tidal_resource) # Losses loss_attributes = [ diff --git a/hopp/simulation/technologies/wave/mhk_wave_plant.py b/hopp/simulation/technologies/wave/mhk_wave_plant.py index 71da8986a..c8844fd31 100644 --- a/hopp/simulation/technologies/wave/mhk_wave_plant.py +++ b/hopp/simulation/technologies/wave/mhk_wave_plant.py @@ -11,7 +11,7 @@ @define -class MHKConfig(BaseClass): +class MHKWaveConfig(BaseClass): """ Configuration class for MHKWavePlant. @@ -56,7 +56,7 @@ class MHKWavePlant(PowerSource): """ site: SiteInfo - config: MHKConfig + config: MHKWaveConfig cost_model_inputs: Optional[MHKCostModelInputs] = field(default=None) config_name: str = field(default="MhkWave") diff --git a/hopp/simulation/technologies/wind/wind_plant.py b/hopp/simulation/technologies/wind/wind_plant.py index 57f21f527..b7a65c3b2 100644 --- a/hopp/simulation/technologies/wind/wind_plant.py +++ b/hopp/simulation/technologies/wind/wind_plant.py @@ -105,7 +105,7 @@ class WindConfig(BaseClass): validator=contains(["pysam", "floris"]), converter=(str.strip, str.lower) ) - model_input_file: Optional[str] = field(default=None) + model_input_file: Optional[Union[str,dict]] = field(default=None) rating_range_kw: Tuple[int, int] = field(default=(1000, 3000)) floris_config: Optional[Union[dict, str, Path]] = field(default=None) adjust_air_density_for_elevation: Optional[bool] = field(default=False) @@ -186,19 +186,40 @@ def __attrs_post_init__(self): system_model = Windpower.default(self.config_name) else: # initialize system using pysam input file - input_file_path = resource_file_converter(self.config.model_input_file) - input_dict = load_yaml(input_file_path) - - system_model = Windpower.new() + if isinstance(self.config.model_input_file,str): + input_dict = load_yaml(self.config.model_input_file) + else: + input_dict = self.config.model_input_file + try: + nTurbs = len(input_dict['Farm']['wind_farm_xCoordinates']) + except KeyError: + nTurbs = 0 + if nTurbs==self.config.num_turbines: + self.config.layout_mode = 'custom' + self.config.layout_params = { + 'layout_x': input_dict['Farm']['wind_farm_xCoordinates'], + 'layout_y': input_dict['Farm']['wind_farm_yCoordinates'], + } + print("Using wind layout found in model_input_file, changing layout_mode to custom.") + + resource = input_dict.get("Resource", {}) + user_provided_data = False if resource.get("wind_resource_data", None) is None else True + user_provided_distribution = False if resource.get("wind_resource_distribution", None) is None else True + user_provided_weibull = False if resource.get("weibull_wind_speed:", None) is None else True + input_dict.setdefault('Resource',{}) + if not user_provided_data and not user_provided_distribution and not user_provided_weibull: + input_dict['Resource'].update({"wind_resource_data": self.site.wind_resource.data}) + user_provided_data = True + if user_provided_data: + input_dict['Resource'].setdefault("wind_resource_model_choice",0) + if user_provided_weibull: + input_dict['Resource'].setdefault("wind_resource_model_choice",1) + if user_provided_distribution: + input_dict['Resource'].setdefault("wind_resource_model_choice",2) + + system_model = Windpower.default(self.config_name) system_model.assign(input_dict) - wind_farm_xCoordinates = input_dict['Farm']['wind_farm_xCoordinates'] - nTurbs = len(wind_farm_xCoordinates) - 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 - if financial_model is None: # default financial_model = Singleowner.from_existing(system_model, self.config_name) diff --git a/hopp/tools/resource/__init__.py b/hopp/tools/resource/__init__.py index e21bf6934..eb49d1637 100644 --- a/hopp/tools/resource/__init__.py +++ b/hopp/tools/resource/__init__.py @@ -1,2 +1,2 @@ -from .resource_tools import get_country, filter_sites, get_offset, extrapolate_wind_speed +from .resource_tools import get_country, filter_sites, extrapolate_wind_speed from .resource_loader.resource_loader_files import resource_loader_file diff --git a/hopp/tools/resource/resource_tools.py b/hopp/tools/resource/resource_tools.py index 286bec65d..4c6338523 100644 --- a/hopp/tools/resource/resource_tools.py +++ b/hopp/tools/resource/resource_tools.py @@ -10,13 +10,12 @@ from datetime import datetime from pytz import timezone, utc -from timezonefinder import TimezoneFinder from global_land_mask import globe from shapely.geometry import shape from shapely.prepared import prep from shapely.geometry import Point import requests -import pandas as pd +import timezonefinder def get_country(lat, lon, geo_data): @@ -79,7 +78,7 @@ def get_offset(lat, long): :return: """ today = datetime.now() - tf = TimezoneFinder() + tf = timezonefinder.TimezoneFinder() tz_target = timezone(tf.timezone_at(lng=long, lat=lat)) if not tz_target: raise ValueError("tz_target error") diff --git a/hopp/tools/utils.py b/hopp/tools/utils.py deleted file mode 100644 index 6a067003d..000000000 --- a/hopp/tools/utils.py +++ /dev/null @@ -1,81 +0,0 @@ -import numpy as np -from typing import Sequence - - -def flatten_dict(d): - def get_key_values(d): - for key, value in d.items(): - if isinstance(value, dict): - yield from get_key_values(value) - else: - yield key, value - - return {key:value for (key,value) in get_key_values(d)} - -def equal(a, b): - """Determines whether integers, floats, lists, tupes or dictionaries are equal""" - if isinstance(a, (int, float)): - return np.isclose(a, b) - elif isinstance(a, (list, tuple)): - if len(a) != len(b): - return False - else: - for i in range(len(a)): - if not np.isclose(a[i], b[i]): - return False - return True - elif isinstance(a, dict): - if len(a) != len(b): - return False - else: - for key in a.keys(): - if key not in b.keys(): - return False - if not np.isclose(a[key], b[key]): - return False - return True - else: - raise Exception('Type not recognized') - -def export_all(obj): - """ - Exports all variables from pysam objects including those not assigned - - Assumes the object is a collection of objects with all the variables within them: - obj: - object1: - variable1: - variable2: - - """ - output_dict = {} - for attribute_name in dir(obj): - try: - attribute = getattr(obj, attribute_name) - except: - continue - if not callable(attribute) and not attribute_name.startswith('__'): - output_dict[attribute_name] = {} - for subattribute_name in dir(attribute): - if subattribute_name.startswith('__'): - continue - try: - subattribute = getattr(attribute, subattribute_name) - except Exception as e: - if 'not assigned' in str(e): - output_dict[attribute_name][subattribute_name] = None - continue - else: - continue - if not callable(subattribute): - output_dict[attribute_name][subattribute_name] = subattribute - - # Remove dictionary if empty - if len(output_dict[attribute_name]) == 0: - del output_dict[attribute_name] - - return output_dict - -def array_not_scalar(array): - """Return True if array is array-like and not a scalar""" - return isinstance(array, Sequence) or (isinstance(array, np.ndarray) and hasattr(array, "__len__")) \ No newline at end of file diff --git a/hopp/utilities/__init__.py b/hopp/utilities/__init__.py index bdb1fb062..e3a5ce5b9 100644 --- a/hopp/utilities/__init__.py +++ b/hopp/utilities/__init__.py @@ -1 +1 @@ -from .utilities import load_yaml \ No newline at end of file +from .utilities import load_yaml, write_yaml, check_create_folder, flatten_dict, equal, array_not_scalar \ No newline at end of file diff --git a/hopp/utilities/log.py b/hopp/utilities/log.py index c6e2b0327..61b81cb54 100644 --- a/hopp/utilities/log.py +++ b/hopp/utilities/log.py @@ -3,50 +3,50 @@ import logging from datetime import datetime from pathlib import Path - -try: - from mpi4py import MPI -except: - MPI = False - -# set to logging.WARNING for fewer messages -logging_level = logging.INFO - -# level to print to console -console_level = logging.WARNING - +from hopp import ROOT_DIR # set up logging to file - see previous section for more details formatter = logging.Formatter('%(name)-12s: %(levelname)-8s %(message)s') -if MPI: - print("logging to stdout") - logging.basicConfig(level=logging_level, - datefmt='%m-%d %H:%M', - stream=sys.stdout) - handler = logging.StreamHandler() - handler.setFormatter(formatter) +if os.getenv("ENABLE_HOPP_LOGGING",default=False): + log_level = os.getenv("HOPP_LOG_LEVEL",default="INFO") + if log_level.upper() == "INFO": + logging_level = logging.INFO + if log_level.upper() == "WARNING": + logging_level = logging.WARNING + if log_level.upper() == "DEBUG": + logging_level = logging.DEBUG + + # setup logging to file + if os.getenv("HOPP_LOG_TO_FILE",default=True): + run_suffix = '_' + datetime.now().isoformat().replace(':', '.') + log_path = Path.cwd() / "log" + if not os.path.isdir(log_path): + os.mkdir(log_path) + log_path = log_path / ("hybrid_systems" + run_suffix + ".log") + + logging.basicConfig(level=logging_level, + datefmt='%m/%d/%Y %I:%M:%S %p', + filename=str(log_path), + filemode='w') + + handler = logging.FileHandler(str(log_path)) + handler.setFormatter(formatter) + else: + # setup logging to console + logging.basicConfig(level=logging_level, + datefmt='%m-%d %H:%M', + stream=sys.stdout) + handler = logging.StreamHandler() + handler.setFormatter(formatter) + handler.setLevel(logging_level) else: - run_suffix = '_' + datetime.now().isoformat().replace(':', '.') - log_path = Path.cwd() / "log" + log_path = ROOT_DIR.parent / "log" if not os.path.isdir(log_path): os.mkdir(log_path) - log_path = log_path / ("hybrid_systems" + run_suffix + ".log") - print(log_path) - # logging.basicConfig(level=logging_level, - # datefmt='%m-%d %H:%M', - # filename=str(log_path), - # filemode='w') + log_path = log_path / ("empty_log.log") handler = logging.FileHandler(str(log_path)) handler.setFormatter(formatter) -# define a Handler which writes WARNING messages or higher to the sys.stderr -console = logging.StreamHandler() -console.setLevel(console_level) - - -# Now, define a couple of other loggers which might represent areas in your -# application: - hybrid_logger = logging.getLogger('HybridSim') flicker_logger = hybrid_logger bos_logger = hybrid_logger @@ -55,8 +55,6 @@ hybrid_logger.addHandler(handler) opt_logger.addHandler(handler) -hybrid_logger.addHandler(console) -opt_logger.addHandler(console) logging.getLogger('').propagate = False logging.getLogger('HybridSim').propagate = False diff --git a/hopp/utilities/utilities.py b/hopp/utilities/utilities.py index 116e77ecc..8892c5b11 100644 --- a/hopp/utilities/utilities.py +++ b/hopp/utilities/utilities.py @@ -1,5 +1,8 @@ import os import yaml +import numpy as np +from typing import Sequence + class Loader(yaml.SafeLoader): @@ -38,3 +41,43 @@ def write_yaml(filename,data): with open(filename, 'w+') as file: yaml.dump(data, file,sort_keys=False,encoding = None,default_flow_style=False) + + +def flatten_dict(d): + def get_key_values(d): + for key, value in d.items(): + if isinstance(value, dict): + yield from get_key_values(value) + else: + yield key, value + + return {key:value for (key,value) in get_key_values(d)} + +def equal(a, b): + """Determines whether integers, floats, lists, tuples or dictionaries are equal""" + if isinstance(a, (int, float)): + return np.isclose(a, b) + elif isinstance(a, (list, tuple)): + if len(a) != len(b): + return False + else: + for i in range(len(a)): + if not np.isclose(a[i], b[i]): + return False + return True + elif isinstance(a, dict): + if len(a) != len(b): + return False + else: + for key in a.keys(): + if key not in b.keys(): + return False + if not np.isclose(a[key], b[key]): + return False + return True + else: + raise Exception('Type not recognized') + +def array_not_scalar(array): + """Return True if array is array-like and not a scalar""" + return isinstance(array, Sequence) or (isinstance(array, np.ndarray) and hasattr(array, "__len__")) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 43a33e040..5056ee3fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,11 +8,11 @@ dynamic = ["version"] authors = [{name = "NREL", email = "dguittet@nrel.gov"}] readme = {file = "README.md", content-type = "text/markdown"} description = "Hybrid Systems Optimization and Performance Platform." -requires-python = ">=3.10, <3.12" +requires-python = ">=3.11" license = {file = "LICENSE"} dependencies = [ "Cython", - "NREL-PySAM>=6.0.1", + "NREL-PySAM>=7.0.0", "Pillow", "Pyomo>=6.1.2", "fastkml<1", @@ -42,8 +42,7 @@ dependencies = [ "scipy", "shapely>=2", "setuptools", - "timezonefinder", - "urllib3", + "timezonefinder==6.5.9", "openpyxl", "attrs", "utm", @@ -69,8 +68,9 @@ classifiers = [ # https://pypi.org/classifiers/ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", ] @@ -87,7 +87,7 @@ develop = [ "pytest-subtests", "pytest-dependency", "responses", - "jupyter-book", + "jupyter-book<2", "sphinxcontrib-napoleon", ] examples = ["jupyterlab"] diff --git a/tests/hopp/inputs/pysam_turbine_input.yaml b/tests/hopp/inputs/pysam_turbine_input.yaml new file mode 100644 index 000000000..8c398eb0f --- /dev/null +++ b/tests/hopp/inputs/pysam_turbine_input.yaml @@ -0,0 +1,329 @@ +Turbine: + wind_resource_shear: 0.14 + wind_turbine_hub_ht: 80.0 + wind_turbine_max_cp: 0.45 + wind_turbine_powercurve_powerout: + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 25.71 + - 37.14 + - 54.29 + - 77.14 + - 111.43 + - 145.71 + - 185.71 + - 231.43 + - 300.0 + - 334.29 + - 391.43 + - 454.29 + - 511.43 + - 602.85 + - 654.29 + - 734.29 + - 820.0 + - 905.71 + - 1000.0 + - 1070.0 + - 1170.0 + - 1280.0 + - 1380.0 + - 1510.0 + - 1640.0 + - 1790.0 + - 1920.0 + - 2010.0 + - 2110.0 + - 2190.0 + - 2260.0 + - 2320.0 + - 2370.0 + - 2420.0 + - 2460.0 + - 2480.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 2500.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + wind_turbine_powercurve_windspeeds: + - 0.0 + - 0.25 + - 0.5 + - 0.75 + - 1.0 + - 1.25 + - 1.5 + - 1.75 + - 2.0 + - 2.25 + - 2.5 + - 2.75 + - 3.0 + - 3.25 + - 3.5 + - 3.75 + - 4.0 + - 4.25 + - 4.5 + - 4.75 + - 5.0 + - 5.25 + - 5.5 + - 5.75 + - 6.0 + - 6.25 + - 6.5 + - 6.75 + - 7.0 + - 7.25 + - 7.5 + - 7.75 + - 8.0 + - 8.25 + - 8.5 + - 8.75 + - 9.0 + - 9.25 + - 9.5 + - 9.75 + - 10.0 + - 10.25 + - 10.5 + - 10.75 + - 11.0 + - 11.25 + - 11.5 + - 11.75 + - 12.0 + - 12.25 + - 12.5 + - 12.75 + - 13.0 + - 13.25 + - 13.5 + - 13.75 + - 14.0 + - 14.25 + - 14.5 + - 14.75 + - 15.0 + - 15.25 + - 15.5 + - 15.75 + - 16.0 + - 16.25 + - 16.5 + - 16.75 + - 17.0 + - 17.25 + - 17.5 + - 17.75 + - 18.0 + - 18.25 + - 18.5 + - 18.75 + - 19.0 + - 19.25 + - 19.5 + - 19.75 + - 20.0 + - 20.25 + - 20.5 + - 20.75 + - 21.0 + - 21.25 + - 21.5 + - 21.75 + - 22.0 + - 22.25 + - 22.5 + - 22.75 + - 23.0 + - 23.25 + - 23.5 + - 23.75 + - 24.0 + - 24.25 + - 24.5 + - 24.75 + - 25.0 + - 25.25 + - 25.5 + - 25.75 + - 26.0 + - 26.25 + - 26.5 + - 26.75 + - 27.0 + - 27.25 + - 27.5 + - 27.75 + - 28.0 + - 28.25 + - 28.5 + - 28.75 + - 29.0 + - 29.25 + - 29.5 + - 29.75 + - 30.0 + - 30.25 + - 30.5 + - 30.75 + - 31.0 + - 31.25 + - 31.5 + - 31.75 + - 32.0 + - 32.25 + - 32.5 + - 32.75 + - 33.0 + - 33.25 + - 33.5 + - 33.75 + - 34.0 + - 34.25 + - 34.5 + - 34.75 + - 35.0 + - 35.25 + - 35.5 + - 35.75 + - 36.0 + - 36.25 + - 36.5 + - 36.75 + - 37.0 + - 37.25 + - 37.5 + - 37.75 + - 38.0 + - 38.25 + - 38.5 + - 38.75 + - 39.0 + - 39.25 + - 39.5 + - 39.75 + - 40.0 + wind_turbine_rotor_diameter: 100.0 \ No newline at end of file diff --git a/tests/hopp/inputs/tidal/tidal_device.yaml b/tests/hopp/inputs/tidal/tidal_device.yaml index 4c2d24a6a..6b5b1209e 100644 --- a/tests/hopp/inputs/tidal/tidal_device.yaml +++ b/tests/hopp/inputs/tidal/tidal_device.yaml @@ -45,43 +45,4 @@ tidal_power_curve: - [3.200000, 1085.370000] - [3.300000, 1055.730000] -num_devices: 20 -# Tidal resource is required in PySAM prechecks -# this is a dummy resource profile and does not -# impact simulation when using timeseries data -# TODO: Remove once PySAM Pypi updates -tidal_resource: -- [0.000000, 0.009000] -- [0.100000, 0.031000] -- [0.200000, 0.042000] -- [0.300000, 0.044000] -- [0.400000, 0.048000] -- [0.500000, 0.049000] -- [0.600000, 0.053000] -- [0.700000, 0.051000] -- [0.800000, 0.052000] -- [0.900000, 0.056000] -- [1.000000, 0.050000] -- [1.100000, 0.052000] -- [1.200000, 0.050000] -- [1.300000, 0.048000] -- [1.400000, 0.047000] -- [1.500000, 0.043000] -- [1.600000, 0.042000] -- [1.700000, 0.040000] -- [1.800000, 0.034000] -- [1.900000, 0.031000] -- [2.000000, 0.026000] -- [2.100000, 0.023000] -- [2.200000, 0.020000] -- [2.300000, 0.016000] -- [2.400000, 0.013000] -- [2.500000, 0.011000] -- [2.600000, 0.007000] -- [2.700000, 0.005000] -- [2.800000, 0.004000] -- [2.900000, 0.002000] -- [3.000000, 0.001000] -- [3.100000, 0.000000] -- [3.200000, 0.000000] -- [3.300000, 0.000000] \ No newline at end of file +num_devices: 20 \ No newline at end of file diff --git a/tests/hopp/test_dispatch.py b/tests/hopp/test_dispatch.py index 27fd715e7..25363a377 100644 --- a/tests/hopp/test_dispatch.py +++ b/tests/hopp/test_dispatch.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest import pyomo.environ as pyomo +import numpy as np from pyomo.environ import units as u from pyomo.opt import TerminationCondition from pyomo.util.check_units import assert_units_consistent @@ -12,7 +13,7 @@ from hopp.simulation.technologies.pv.pv_plant import PVPlant, PVConfig from hopp.simulation.technologies.csp.tower_plant import TowerPlant, TowerConfig from hopp.simulation.technologies.csp.trough_plant import TroughPlant, TroughConfig -from hopp.simulation.technologies.wave.mhk_wave_plant import MHKWavePlant, MHKConfig +from hopp.simulation.technologies.wave.mhk_wave_plant import MHKWavePlant, MHKWaveConfig from hopp.simulation.technologies.financial.mhk_cost_model import MHKCostModelInputs from hopp.simulation.technologies.dispatch.power_sources.csp_dispatch import CspDispatch from hopp.simulation.technologies.dispatch.power_sources.tower_dispatch import TowerDispatch @@ -22,6 +23,7 @@ from hopp.simulation.technologies.dispatch.power_storage.linear_voltage_convex_battery_dispatch import ConvexLinearVoltageBatteryDispatch from hopp.simulation.technologies.dispatch.power_storage.simple_battery_dispatch import SimpleBatteryDispatch +from hopp.simulation.technologies.dispatch.power_storage.heuristic_load_following_dispatch import HeuristicLoadFollowingDispatch from hopp.simulation.technologies.dispatch.hybrid_dispatch_builder_solver import HybridDispatchBuilderSolver, HybridDispatchOptions from hopp.simulation.technologies.dispatch.power_sources.pv_dispatch import PvDispatch from hopp.simulation.technologies.dispatch.power_sources.wind_dispatch import WindDispatch @@ -359,7 +361,7 @@ def test_wave_dispatch(): financial_model = {'fin_model': DEFAULT_FIN_CONFIG} mhk_config.update(financial_model) - config = MHKConfig.from_dict(mhk_config) + config = MHKWaveConfig.from_dict(mhk_config) cost_model_input = MHKCostModelInputs.from_dict({ 'reference_model_num':3, @@ -524,6 +526,74 @@ def create_test_objective_rule(m): assert battery.outputs.P[i] == pytest.approx(dispatch_power, 1e-3 * abs(dispatch_power)) +def test_heuristic_load_following_dispatch(site): + dispatch_n_look_ahead = 48 # note this must be an even number for this test + + # Load in battery config + config = BatteryConfig.from_dict(technologies['battery']) + battery = Battery(site, config=config) + + # Create pyomo model + model = pyomo.ConcreteModel(name='battery_only') + model.forecast_horizon = pyomo.Set(initialize=range(dispatch_n_look_ahead)) + + # Instantiate battery dispatch + battery._dispatch = HeuristicLoadFollowingDispatch( + pyomo_model=model, + index_set=model.forecast_horizon, + system_model=battery._system_model, + financial_model=battery._financial_model, + block_set_name="heuristic_load_following_battery", + dispatch_options=HybridDispatchOptions( + { + 'include_lifecycle_count': False, + 'battery_dispatch': 'load_following_heuristic', + } + ), + ) + + # Setup dispatch for battery + battery.dispatch.initialize_parameters() + battery.dispatch.update_time_series_parameters(0) + battery.dispatch.update_dispatch_initial_soc(battery.dispatch.minimum_soc) # Set initial SOC to minimum + assert_units_consistent(model) + + # Generate test data for n horizon, charging for first half of the horizon, + # then discharging for the latter half. + tot_gen = np.ones(dispatch_n_look_ahead) + n_look_ahead_half = int(dispatch_n_look_ahead / 2) + grid_limit = np.concatenate( + (np.ones(n_look_ahead_half) * 0.9, np.ones(n_look_ahead_half) * 1.1) + ) + + # Set the dispatch pyomo variables + battery.dispatch.set_fixed_dispatch(tot_gen, grid_limit, grid_limit) + + # Simulate the battery with the heuristic dispatch + battery.simulate_with_dispatch(n_periods=dispatch_n_look_ahead, sim_start_time=0) + + # Check that the power is being assigned correctly to the battery outputs + dispatch_power = np.empty(dispatch_n_look_ahead) + for i in range(dispatch_n_look_ahead): + dispatch_power[i] = battery.dispatch.power[i] * 1e3 + assert battery.outputs.P[i] == pytest.approx( + dispatch_power[i], 1e-3 * abs(dispatch_power[i]) + ) + + # Check that the has non-zero charge and discharge power, and that the sum of charge + # and discharge power are equal. + assert sum(battery.dispatch.charge_power) > 0.0 + assert sum(battery.dispatch.discharge_power) > 0.0 + assert (sum(battery.dispatch.charge_power) #* battery.dispatch.round_trip_efficiency / 100.0 + == pytest.approx(sum(battery.dispatch.discharge_power))) + + # Check that the dispatch values have not changed + expected_dispatch_power = np.concatenate( + (np.ones(n_look_ahead_half) * -100., np.ones(n_look_ahead_half) * 100.) + ) + np.testing.assert_allclose(dispatch_power, expected_dispatch_power) + + def test_simple_battery_dispatch_lifecycle_count(site): expected_objective = 24378.6 expected_lifecycles = [0.75048, 1.50096] diff --git a/tests/hopp/test_hybrid.py b/tests/hopp/test_hybrid.py index 3c065d4e8..ff1543c4d 100644 --- a/tests/hopp/test_hybrid.py +++ b/tests/hopp/test_hybrid.py @@ -449,7 +449,6 @@ def test_hybrid_tidal_only(hybrid_config, mhk_tidal_config, tidalsite, subtests) "device_rating_kw": mhk_tidal_config["device_rating_kw"], "num_devices": 2, "tidal_power_curve": mhk_tidal_config["tidal_power_curve"], - "tidal_resource": mhk_tidal_config["tidal_resource"], "fin_model": DEFAULT_FIN_CONFIG, }, "grid": { @@ -598,7 +597,6 @@ def test_hybrid_tidal_battery(hybrid_config, mhk_tidal_config,tidalsite, subtest "device_rating_kw": mhk_tidal_config["device_rating_kw"], "num_devices": 2, "tidal_power_curve": mhk_tidal_config["tidal_power_curve"], - "tidal_resource": mhk_tidal_config["tidal_resource"], "fin_model": DEFAULT_FIN_CONFIG, }, "battery": { @@ -777,7 +775,7 @@ def test_hybrid_pv_only_custom_fin(hybrid_config, subtests): with subtests.test("aep"): assert aeps.pv == approx(10789795.03, 1e-3) - assert aeps.hybrid == aeps.pv + assert aeps.hybrid == approx(aeps.pv) def test_hybrid_pv_battery_custom_fin(hybrid_config, subtests): diff --git a/tests/hopp/test_wave.py b/tests/hopp/test_wave.py index 627ff6de4..da555d5b7 100644 --- a/tests/hopp/test_wave.py +++ b/tests/hopp/test_wave.py @@ -3,7 +3,7 @@ from pathlib import Path from hopp.simulation.technologies.sites import SiteInfo -from hopp.simulation.technologies.wave.mhk_wave_plant import MHKWavePlant, MHKConfig +from hopp.simulation.technologies.wave.mhk_wave_plant import MHKWavePlant, MHKWaveConfig from hopp.simulation.technologies.financial.mhk_cost_model import MHKCostModelInputs from hopp.simulation.technologies.financial.custom_financial_model import CustomFinancialModel from hopp.utilities import load_yaml @@ -35,7 +35,7 @@ def mhk_config(): def waveplant(mhk_config, site): financial_model = {'fin_model': DEFAULT_FIN_CONFIG} mhk_config.update(financial_model) - config = MHKConfig.from_dict(mhk_config) + config = MHKWaveConfig.from_dict(mhk_config) cost_model_input = MHKCostModelInputs.from_dict({ 'reference_model_num':3, @@ -54,7 +54,7 @@ def test_mhk_config(mhk_config, subtests): financial_model = {'fin_model': DEFAULT_FIN_CONFIG} mhk_config.update(financial_model) - config = MHKConfig.from_dict(mhk_config) + config = MHKWaveConfig.from_dict(mhk_config) assert config.device_rating_kw == 286. assert config.num_devices == 100 diff --git a/tests/hopp/test_wind.py b/tests/hopp/test_wind.py index 455205c41..9ea7a5ea3 100644 --- a/tests/hopp/test_wind.py +++ b/tests/hopp/test_wind.py @@ -75,6 +75,46 @@ def test_wind_powercurve_pysam(): assert all([a == b for a, b in zip(windspeeds_truth, windspeeds_calc)]) assert all([a == b for a, b in zip(powercurve_truth, powercurve_calc)]) +def test_user_input_turbine_dict_pysam(site): + nTurbs = 10 + pysam_model = windpower.default("WindpowerSingleowner") + pysam_default_model = pysam_model.export() + input_turbine_config = {'Turbine':pysam_default_model['Turbine']} + + config = WindConfig.from_dict({'num_turbines': nTurbs, "model_input_file": input_turbine_config}) + model = WindPlant(site, config=config) + + turbine_rated_power_kW = max(pysam_default_model['Turbine']['wind_turbine_powercurve_powerout']) + assert model.system_capacity_kw == nTurbs*turbine_rated_power_kW + + model._system_model.execute(0) + assert model._system_model.Outputs.capacity_factor == approx(36.5,abs = 0.5) + +def test_user_input_turbine_file_pysam(site): + nTurbs = 10 + pysam_turbine_input_filepath = str(ROOT_DIR.parent/"tests"/"hopp"/"inputs"/"pysam_turbine_input.yaml") + config = WindConfig.from_dict({'num_turbines': nTurbs, "model_input_file": pysam_turbine_input_filepath}) + model = WindPlant(site, config=config) + + testing_pysam_model = load_yaml(pysam_turbine_input_filepath) + turbine_rated_power_kW = max(testing_pysam_model['Turbine']['wind_turbine_powercurve_powerout']) + assert model.system_capacity_kw == nTurbs*turbine_rated_power_kW + model._system_model.execute(0) + assert model._system_model.Outputs.capacity_factor == approx(36.5,abs = 0.5) + + +def test_user_input_pysam_file(site): + nTurbs = 32 + pysam_input_filepath = str(ROOT_DIR.parent/"tests"/"hopp"/"inputs"/"pysam_simulation_input.yaml") + config = WindConfig.from_dict({'num_turbines': nTurbs, "model_input_file": pysam_input_filepath}) + model = WindPlant(site, config=config) + + testing_pysam_model = load_yaml(pysam_input_filepath) + turbine_rated_power_kW = max(testing_pysam_model['Turbine']['wind_turbine_powercurve_powerout']) + assert model.system_capacity_kw == nTurbs*turbine_rated_power_kW + + model._system_model.execute(0) + assert model._system_model.Outputs.capacity_factor == approx(35.0,abs = 0.5) def test_changing_n_turbines_pysam(site): # test with gridded layout