From a5b1ef68117208857f9b2e80bf9dd44ff12de6c1 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:40:00 -0700 Subject: [PATCH 01/35] added draft of floris wind model --- h2integrate/converters/wind/floris.py | 192 ++++++++++++++++++++++++++ h2integrate/core/validators.py | 10 ++ 2 files changed, 202 insertions(+) create mode 100644 h2integrate/converters/wind/floris.py diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py new file mode 100644 index 000000000..12d408cdd --- /dev/null +++ b/h2integrate/converters/wind/floris.py @@ -0,0 +1,192 @@ +import numpy as np +from attrs import field, define +from floris import TimeSeries, FlorisModel + +from h2integrate.core.utilities import BaseConfig, merge_shared_inputs +from h2integrate.core.validators import gt_val, gt_zero, gte_zero +from h2integrate.converters.wind.wind_plant_baseclass import WindPerformanceBaseClass +from h2integrate.converters.wind.layout.simple_grid_layout import ( + BasicGridLayoutConfig, + make_basic_grid_turbine_layout, +) + + +# @define +# class HybridTurbineFarmConfig(BaseConfig): +# turbine_types: dict | list[dict] = field() +# turbine_type_to + + +@define +class FlorisWindPlantPerformanceConfig(BaseConfig): + num_turbines: int = field(converter=int, validator=gt_zero) + floris_config: dict = field() + hub_height: float = field(default=-1, validator=gt_val(-1)) + layout: dict = field(default={}) + # operation_model: str = field(default="cosine-loss") + # floris_turbines: dict | list[dict] = field() + hybrid_turbine_design: bool = field(default=False) + operational_losses: float = field(default=0.0, validator=gte_zero) + + # if using multiple turbines, then need to specify resource reference height + def __attrs_post_init__(self): + n_turbine_types = len(self.floris_config.get("farm", {}).get("turbine_type", [])) + n_pos = len(self.floris_config.get("farm", {}).get("layout_x", [])) + if n_turbine_types > 1 and n_turbine_types != n_pos: + self.hybrid_turbine_design = True + + # use floris_turbines + if self.hub_height < 0 and not self.hybrid_turbine_design: + self.hub_height = ( + self.floris_config.get("farm", {}).get("turbine_type")[0].get("hub_height") + ) + + +class FlorisWindPlantPerformanceModel(WindPerformanceBaseClass): + """ + An OpenMDAO component that wraps a Floris model. + It takes wind parameters as input and outputs power generation data. + """ + + def setup(self): + super().setup() + + performance_inputs = self.options["tech_config"]["model_inputs"]["performance_parameters"] + + # initialize layout config + layout_options = {} + if "layout" in performance_inputs: + layout_params = self.options["tech_config"]["model_inputs"]["performance_parameters"][ + "layout" + ] + layout_mode = layout_params.get("layout_mode", "basicgrid") + layout_options = layout_params.get("layout_options", {}) + if layout_mode == "basicgrid": + self.layout_config = BasicGridLayoutConfig.from_dict(layout_options) + self.layout_mode = layout_mode + + # initialize wind turbine config + self.config = FlorisWindPlantPerformanceConfig.from_dict( + merge_shared_inputs(self.options["tech_config"]["model_inputs"], "performance") + ) + + if self.config.hybrid_turbine_design: + raise NotImplementedError( + "H2I does not currently support running multiple wind turbines with Floris." + ) + + self.add_input( + "num_turbines", + val=self.config.num_turbines, + units="unitless", + desc="number of turbines in farm", + ) + + self.add_input( + "hub_height", + val=self.config.hub_height, + units="m", + desc="turbine hub-height in meters", + ) + + self.add_output( + "annual_energy", + val=0.0, + units="kW*h/year", + desc="Annual energy production from WindPlant in kW", + ) + self.add_output( + "total_capacity", val=0.0, units="kW", desc="Wind farm rated capacity in kW" + ) + + self.n_timesteps = int(self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]) + + power_curve = ( + self.config.floris_config.get("farm", {}) + .get("turbine_type")[0] + .get("power_thrust_table") + .get("power") + ) + self.wind_turbine_rating_kW = np.max(power_curve) + + def format_resource_data(self, hub_height, wind_resource_data): + # NOTE: could weight resource data of bounding heights like + # `weighted_parse_resource_data` method in HOPP + # constant_resource_data = ["wind_shear","wind_veer","reference_wind_height","air_density"] + # timeseries_resource_data = ["turbulence_intensity","wind_directions","wind_speeds"] + + bounding_heights = self.calculate_bounding_heights_from_resource_data( + hub_height, wind_resource_data, resource_vars=["wind_speed", "wind_direction"] + ) + if len(bounding_heights) == 1: + resource_height = bounding_heights[0] + else: + height_difference = [np.abs(hub_height - b) for b in bounding_heights] + resource_height = bounding_heights[np.argmin(height_difference).flatten()[0]] + + default_ti = self.floris_config.get("flow_field", {}).get("turbulence_intensities", [0.06]) + ti = wind_resource_data.get("turbulence_intensity", {}).get( + f"turbulence_intensity_{resource_height}", default_ti + ) + if isinstance(ti, (int, float)): + ti = [ti] + time_series = TimeSeries( + wind_directions=wind_resource_data[f"wind_direction_{resource_height}"], + wind_speeds=wind_resource_data[f"wind_speed_{resource_height}"], + turbulence_intensities=ti, + ) + + return time_series + + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): + # NOTE: could update air density based on elevation if elevation is included + # in the resource data. + # would need to duplicate the `calculate_air_density`` function from HOPP + inputs["hub_height"] + inputs["num_turbines"] + + n_turbs = int(np.round(inputs["num_turbines"][0])) + + floris_config = self.config.floris_config.copy() + turbine_design = floris_config["farm"]["turbine_type"][0] + + turbine_design.update({"hub_height": inputs["hub_height"][0]}) + + # format resource data and input into model + time_series = self.format_resource_data( + inputs["hub_height"][0], discrete_inputs["wind_resource_data"] + ) + + # make layout for number of turbines + if self.layout_mode == "basicgrid": + x_pos, y_pos = make_basic_grid_turbine_layout( + turbine_design.get("rotor_diameter"), n_turbs, self.layout_config + ) + + self.fi = FlorisModel(floris_config) + + self.fi.set(layout_x=x_pos, layout_y=y_pos, wind_data=time_series) + + self.fi.run() + # power_turbines = self.fi.get_turbine_powers().reshape( + # (n_turbs, self.n_timesteps) + # ) #W + power_farm = self.fi.get_farm_power().reshape(self.n_timesteps) # W + + operational_efficiency = (100 - self.config.operational_losses) / 100 + # Adding losses (excluding turbine and wake losses) + gen = power_farm * operational_efficiency / 1000 # kW + + outputs["electricity_out"] = gen + outputs["total_capacity"] = n_turbs * self.wind_turbine_rating_kW + + max_production = n_turbs * self.wind_turbine_rating_kW * len(gen) + outputs["annual_energy"] = (np.sum(gen) / max_production) * 8760 + # power_turbines = np.zeros((self.nTurbs, 8760)) + # power_farm = np.zeros(8760) + # power_turbines[:, 0:self.n_timesteps] = self.fi.get_turbine_powers().reshape( + # (self.nTurbs, self.n_timesteps) + # ) + # power_farm[0:self.n_timesteps] = self.fi.get_farm_power().reshape( + # (self.n_timesteps) + # ) diff --git a/h2integrate/core/validators.py b/h2integrate/core/validators.py index 6fcf0b763..041955201 100644 --- a/h2integrate/core/validators.py +++ b/h2integrate/core/validators.py @@ -25,6 +25,16 @@ def validator(instance, attribute, value): return validator +def gt_val(min_val): + """Validates that an attribute's value is greater than some minumum value.""" + + def validator(instance, attribute, value): + if value < min_val: + raise ValueError(f"{attribute} must be greater than {min_val} (but has value {value})") + + return validator + + def range_val_or_none(min_val, max_val): """Validates that an attribute's value is between two values, inclusive ([min_val, max_val]). Ignores None type values.""" From 0e99c4e2f6d5f6c76639a289ee30864cb8f352dd Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:04:54 -0700 Subject: [PATCH 02/35] added is_day variable to openmeteo downloads --- h2integrate/resource/wind/openmeteo_wind.py | 5 +++++ h2integrate/resource/wind/wind_resource_base.py | 1 + 2 files changed, 6 insertions(+) diff --git a/h2integrate/resource/wind/openmeteo_wind.py b/h2integrate/resource/wind/openmeteo_wind.py index 0a113135f..2a3425ff5 100644 --- a/h2integrate/resource/wind/openmeteo_wind.py +++ b/h2integrate/resource/wind/openmeteo_wind.py @@ -93,6 +93,7 @@ def setup(self): "surface_pressure": "hPa", "precipitation": "mm/h", "relative_humidity_2m": "unitless", + "is_day": "percent", } # get the data dictionary data = self.get_data() @@ -322,6 +323,10 @@ def format_timeseries_data(self, data): if old_c not in self.hourly_wind_data_to_units: continue + if "is_day" in c: + data_rename_mapper.update({c: "is_day"}) + data_units.update({"is_day": "percent"}) + if "surface" in c: new_c += "_0m" new_c = new_c.replace("surface", "").replace("__", "").strip("_") diff --git a/h2integrate/resource/wind/wind_resource_base.py b/h2integrate/resource/wind/wind_resource_base.py index cd6a7f766..b416a46ce 100644 --- a/h2integrate/resource/wind/wind_resource_base.py +++ b/h2integrate/resource/wind/wind_resource_base.py @@ -14,6 +14,7 @@ def setup(self): "pressure": "atm", "precipitation_rate": "mm/h", "relative_humidity": "percent", + "is_day": "percent", } def compare_units_and_correct(self, data, data_units): From 7cc0eeb92749f7d7d563c07d769096af665089c4 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:45:46 -0700 Subject: [PATCH 03/35] added cache to floris model --- h2integrate/converters/wind/floris.py | 77 +++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 12d408cdd..be96bae06 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -1,3 +1,8 @@ +import copy +import hashlib +from pathlib import Path + +import dill import numpy as np from attrs import field, define from floris import TimeSeries, FlorisModel @@ -27,6 +32,7 @@ class FlorisWindPlantPerformanceConfig(BaseConfig): # floris_turbines: dict | list[dict] = field() hybrid_turbine_design: bool = field(default=False) operational_losses: float = field(default=0.0, validator=gte_zero) + enable_caching: bool = field(default=True) # if using multiple turbines, then need to specify resource reference height def __attrs_post_init__(self): @@ -138,18 +144,64 @@ def format_resource_data(self, hub_height, wind_resource_data): return time_series + def make_cache_filename(self, resource_data, hub_height, n_turbines): + # unique resource data is year, lat, lon, timezone, filepath (perhaps) + resource_keys = ["site_lat", "site_lon", "data_tz", "filepath"] + time_keys = ["year", "month", "day", "hour", "minute"] + resource_info = {resource_data.get(k, None) for k in resource_keys} + time_info = {resource_data.get(k, [None])[0] for k in time_keys} + resource_info.update(time_info) + + resource_data_included = [k for k, v in resource_data.items() if k not in resource_info] + resource_info.update({"resource_data_included": resource_data_included}) + + hash_info = copy.deepcopy(self.config.as_dict()) + hash_info.update({"resource_data": resource_info}) + hash_info.update( + { + "hub_height": hub_height, + "n_turbines": n_turbines, + "wind_turbine_size_kw": self.wind_turbine_rating_kW, + } + ) + + # Create a unique hash for the current configuration to use as a cache key + config_hash = hashlib.md5(str(hash_info).encode("utf-8")).hexdigest() + + # Create a cache directory if it doesn't exist + cache_dir = Path("cache") + if not cache_dir.exists(): + cache_dir.mkdir(parents=True) + cache_file = f"cache/{config_hash}.pkl" + return cache_file + def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # NOTE: could update air density based on elevation if elevation is included # in the resource data. # would need to duplicate the `calculate_air_density`` function from HOPP - inputs["hub_height"] - inputs["num_turbines"] + # Check if the results for the current configuration are already cached + if self.config.enable_caching: + cache_filename = self.make_cache_filename( + discrete_inputs["wind_resource_data"], inputs["hub_height"], inputs["num_turbines"] + ) + if Path(cache_filename).exists(): + # Load the cached results + cache_path = Path(cache_filename) + with cache_path.open("rb") as f: + cached_data = dill.load(f) + outputs["electricity_out"] = cached_data["electricity_out"] + outputs["total_capacity"] = cached_data["total_capacity"] + outputs["annual_energy"] = cached_data["annual_energy"] + return + + # If caching is not enabled or a cache file does not exist, run FLORIS n_turbs = int(np.round(inputs["num_turbines"][0])) - floris_config = self.config.floris_config.copy() + floris_config = copy.deepcopy(self.config.floris_config) turbine_design = floris_config["farm"]["turbine_type"][0] + # update the turbine hub-height in the floris turbine config turbine_design.update({"hub_height": inputs["hub_height"][0]}) # format resource data and input into model @@ -163,18 +215,21 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): turbine_design.get("rotor_diameter"), n_turbs, self.layout_config ) + # initialize FLORIS self.fi = FlorisModel(floris_config) + # set the layout and wind data in Floris self.fi.set(layout_x=x_pos, layout_y=y_pos, wind_data=time_series) + # run the model self.fi.run() # power_turbines = self.fi.get_turbine_powers().reshape( # (n_turbs, self.n_timesteps) # ) #W power_farm = self.fi.get_farm_power().reshape(self.n_timesteps) # W - operational_efficiency = (100 - self.config.operational_losses) / 100 # Adding losses (excluding turbine and wake losses) + operational_efficiency = (100 - self.config.operational_losses) / 100 gen = power_farm * operational_efficiency / 1000 # kW outputs["electricity_out"] = gen @@ -182,6 +237,20 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): max_production = n_turbs * self.wind_turbine_rating_kW * len(gen) outputs["annual_energy"] = (np.sum(gen) / max_production) * 8760 + + # Cache the results for future use + if self.config.enable_caching: + cache_path = Path(cache_filename) + with cache_path.open("wb") as f: + floris_results = { + "electricity_out": outputs["electricity_out"], + "total_capacity": outputs["total_capacity"], + "annual_energy": outputs["total_capacity"], + "layout_x": x_pos, + "layout_y": y_pos, + } + dill.dump(floris_results, f) + # power_turbines = np.zeros((self.nTurbs, 8760)) # power_farm = np.zeros(8760) # power_turbines[:, 0:self.n_timesteps] = self.fi.get_turbine_powers().reshape( From ae6d5bba090161850cfb95a360aa126ec7866eff Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:48:27 -0700 Subject: [PATCH 04/35] added cache to floris model --- h2integrate/converters/wind/floris.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index be96bae06..df2e46c7e 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -178,7 +178,7 @@ def make_cache_filename(self, resource_data, hub_height, n_turbines): def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # NOTE: could update air density based on elevation if elevation is included # in the resource data. - # would need to duplicate the `calculate_air_density`` function from HOPP + # would need to duplicate the ``calculate_air_density`` function from HOPP # Check if the results for the current configuration are already cached if self.config.enable_caching: From db50a0fe3974858f89d674200dfae73186eb158c Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:39:00 -0700 Subject: [PATCH 05/35] made cache file utility method --- h2integrate/converters/wind/floris.py | 3 +++ h2integrate/core/utilities.py | 37 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index df2e46c7e..dd05a2344 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -145,6 +145,9 @@ def format_resource_data(self, hub_height, wind_resource_data): return time_series def make_cache_filename(self, resource_data, hub_height, n_turbines): + # inputs + # discrete_inputs + # unique resource data is year, lat, lon, timezone, filepath (perhaps) resource_keys = ["site_lat", "site_lon", "data_tz", "filepath"] time_keys = ["year", "month", "day", "hour", "minute"] diff --git a/h2integrate/core/utilities.py b/h2integrate/core/utilities.py index bff78344b..ffe7b9ab9 100644 --- a/h2integrate/core/utilities.py +++ b/h2integrate/core/utilities.py @@ -1,6 +1,7 @@ import re import csv import copy +import hashlib import operator from typing import Any from pathlib import Path @@ -878,3 +879,39 @@ def _structured(meta_list): "explicit_outputs": _structured(explicit_meta), "implicit_outputs": _structured(implicit_meta), } + + +def make_cache_hash_filename(config, inputs, discrete_inputs={}): + """_summary_ + + Args: + config (_type_): _description_ + inputs (_type_): _description_ + discrete_inputs (dict, optional): _description_. Defaults to {}. + + Returns: + _type_: _description_ + """ + # NOTE: maybe would be good to add a string input that can specify what model this cache is for, + # like "hopp" or "floris", this could be used in the cache filename but perhaps unnecessary + + if not isinstance(config, dict): + hash_dict = config.as_dict() + else: + hash_dict = copy.deepcopy(config) + + input_dict = dict(inputs.items()) + discrete_input_dict = dict(discrete_inputs.items()) + + hash_dict.update(input_dict) + hash_dict.update(discrete_input_dict) + + # Create a unique hash for the current configuration to use as a cache key + config_hash = hashlib.md5(str(hash_dict).encode("utf-8")).hexdigest() + + # Create a cache directory if it doesn't exist + cache_dir = Path("cache") + if not cache_dir.exists(): + cache_dir.mkdir(parents=True) + cache_file = cache_dir / f"{config_hash}.pkl" + return cache_file From d1c766ce89dbe811f4525821a3879fcf6c55485f Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:51:40 -0700 Subject: [PATCH 06/35] updated floris to use cache filename making utility --- h2integrate/converters/wind/floris.py | 53 +++------------------------ 1 file changed, 5 insertions(+), 48 deletions(-) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index dd05a2344..123dc63ca 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -1,5 +1,4 @@ import copy -import hashlib from pathlib import Path import dill @@ -7,7 +6,7 @@ from attrs import field, define from floris import TimeSeries, FlorisModel -from h2integrate.core.utilities import BaseConfig, merge_shared_inputs +from h2integrate.core.utilities import BaseConfig, merge_shared_inputs, make_cache_hash_filename from h2integrate.core.validators import gt_val, gt_zero, gte_zero from h2integrate.converters.wind.wind_plant_baseclass import WindPerformanceBaseClass from h2integrate.converters.wind.layout.simple_grid_layout import ( @@ -144,40 +143,6 @@ def format_resource_data(self, hub_height, wind_resource_data): return time_series - def make_cache_filename(self, resource_data, hub_height, n_turbines): - # inputs - # discrete_inputs - - # unique resource data is year, lat, lon, timezone, filepath (perhaps) - resource_keys = ["site_lat", "site_lon", "data_tz", "filepath"] - time_keys = ["year", "month", "day", "hour", "minute"] - resource_info = {resource_data.get(k, None) for k in resource_keys} - time_info = {resource_data.get(k, [None])[0] for k in time_keys} - resource_info.update(time_info) - - resource_data_included = [k for k, v in resource_data.items() if k not in resource_info] - resource_info.update({"resource_data_included": resource_data_included}) - - hash_info = copy.deepcopy(self.config.as_dict()) - hash_info.update({"resource_data": resource_info}) - hash_info.update( - { - "hub_height": hub_height, - "n_turbines": n_turbines, - "wind_turbine_size_kw": self.wind_turbine_rating_kW, - } - ) - - # Create a unique hash for the current configuration to use as a cache key - config_hash = hashlib.md5(str(hash_info).encode("utf-8")).hexdigest() - - # Create a cache directory if it doesn't exist - cache_dir = Path("cache") - if not cache_dir.exists(): - cache_dir.mkdir(parents=True) - cache_file = f"cache/{config_hash}.pkl" - return cache_file - def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # NOTE: could update air density based on elevation if elevation is included # in the resource data. @@ -185,9 +150,10 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # Check if the results for the current configuration are already cached if self.config.enable_caching: - cache_filename = self.make_cache_filename( - discrete_inputs["wind_resource_data"], inputs["hub_height"], inputs["num_turbines"] - ) + config_dict = self.config.as_dict() + config_dict.update({"wind_turbine_size_kw": self.wind_turbine_rating_kW}) + cache_filename = make_cache_hash_filename(config_dict, inputs, discrete_inputs) + if Path(cache_filename).exists(): # Load the cached results cache_path = Path(cache_filename) @@ -253,12 +219,3 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): "layout_y": y_pos, } dill.dump(floris_results, f) - - # power_turbines = np.zeros((self.nTurbs, 8760)) - # power_farm = np.zeros(8760) - # power_turbines[:, 0:self.n_timesteps] = self.fi.get_turbine_powers().reshape( - # (self.nTurbs, self.n_timesteps) - # ) - # power_farm[0:self.n_timesteps] = self.fi.get_farm_power().reshape( - # (self.n_timesteps) - # ) From 3284f2ca7f64283a85c91f1bef342b3a70e8d8d7 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:34:33 -0700 Subject: [PATCH 07/35] added docstring to new utility method and updates to floris wrapper --- h2integrate/converters/wind/floris.py | 22 ++++++++++++---------- h2integrate/core/utilities.py | 12 +++++++----- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 123dc63ca..3066e50c6 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -15,16 +15,12 @@ ) -# @define -# class HybridTurbineFarmConfig(BaseConfig): -# turbine_types: dict | list[dict] = field() -# turbine_type_to - - @define class FlorisWindPlantPerformanceConfig(BaseConfig): num_turbines: int = field(converter=int, validator=gt_zero) - floris_config: dict = field() + floris_wake_config: dict = field() + floris_turbine_config: dict = field() + floris_operation_model: str = field(default="cosine-loss") hub_height: float = field(default=-1, validator=gt_val(-1)) layout: dict = field(default={}) # operation_model: str = field(default="cosine-loss") @@ -107,7 +103,7 @@ def setup(self): self.n_timesteps = int(self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]) power_curve = ( - self.config.floris_config.get("farm", {}) + self.config.floris_turbine_config.get("farm", {}) .get("turbine_type")[0] .get("power_thrust_table") .get("power") @@ -167,8 +163,10 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # If caching is not enabled or a cache file does not exist, run FLORIS n_turbs = int(np.round(inputs["num_turbines"][0])) - floris_config = copy.deepcopy(self.config.floris_config) - turbine_design = floris_config["farm"]["turbine_type"][0] + floris_config = copy.deepcopy(self.config.floris_wake_config) + turbine_design = copy.deepcopy(self.config.floris_turbine_config) + + # turbine_design = floris_config["farm"]["turbine_type"][0] # update the turbine hub-height in the floris turbine config turbine_design.update({"hub_height": inputs["hub_height"][0]}) @@ -184,6 +182,10 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): turbine_design.get("rotor_diameter"), n_turbs, self.layout_config ) + floris_farm = {"layout_x": x_pos, "layout_y": y_pos, "turbine_type": [turbine_design]} + + floris_config["farm"].update(floris_farm) + # initialize FLORIS self.fi = FlorisModel(floris_config) diff --git a/h2integrate/core/utilities.py b/h2integrate/core/utilities.py index ffe7b9ab9..0d7ceaab3 100644 --- a/h2integrate/core/utilities.py +++ b/h2integrate/core/utilities.py @@ -882,15 +882,17 @@ def _structured(meta_list): def make_cache_hash_filename(config, inputs, discrete_inputs={}): - """_summary_ + """Make valid filepath to a pickle file with a filename that is unique based on information + available in the config, inputs, and discrete inputs. Args: - config (_type_): _description_ - inputs (_type_): _description_ - discrete_inputs (dict, optional): _description_. Defaults to {}. + config (object | dict): configuration object that inherits `BaseConfig` or dictionary. + inputs (om.vectors.default_vector.DefaultVector): OM inputs to `compute()` method + discrete_inputs (om.core.component._DictValues, optional): OM discrete inputs to `compute()` + method. Defaults to {}. Returns: - _type_: _description_ + Path: filepath to pickle file with filename as unique cache key. """ # NOTE: maybe would be good to add a string input that can specify what model this cache is for, # like "hopp" or "floris", this could be used in the cache filename but perhaps unnecessary From 37a8eff214e397545dd55bb3afe5a183ff4827bb Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:35:26 -0700 Subject: [PATCH 08/35] added floris files to the library --- library/floris_turbine_Vestas_660kW.yaml | 213 +++++++++++++++++++++++ library/floris_v4_default_template.yaml | 92 ++++++++++ 2 files changed, 305 insertions(+) create mode 100644 library/floris_turbine_Vestas_660kW.yaml create mode 100644 library/floris_v4_default_template.yaml diff --git a/library/floris_turbine_Vestas_660kW.yaml b/library/floris_turbine_Vestas_660kW.yaml new file mode 100644 index 000000000..e7fa46a8f --- /dev/null +++ b/library/floris_turbine_Vestas_660kW.yaml @@ -0,0 +1,213 @@ +turbine_type: VestasV47_660kW_47 +hub_height: 65.0 +TSR: 8.0 +rotor_diameter: 47.0 +power_thrust_table: + ref_air_density: 1.225 + ref_tilt: 5.0 + cosine_loss_exponent_yaw: 1.88 + cosine_loss_exponent_tilt: 1.88 + wind_speed: + - 0.0 + - 1.0 + - 2.0 + - 3.0 + - 4.0 + - 4.17 + - 4.58 + - 5.02 + - 5.5 + - 6.0 + - 6.51 + - 6.99 + - 7.49 + - 7.99 + - 8.47 + - 9.02 + - 9.51 + - 10.01 + - 10.47 + - 10.99 + - 11.5 + - 11.99 + - 12.48 + - 13.01 + - 13.49 + - 13.99 + - 14.5 + - 14.91 + - 15.39 + - 15.96 + - 16.42 + - 16.91 + - 17.45 + - 17.91 + - 17.91 + - 18.91 + - 19.91 + - 20.91 + - 21.91 + - 22.91 + - 23.91 + - 24.91 + - 25.91 + - 26.91 + - 27.91 + - 28.91 + - 29.91 + - 30.91 + - 31.91 + - 32.91 + - 33.91 + - 34.91 + - 35.91 + - 36.91 + - 37.91 + - 38.91 + - 39.91 + - 40.91 + - 41.91 + - 42.91 + - 43.91 + - 44.91 + - 45.91 + - 46.91 + - 47.91 + - 48.91 + - 49.91 + power: + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 11.75 + - 26.46 + - 41.79 + - 67.86 + - 95.69 + - 131.45 + - 164.31 + - 209.85 + - 253.8 + - 301.19 + - 362.48 + - 406.7 + - 468.74 + - 508.55 + - 551.26 + - 587.82 + - 612.36 + - 634.38 + - 646.79 + - 651.4 + - 658.4 + - 659.46 + - 660.0 + - 660.0 + - 660.0 + - 660.0 + - 660.0 + - 660.0 + - 660.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 + thrust_coefficient: + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.0 + - 0.1596 + - 0.2755 + - 0.3399 + - 0.4364 + - 0.4857 + - 0.529 + - 0.5382 + - 0.5653 + - 0.5637 + - 0.5604 + - 0.5572 + - 0.5274 + - 0.5183 + - 0.4857 + - 0.4488 + - 0.412 + - 0.3728 + - 0.3386 + - 0.3018 + - 0.2696 + - 0.2416 + - 0.2164 + - 0.1984 + - 0.1794 + - 0.1596 + - 0.1466 + - 0.1336 + - 0.1208 + - 0.1112 + - 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 diff --git a/library/floris_v4_default_template.yaml b/library/floris_v4_default_template.yaml new file mode 100644 index 000000000..4b00ef9a5 --- /dev/null +++ b/library/floris_v4_default_template.yaml @@ -0,0 +1,92 @@ + +name: Gauss +description: Onshore template +floris_version: v4.0.0 +logging: + console: + enable: false + level: WARNING + file: + enable: false + level: WARNING +solver: + type: turbine_grid + turbine_grid_points: 1 +flow_field: + air_density: 1.225 + reference_wind_height: -1 + wind_directions: [] + wind_shear: 0.33 + wind_speeds: [] + wind_veer: 0.0 + turbulence_intensities: [0.06] + +wake: + model_strings: + combination_model: sosfs + deflection_model: gauss + turbulence_model: crespo_hernandez + velocity_model: gauss + enable_secondary_steering: false + enable_yaw_added_recovery: false + enable_transverse_velocities: false + wake_deflection_parameters: + gauss: + ad: 0.0 + alpha: 0.58 + bd: 0.0 + beta: 0.077 + dm: 1.0 + ka: 0.38 + kb: 0.004 + jimenez: + ad: 0.0 + bd: 0.0 + kd: 0.05 + wake_velocity_parameters: + cc: + a_s: 0.179367259 + b_s: 0.0118889215 + c_s1: 0.0563691592 + c_s2: 0.13290157 + a_f: 3.11 + b_f: -0.68 + c_f: 2.41 + alpha_mod: 1.0 + gauss: + alpha: 0.58 + beta: 0.077 + ka: 0.38 + kb: 0.004 + jensen: + we: 0.05 + wake_turbulence_parameters: + crespo_hernandez: + initial: 0.1 + constant: 0.5 + ai: 0.8 + downstream: -0.32 + enable_active_wake_mixing: false + + wake_velocity_parameters: + cc: + a_f: 3.11 + a_s: 0.179367259 + alpha_mod: 1.0 + b_f: -0.68 + b_s: 0.0118889215 + c_f: 2.41 + c_s1: 0.0563691592 + c_s2: 0.13290157 + gauss: + alpha: 0.58 + beta: 0.077 + ka: 0.38 + kb: 0.004 + jensen: + we: 0.05 +farm: + layout_x: [] + layout_y: [] + turbine_type: + # - operation_model: cosine-loss From c3eb4175bff94da356165ea87ce2d5d47cffe3ee Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:36:21 -0700 Subject: [PATCH 09/35] added draft files for floris example --- examples/floris_example/driver_config.yaml | 5 ++ examples/floris_example/plant_config.yaml | 54 +++++++++++++++++++ examples/floris_example/run_floris_example.py | 27 ++++++++++ examples/floris_example/tech_config.yaml | 27 ++++++++++ 4 files changed, 113 insertions(+) create mode 100644 examples/floris_example/driver_config.yaml create mode 100644 examples/floris_example/plant_config.yaml create mode 100644 examples/floris_example/run_floris_example.py create mode 100644 examples/floris_example/tech_config.yaml diff --git a/examples/floris_example/driver_config.yaml b/examples/floris_example/driver_config.yaml new file mode 100644 index 000000000..aff911d3b --- /dev/null +++ b/examples/floris_example/driver_config.yaml @@ -0,0 +1,5 @@ +name: "driver_config" +description: "This analysis runs a wind plant using FLORIS" + +general: + folder_output: floris_wind_outputs diff --git a/examples/floris_example/plant_config.yaml b/examples/floris_example/plant_config.yaml new file mode 100644 index 000000000..a12072147 --- /dev/null +++ b/examples/floris_example/plant_config.yaml @@ -0,0 +1,54 @@ +name: "plant_config" +description: "" + +site: + latitude: 44.04218 + longitude: -95.19757 + + resources: + wind_resource: + resource_model: "openmeteo_wind_api" + resource_parameters: + resource_year: 2023 + +resource_to_tech_connections: [ + # connect the wind resource to the wind technology + ['wind_resource', 'wind', 'wind_resource_data'], +] + +plant: + plant_life: 30 + simulation: + n_timesteps: 8760 + dt: 3600 +finance_parameters: + finance_groups: + finance_model: "ProFastComp" + model_inputs: + params: + analysis_start_year: 2032 + installation_time: 36 # months + inflation_rate: 0.0 # 0 for nominal analysis + discount_rate: 0.09 + debt_equity_ratio: 2.62 + property_tax_and_insurance: 0.03 # percent of CAPEX + total_income_tax_rate: 0.257 + capital_gains_tax_rate: 0.15 # H2FAST default + sales_tax_rate: 0.07375 + debt_interest_rate: 0.07 + debt_type: "Revolving debt" # can be "Revolving debt" or "One time loan". + loan_period_if_used: 0 # H2FAST default, not used for revolving debt + cash_onhand_months: 1 # H2FAST default + admin_expense: 0.00 # percent of sales H2FAST default + capital_items: + depr_type: "MACRS" # can be "MACRS" or "Straight line" + depr_period: 7 #years + refurb: [0.] + finance_subgroups: + electricity: + commodity: "electricity" + commodity_stream: "wind" #use the total electricity output from the combiner for the finance calc + technologies: ["wind"] + cost_adjustment_parameters: + cost_year_adjustment_inflation: 0.025 # used to adjust modeled costs to target_dollar_year + target_dollar_year: 2022 diff --git a/examples/floris_example/run_floris_example.py b/examples/floris_example/run_floris_example.py new file mode 100644 index 000000000..69d327994 --- /dev/null +++ b/examples/floris_example/run_floris_example.py @@ -0,0 +1,27 @@ +import os +from pathlib import Path + +from h2integrate.core.utilities import load_yaml +from h2integrate.core.h2integrate_model import H2IntegrateModel + + +os.chdir(Path(__file__).parent) + +driver_config = load_yaml(Path(__file__).parent / "driver_config.yaml") +tech_config = load_yaml(Path(__file__).parent / "tech_config.yaml") +plant_config = load_yaml(Path(__file__).parent / "plant_config.yaml") + +h2i_config = { + "name": "H2Integrate_config", + "system_summary": "", + "driver_config": driver_config, + "technology_config": tech_config, + "plant_config": plant_config, +} +# Create a GreenHEART model +h2i = H2IntegrateModel(h2i_config) + +# Run the model +h2i.run() + +h2i.post_process() diff --git a/examples/floris_example/tech_config.yaml b/examples/floris_example/tech_config.yaml new file mode 100644 index 000000000..0d99c1789 --- /dev/null +++ b/examples/floris_example/tech_config.yaml @@ -0,0 +1,27 @@ +name: "technology_config" +description: "This plant runs a wind farm using FLORIS" + +technologies: + wind: + performance_model: + model: "floris_wind_plant_performance" + cost_model: + model: "atb_wind_cost" + model_inputs: + performance_parameters: + num_turbines: 100 + hub_height: 65.0 + floris_wake_config: !include "floris_v4_default_template.yaml" + floris_turbine_config: !include "floris_turbine_Vestas_660kW.yaml" + layout: + layout_mode: "basicgrid" + layout_options: + row_D_spacing: 5.0 + turbine_D_spacing: 5.0 + rotation_angle_deg: 0.0 + row_phase_offset: 0.0 + layout_shape: "square" + cost_parameters: + capex_per_kW: 1472.0 + opex_per_kW_per_year: 32 + cost_year: 2022 From 73e53c03e328a52ac463532c1fd4c90e81951113 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:37:36 -0700 Subject: [PATCH 10/35] added floris to supported models --- h2integrate/core/supported_models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/h2integrate/core/supported_models.py b/h2integrate/core/supported_models.py index b84ed2b85..d69cfc010 100644 --- a/h2integrate/core/supported_models.py +++ b/h2integrate/core/supported_models.py @@ -5,6 +5,7 @@ from h2integrate.finances.profast_lco import ProFastLCO from h2integrate.finances.profast_npv import ProFastNPV from h2integrate.converters.steel.steel import SteelPerformanceModel, SteelCostAndFinancialModel +from h2integrate.converters.wind.floris import FlorisWindPlantPerformanceModel from h2integrate.converters.iron.iron_mine import ( IronMineCostComponent, IronMinePerformanceComponent, @@ -137,6 +138,7 @@ # Converters "atb_wind_cost": ATBWindPlantCostModel, "pysam_wind_plant_performance": PYSAMWindPlantPerformanceModel, + "floris_wind_plant_performance": FlorisWindPlantPerformanceModel, "pysam_solar_plant_performance": PYSAMSolarPlantPerformanceModel, "atb_utility_pv_cost": ATBUtilityPVCostModel, "atb_comm_res_pv_cost": ATBResComPVCostModel, From cbacf7e84e568cceb75abb402662d6960833fde7 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 21 Nov 2025 09:48:10 -0700 Subject: [PATCH 11/35] added test for floris --- h2integrate/converters/wind/floris.py | 54 +++++---- .../converters/wind/test/test_floris_wind.py | 114 ++++++++++++++++++ h2integrate/core/validators.py | 12 +- library/floris_v4_default_template.yaml | 4 +- 4 files changed, 158 insertions(+), 26 deletions(-) create mode 100644 h2integrate/converters/wind/test/test_floris_wind.py diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 3066e50c6..87563155d 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -23,24 +23,20 @@ class FlorisWindPlantPerformanceConfig(BaseConfig): floris_operation_model: str = field(default="cosine-loss") hub_height: float = field(default=-1, validator=gt_val(-1)) layout: dict = field(default={}) - # operation_model: str = field(default="cosine-loss") - # floris_turbines: dict | list[dict] = field() hybrid_turbine_design: bool = field(default=False) operational_losses: float = field(default=0.0, validator=gte_zero) enable_caching: bool = field(default=True) # if using multiple turbines, then need to specify resource reference height def __attrs_post_init__(self): - n_turbine_types = len(self.floris_config.get("farm", {}).get("turbine_type", [])) - n_pos = len(self.floris_config.get("farm", {}).get("layout_x", [])) + n_turbine_types = len(self.floris_wake_config.get("farm", {}).get("turbine_type", [])) + n_pos = len(self.floris_wake_config.get("farm", {}).get("layout_x", [])) if n_turbine_types > 1 and n_turbine_types != n_pos: self.hybrid_turbine_design = True # use floris_turbines if self.hub_height < 0 and not self.hybrid_turbine_design: - self.hub_height = ( - self.floris_config.get("farm", {}).get("turbine_type")[0].get("hub_height") - ) + self.hub_height = self.floris_turbine_config.get("hub_height") class FlorisWindPlantPerformanceModel(WindPerformanceBaseClass): @@ -91,23 +87,22 @@ def setup(self): ) self.add_output( - "annual_energy", + "total_electricity_produced", val=0.0, units="kW*h/year", - desc="Annual energy production from WindPlant in kW", + desc="Annual energy production from WindPlant", ) self.add_output( "total_capacity", val=0.0, units="kW", desc="Wind farm rated capacity in kW" ) + self.add_output( + "capacity_factor", val=0.0, units="percent", desc="Wind farm capacity factor" + ) + self.n_timesteps = int(self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]) - power_curve = ( - self.config.floris_turbine_config.get("farm", {}) - .get("turbine_type")[0] - .get("power_thrust_table") - .get("power") - ) + power_curve = self.config.floris_turbine_config.get("power_thrust_table").get("power") self.wind_turbine_rating_kW = np.max(power_curve) def format_resource_data(self, hub_height, wind_resource_data): @@ -125,15 +120,25 @@ def format_resource_data(self, hub_height, wind_resource_data): height_difference = [np.abs(hub_height - b) for b in bounding_heights] resource_height = bounding_heights[np.argmin(height_difference).flatten()[0]] - default_ti = self.floris_config.get("flow_field", {}).get("turbulence_intensities", [0.06]) + default_ti = self.config.floris_wake_config.get("flow_field", {}).get( + "turbulence_intensities", 0.06 + ) + ti = wind_resource_data.get("turbulence_intensity", {}).get( f"turbulence_intensity_{resource_height}", default_ti ) if isinstance(ti, (int, float)): - ti = [ti] + ti = float( + ti + ) # [ti]*len(wind_resource_data[f"wind_direction_{resource_height}m"]) #TODO: update + elif isinstance(ti, (list, np.ndarray)): + if len(ti) == 1: + ti = float(ti[0]) + if len(ti) == 0: + ti = 0.06 time_series = TimeSeries( - wind_directions=wind_resource_data[f"wind_direction_{resource_height}"], - wind_speeds=wind_resource_data[f"wind_speed_{resource_height}"], + wind_directions=wind_resource_data[f"wind_direction_{resource_height}m"], + wind_speeds=wind_resource_data[f"wind_speed_{resource_height}m"], turbulence_intensities=ti, ) @@ -157,13 +162,16 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): cached_data = dill.load(f) outputs["electricity_out"] = cached_data["electricity_out"] outputs["total_capacity"] = cached_data["total_capacity"] - outputs["annual_energy"] = cached_data["annual_energy"] + outputs["total_electricity_produced"] = cached_data["total_electricity_produced"] + outputs["capacity_factor"] = cached_data["capacity_factor"] return # If caching is not enabled or a cache file does not exist, run FLORIS n_turbs = int(np.round(inputs["num_turbines"][0])) floris_config = copy.deepcopy(self.config.floris_wake_config) + # make default turbulence intensity + turbine_design = copy.deepcopy(self.config.floris_turbine_config) # turbine_design = floris_config["farm"]["turbine_type"][0] @@ -207,7 +215,8 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): outputs["total_capacity"] = n_turbs * self.wind_turbine_rating_kW max_production = n_turbs * self.wind_turbine_rating_kW * len(gen) - outputs["annual_energy"] = (np.sum(gen) / max_production) * 8760 + outputs["total_electricity_produced"] = np.sum(gen) + outputs["capacity_factor"] = np.sum(gen) / max_production # Cache the results for future use if self.config.enable_caching: @@ -216,7 +225,8 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): floris_results = { "electricity_out": outputs["electricity_out"], "total_capacity": outputs["total_capacity"], - "annual_energy": outputs["total_capacity"], + "total_electricity_produced": outputs["total_electricity_produced"], + "capacity_factor": outputs["capacity_factor"], "layout_x": x_pos, "layout_y": y_pos, } diff --git a/h2integrate/converters/wind/test/test_floris_wind.py b/h2integrate/converters/wind/test/test_floris_wind.py new file mode 100644 index 000000000..066bbd5e7 --- /dev/null +++ b/h2integrate/converters/wind/test/test_floris_wind.py @@ -0,0 +1,114 @@ +import numpy as np +import pytest +import openmdao.api as om +from pytest import fixture + +from h2integrate import H2I_LIBRARY_DIR +from h2integrate.core.utilities import load_yaml +from h2integrate.converters.wind.floris import FlorisWindPlantPerformanceModel +from h2integrate.resource.wind.openmeteo_wind import OpenMeteoHistoricalWindResource + + +@fixture +def floris_config(): + floris_wake_config = load_yaml(H2I_LIBRARY_DIR / "floris_v4_default_template.yaml") + floris_turbine_config = load_yaml(H2I_LIBRARY_DIR / "floris_turbine_Vestas_660kW.yaml") + floris_performance_dict = { + "num_turbines": 20, + "floris_wake_config": floris_wake_config, + "floris_turbine_config": floris_turbine_config, + "hub_height": -1, + "layout": { + "layout_mode": "basicgrid", + "layout_options": { + "row_D_spacing": 5.0, + "turbine_D_spacing": 5.0, + }, + }, + "operational_losses": 12.83, + "enable_caching": False, + } + return floris_performance_dict + + +@fixture +def plant_config(): + site_config = { + "latitude": 44.04218, + "longitude": -95.19757, + "resource": { + "wind_resource": { + "resource_model": "openmeteo_wind_api", + "resource_parameters": { + "resource_year": 2023, + }, + } + }, + } + plant_dict = { + "plant_life": 30, + "simulation": {"n_timesteps": 8760, "dt": 3600, "start_time": "01/01 00:30:00"}, + } + + d = {"site": site_config, "plant": plant_dict} + return d + + +def test_floris_wind_performance(plant_config, floris_config, subtests): + cost_dict = { + "capex_per_kW": 1000, # overnight capital cost + "opex_per_kW_per_year": 5, # fixed operations and maintenance expenses + "cost_year": 2022, + } + tech_config_dict = { + "model_inputs": { + "performance_parameters": floris_config, + "cost_parameters": cost_dict, + } + } + + prob = om.Problem() + + wind_resource_config = plant_config["site"]["resource"]["wind_resource"]["resource_parameters"] + wind_resource = OpenMeteoHistoricalWindResource( + plant_config=plant_config, + resource_config=wind_resource_config, + driver_config={}, + ) + + wind_plant = FlorisWindPlantPerformanceModel( + plant_config=plant_config, + tech_config=tech_config_dict, + driver_config={}, + ) + + prob.model.add_subsystem("wind_resource", wind_resource, promotes=["*"]) + prob.model.add_subsystem("wind_plant", wind_plant, promotes=["*"]) + prob.setup() + prob.run_model() + + with subtests.test("wind farm capacity"): + assert ( + pytest.approx(prob.get_val("wind_plant.total_capacity", units="kW")[0], rel=1e-6) + == 660 * 20 + ) + + with subtests.test("wind farm capacity"): + assert ( + pytest.approx(prob.get_val("wind_plant.total_capacity", units="kW")[0], rel=1e-6) + == 660 * 20 + ) + + with subtests.test("AEP"): + assert ( + pytest.approx( + prob.get_val("wind_plant.total_electricity_produced", units="kW*h/year")[0], + rel=1e-6, + ) + == 36471.03023616864 * 1e3 + ) + + with subtests.test("total electricity_out"): + assert pytest.approx( + np.sum(prob.get_val("wind_plant.electricity_out", units="kW")), rel=1e-6 + ) == prob.get_val("wind_plant.total_electricity_produced", units="kW*h/year") diff --git a/h2integrate/core/validators.py b/h2integrate/core/validators.py index 041955201..0fb4401d8 100644 --- a/h2integrate/core/validators.py +++ b/h2integrate/core/validators.py @@ -29,8 +29,16 @@ def gt_val(min_val): """Validates that an attribute's value is greater than some minumum value.""" def validator(instance, attribute, value): - if value < min_val: - raise ValueError(f"{attribute} must be greater than {min_val} (but has value {value})") + if value is None: + if attribute.default < min_val: + raise ValueError( + f"{attribute.name} must be greater than {min_val} (but has value {value})" + ) + else: + if value < min_val: + raise ValueError( + f"{attribute.name} must be greater than {min_val} (but has value {value})" + ) return validator diff --git a/library/floris_v4_default_template.yaml b/library/floris_v4_default_template.yaml index 4b00ef9a5..cd48a9626 100644 --- a/library/floris_v4_default_template.yaml +++ b/library/floris_v4_default_template.yaml @@ -19,7 +19,7 @@ flow_field: wind_shear: 0.33 wind_speeds: [] wind_veer: 0.0 - turbulence_intensities: [0.06] + turbulence_intensities: [] wake: model_strings: @@ -88,5 +88,5 @@ wake: farm: layout_x: [] layout_y: [] - turbine_type: + # turbine_type: # - operation_model: cosine-loss From ebddbe522711fabb0be5548f87ccf5ceec7f901a Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 21 Nov 2025 10:28:44 -0700 Subject: [PATCH 12/35] added in wind resource tools --- h2integrate/converters/wind/tools/__init__.py | 0 .../converters/wind/tools/resource_tools.py | 118 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 h2integrate/converters/wind/tools/__init__.py create mode 100644 h2integrate/converters/wind/tools/resource_tools.py diff --git a/h2integrate/converters/wind/tools/__init__.py b/h2integrate/converters/wind/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/h2integrate/converters/wind/tools/resource_tools.py b/h2integrate/converters/wind/tools/resource_tools.py new file mode 100644 index 000000000..16e3ae825 --- /dev/null +++ b/h2integrate/converters/wind/tools/resource_tools.py @@ -0,0 +1,118 @@ +import numpy as np +from scipy.constants import R, g, convert_temperature + + +RHO_0 = 1.225 # Air density at sea level (kg/m3) +T_REF = 20 # Standard air temperature (Celsius) +MOLAR_MASS_AIR = 28.96 # Molar mass of air (g/mol) +LAPSE_RATE = 0.0065 # Temperature lapse rate (K/m) for 0-11000m above sea level + + +def calculate_air_density(elevation_m: float) -> float: + """ + Calculate air density based on site elevation using the Barometric formula. + + This function is based on Equation 1 from: https://en.wikipedia.org/wiki/Barometric_formula#Density_equations + Imported constants are: + + - g: acceleration due to gravity (m/s2) + - R: universal gas constant (J/mol-K) + + Args: + elevation_m (float): Elevation of site in meters + + Returns: + float: Air density in kg/m^3 at elevation of site + """ + + # Reference elevation at sea level (m) + elevation_sea_level = 0.0 + + # Convert temperature to Kelvin + T_ref_K = convert_temperature([T_REF], "C", "K")[0] + + # Exponent value used in equation below + e = g * (MOLAR_MASS_AIR / 1e3) / (R * LAPSE_RATE) + + # Calculate air density at site elevation + rho = RHO_0 * ((T_ref_K - ((elevation_m - elevation_sea_level) * LAPSE_RATE)) / T_ref_K) ** ( + e - 1 + ) + return rho + + +def calculate_air_density_losses(elevation_m: float) -> float: + """Calculate loss (%) from air density drop at site elevation. + + Args: + elevation_m (float): site elevation in meters + + Returns: + float: percentage loss associated with air density decrease at elevation. + """ + + if elevation_m <= 0.0: + return 0.0 + + air_density = calculate_air_density(elevation_m) + loss_ratio = 1 - (air_density / RHO_0) + loss_percent = loss_ratio * 100 + + return loss_percent + + +def weighted_average_wind_data_for_hubheight( + wind_resource_data: dict, + bounding_resource_heights: tuple[int] | list[int], + hub_height: float | int, + wind_resource_spec: str, +): + height_lower, height_upper = bounding_resource_heights + + has_lowerbound = f"{wind_resource_spec}_{height_lower}m" in wind_resource_data + has_upperbound = f"{wind_resource_spec}_{height_upper}m" in wind_resource_data + if not has_lowerbound or not has_upperbound: + msg = ( + f"Wind resource data for {wind_resource_spec} is missing either " + f"{height_lower}m or {height_upper}m" + ) + raise ValueError(msg) + + weight1 = np.abs(height_lower - hub_height) + weight2 = np.abs(height_upper - hub_height) + + weighted_wind_resource = ( + (weight1 * wind_resource_data[f"{wind_resource_spec}_{height_lower}m"]) + + (weight2 * wind_resource_data[f"{wind_resource_spec}_{height_upper}m"]) + ) / (weight1 + weight2) + + return weighted_wind_resource + + +def average_wind_data_for_hubheight( + wind_resource_data: dict, + bounding_resource_heights: tuple[int] | list[int], + hub_height: float | int, + wind_resource_spec: str, +): + height_lower, height_upper = bounding_resource_heights + # wind_resource_spec should be "wind_speed", "wind_directon" + # Weights corresponding to difference of resource height and hub-height + has_lowerbound = f"{wind_resource_spec}_{height_lower}m" in wind_resource_data + has_upperbound = f"{wind_resource_spec}_{height_upper}m" in wind_resource_data + if not has_lowerbound or not has_upperbound: + msg = ( + f"Wind resource data for {wind_resource_spec} is missing either " + f"{height_lower}m or {height_upper}m" + ) + raise ValueError(msg) + + combined_data = np.stack( + [ + wind_resource_data[f"{wind_resource_spec}_{height_lower}m"], + wind_resource_data[f"{wind_resource_spec}_{height_upper}m"], + ] + ) + averaged_data = combined_data.mean(axis=0) + + return averaged_data From fc53ff1150ea3cb368a242517b069a2a42fc0c77 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 21 Nov 2025 10:29:08 -0700 Subject: [PATCH 13/35] added resource parse methods into floris wrapper --- h2integrate/converters/wind/floris.py | 70 ++++++++++++------- .../converters/wind/test/test_floris_wind.py | 1 + 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 87563155d..9f2860ab3 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -7,7 +7,11 @@ from floris import TimeSeries, FlorisModel from h2integrate.core.utilities import BaseConfig, merge_shared_inputs, make_cache_hash_filename -from h2integrate.core.validators import gt_val, gt_zero, gte_zero +from h2integrate.core.validators import gt_val, gt_zero, contains, gte_zero +from h2integrate.converters.wind.tools.resource_tools import ( + average_wind_data_for_hubheight, + weighted_average_wind_data_for_hubheight, +) from h2integrate.converters.wind.wind_plant_baseclass import WindPerformanceBaseClass from h2integrate.converters.wind.layout.simple_grid_layout import ( BasicGridLayoutConfig, @@ -25,6 +29,9 @@ class FlorisWindPlantPerformanceConfig(BaseConfig): layout: dict = field(default={}) hybrid_turbine_design: bool = field(default=False) operational_losses: float = field(default=0.0, validator=gte_zero) + resource_data_averaging_method: str = field( + default="weighted_average", validator=contains(["weighted_average", "average", "nearest"]) + ) enable_caching: bool = field(default=True) # if using multiple turbines, then need to specify resource reference height @@ -108,37 +115,57 @@ def setup(self): def format_resource_data(self, hub_height, wind_resource_data): # NOTE: could weight resource data of bounding heights like # `weighted_parse_resource_data` method in HOPP - # constant_resource_data = ["wind_shear","wind_veer","reference_wind_height","air_density"] - # timeseries_resource_data = ["turbulence_intensity","wind_directions","wind_speeds"] bounding_heights = self.calculate_bounding_heights_from_resource_data( hub_height, wind_resource_data, resource_vars=["wind_speed", "wind_direction"] ) if len(bounding_heights) == 1: resource_height = bounding_heights[0] + windspeed = wind_resource_data[f"wind_speed_{resource_height}m"] + winddir = wind_resource_data[f"wind_direction_{resource_height}m"] else: - height_difference = [np.abs(hub_height - b) for b in bounding_heights] - resource_height = bounding_heights[np.argmin(height_difference).flatten()[0]] - + if self.config.resource_data_averaging_method == "nearest": + height_difference = [np.abs(hub_height - b) for b in bounding_heights] + resource_height = bounding_heights[np.argmin(height_difference).flatten()[0]] + windspeed = wind_resource_data[f"wind_speed_{resource_height}m"] + winddir = wind_resource_data[f"wind_direction_{resource_height}m"] + if self.config.resource_data_averaging_method == "weighted_average": + windspeed = weighted_average_wind_data_for_hubheight( + wind_resource_data, bounding_heights, hub_height, "wind_speed" + ) + winddir = weighted_average_wind_data_for_hubheight( + wind_resource_data, bounding_heights, hub_height, "wind_direction" + ) + if self.config.resource_data_averaging_method == "average": + windspeed = average_wind_data_for_hubheight( + wind_resource_data, bounding_heights, hub_height, "wind_speed" + ) + winddir = average_wind_data_for_hubheight( + wind_resource_data, bounding_heights, hub_height, "wind_direction" + ) + + # TODO: add in option to weight resource data default_ti = self.config.floris_wake_config.get("flow_field", {}).get( "turbulence_intensities", 0.06 ) - ti = wind_resource_data.get("turbulence_intensity", {}).get( - f"turbulence_intensity_{resource_height}", default_ti + f"turbulence_intensity_{resource_height}m", default_ti ) - if isinstance(ti, (int, float)): - ti = float( - ti - ) # [ti]*len(wind_resource_data[f"wind_direction_{resource_height}m"]) #TODO: update - elif isinstance(ti, (list, np.ndarray)): - if len(ti) == 1: - ti = float(ti[0]) + if not isinstance(ti, (int, float)): if len(ti) == 0: ti = 0.06 + if not isinstance(ti, (float, int)) and len(ti) == 1: + ti = float(ti[0]) + if not isinstance(ti, (float, int)) and len(ti) != self.n_timesteps: + msg = ( + f"Turbulence intensity is length {len(ti)} but must be " + f"either be length {self.n_timesteps} or a float" + ) + raise ValueError(msg) + time_series = TimeSeries( - wind_directions=wind_resource_data[f"wind_direction_{resource_height}m"], - wind_speeds=wind_resource_data[f"wind_speed_{resource_height}m"], + wind_directions=winddir, + wind_speeds=windspeed, turbulence_intensities=ti, ) @@ -169,13 +196,10 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # If caching is not enabled or a cache file does not exist, run FLORIS n_turbs = int(np.round(inputs["num_turbines"][0])) + # Copy main config files floris_config = copy.deepcopy(self.config.floris_wake_config) - # make default turbulence intensity - turbine_design = copy.deepcopy(self.config.floris_turbine_config) - # turbine_design = floris_config["farm"]["turbine_type"][0] - # update the turbine hub-height in the floris turbine config turbine_design.update({"hub_height": inputs["hub_height"][0]}) @@ -202,9 +226,7 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # run the model self.fi.run() - # power_turbines = self.fi.get_turbine_powers().reshape( - # (n_turbs, self.n_timesteps) - # ) #W + power_farm = self.fi.get_farm_power().reshape(self.n_timesteps) # W # Adding losses (excluding turbine and wake losses) diff --git a/h2integrate/converters/wind/test/test_floris_wind.py b/h2integrate/converters/wind/test/test_floris_wind.py index 066bbd5e7..fd0909716 100644 --- a/h2integrate/converters/wind/test/test_floris_wind.py +++ b/h2integrate/converters/wind/test/test_floris_wind.py @@ -27,6 +27,7 @@ def floris_config(): }, "operational_losses": 12.83, "enable_caching": False, + "resource_data_averaging_method": "nearest", } return floris_performance_dict From c8ed08b340c6a9fd3e5466101caf01f8276b4a8d Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:09:53 -0700 Subject: [PATCH 14/35] updates to floris and resource tools --- h2integrate/converters/wind/floris.py | 4 +- .../converters/wind/tools/resource_tools.py | 37 +++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 9f2860ab3..15dccb747 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -138,10 +138,10 @@ def format_resource_data(self, hub_height, wind_resource_data): ) if self.config.resource_data_averaging_method == "average": windspeed = average_wind_data_for_hubheight( - wind_resource_data, bounding_heights, hub_height, "wind_speed" + wind_resource_data, bounding_heights, "wind_speed" ) winddir = average_wind_data_for_hubheight( - wind_resource_data, bounding_heights, hub_height, "wind_direction" + wind_resource_data, bounding_heights, "wind_direction" ) # TODO: add in option to weight resource data diff --git a/h2integrate/converters/wind/tools/resource_tools.py b/h2integrate/converters/wind/tools/resource_tools.py index 16e3ae825..064086538 100644 --- a/h2integrate/converters/wind/tools/resource_tools.py +++ b/h2integrate/converters/wind/tools/resource_tools.py @@ -67,6 +67,23 @@ def weighted_average_wind_data_for_hubheight( hub_height: float | int, wind_resource_spec: str, ): + """Compute the weighted average of wind resource data at two resource heights. + + Args: + wind_resource_data (dict): dictionary of wind resource data + bounding_resource_heights (tuple[int] | list[int]): resource heights that bound the + hub-height, formatted as [lower_resource_height, upper_resource_height] + hub_height (float | int): wind turbine hub-height in meters. + wind_resource_spec (str): wind resource data key that is unique for + each hub-height. Such as `'wind_speed'` or `'wind_direction'` + + Raises: + ValueError: if f'{wind_resource_spec}_{lower_resource_height}m' or + f'{wind_resource_spec}_{upper_resource_height}m' are not found in `wind_resource_data` + + Returns: + np.ndarray: wind resource data averaged between the two bounding heights. + """ height_lower, height_upper = bounding_resource_heights has_lowerbound = f"{wind_resource_spec}_{height_lower}m" in wind_resource_data @@ -92,12 +109,26 @@ def weighted_average_wind_data_for_hubheight( def average_wind_data_for_hubheight( wind_resource_data: dict, bounding_resource_heights: tuple[int] | list[int], - hub_height: float | int, wind_resource_spec: str, ): + """Compute the average of wind resource data at two resource heights. + + Args: + wind_resource_data (dict): dictionary of wind resource data + bounding_resource_heights (tuple[int] | list[int]): resource heights that bound the + hub-height, formatted as [lower_resource_height, upper_resource_height] + wind_resource_spec (str): wind resource data key that is unique for + each hub-height. Such as `'wind_speed'` or `'wind_direction'` + + Raises: + ValueError: if f'{wind_resource_spec}_{lower_resource_height}m' or + f'{wind_resource_spec}_{upper_resource_height}m' are not found in `wind_resource_data` + + Returns: + np.ndarray: wind resource data averaged between the two bounding heights. + """ height_lower, height_upper = bounding_resource_heights - # wind_resource_spec should be "wind_speed", "wind_directon" - # Weights corresponding to difference of resource height and hub-height + has_lowerbound = f"{wind_resource_spec}_{height_lower}m" in wind_resource_data has_upperbound = f"{wind_resource_spec}_{height_upper}m" in wind_resource_data if not has_lowerbound or not has_upperbound: From 04b9f1b322c570a24474f1344329a94f55dba9af Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:16:22 -0700 Subject: [PATCH 15/35] added docstrings and added cache_dir as input to make_cache_hash_filename --- h2integrate/converters/wind/floris.py | 49 +++++++++++++++++++++++---- h2integrate/core/utilities.py | 7 ++-- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 15dccb747..d9e0fc619 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -7,7 +7,7 @@ from floris import TimeSeries, FlorisModel from h2integrate.core.utilities import BaseConfig, merge_shared_inputs, make_cache_hash_filename -from h2integrate.core.validators import gt_val, gt_zero, contains, gte_zero +from h2integrate.core.validators import gt_val, gt_zero, contains, range_val from h2integrate.converters.wind.tools.resource_tools import ( average_wind_data_for_hubheight, weighted_average_wind_data_for_hubheight, @@ -21,18 +21,52 @@ @define class FlorisWindPlantPerformanceConfig(BaseConfig): + """Configuration class for FlorisWindPlantPerformanceModel. + + Attributes: + num_turbines (int): number of turbines in farm + floris_wake_config (dict): dictionary containing FLORIS inputs for flow_field, wake, + solver, and logging. + floris_turbine_config (dict): dictionary containing turbine parameters formatted for FLORIS. + operational_losses (float | int): non-wake losses represented as a percentage + (between 0 and 100). + hub_height (float | int, optional): a value of -1 indicates to use the hub-height + from the `floris_turbine_config`. Otherwise, is the turbine hub-height + in meters. Defaults to -1. + floris_operation_model (str, optional): turbine operation model. Defaults to 'cosine-loss'. + layout (dict): layout parameters dictionary. + resource_data_averaging_method (str): string indicating what method to use to + adjust or select resource data if no resource data is available at a height + exactly equal to the turbine hub-height. Defaults to 'weighted_average'. + The available methods are: + - 'weighted_average': average the resource data at the heights that most closely bound + the hub-height, weighted by the difference between the resource heights and the + hub-height. + - 'average': average the resource data at the heights that most closely bound + the hub-height. + - 'nearest': use the resource data at the height closest to the hub-height. + enable_caching (bool, optional): if True, checks if the outputs have be saved to a + cached file or saves outputs to a file. Defaults to True. + cache_dir (str | Path, optional): folder to use for reading or writing cached results files. + Only used if enable_caching is True. Defaults to "cache". + hybrid_turbine_design (bool, optional): whether multiple turbine types are included in + the farm. Defaults to False. The functionality to use multiple turbine types + is not yet implemented. Will result in NotImplementedError if True. + """ + num_turbines: int = field(converter=int, validator=gt_zero) floris_wake_config: dict = field() floris_turbine_config: dict = field() - floris_operation_model: str = field(default="cosine-loss") + operational_losses: float = field(validator=range_val(0.0, 100.0)) hub_height: float = field(default=-1, validator=gt_val(-1)) + floris_operation_model: str = field(default="cosine-loss") layout: dict = field(default={}) - hybrid_turbine_design: bool = field(default=False) - operational_losses: float = field(default=0.0, validator=gte_zero) resource_data_averaging_method: str = field( default="weighted_average", validator=contains(["weighted_average", "average", "nearest"]) ) enable_caching: bool = field(default=True) + cache_dir: str | Path = field(default="cache") + hybrid_turbine_design: bool = field(default=False) # if using multiple turbines, then need to specify resource reference height def __attrs_post_init__(self): @@ -49,7 +83,8 @@ def __attrs_post_init__(self): class FlorisWindPlantPerformanceModel(WindPerformanceBaseClass): """ An OpenMDAO component that wraps a Floris model. - It takes wind parameters as input and outputs power generation data. + It takes wind turbine model parameters and wind resource data as input and + outputs power generation data. """ def setup(self): @@ -180,7 +215,9 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): if self.config.enable_caching: config_dict = self.config.as_dict() config_dict.update({"wind_turbine_size_kw": self.wind_turbine_rating_kW}) - cache_filename = make_cache_hash_filename(config_dict, inputs, discrete_inputs) + cache_filename = make_cache_hash_filename( + config_dict, inputs, discrete_inputs, cache_dir=self.config.cache_dir + ) if Path(cache_filename).exists(): # Load the cached results diff --git a/h2integrate/core/utilities.py b/h2integrate/core/utilities.py index 0d7ceaab3..4a6dc6024 100644 --- a/h2integrate/core/utilities.py +++ b/h2integrate/core/utilities.py @@ -881,7 +881,7 @@ def _structured(meta_list): } -def make_cache_hash_filename(config, inputs, discrete_inputs={}): +def make_cache_hash_filename(config, inputs, discrete_inputs={}, cache_dir=Path("cache")): """Make valid filepath to a pickle file with a filename that is unique based on information available in the config, inputs, and discrete inputs. @@ -890,6 +890,7 @@ def make_cache_hash_filename(config, inputs, discrete_inputs={}): inputs (om.vectors.default_vector.DefaultVector): OM inputs to `compute()` method discrete_inputs (om.core.component._DictValues, optional): OM discrete inputs to `compute()` method. Defaults to {}. + cache_dir (str | Path, optional): folder for cached files. Defaults to Path("cache"). Returns: Path: filepath to pickle file with filename as unique cache key. @@ -911,8 +912,10 @@ def make_cache_hash_filename(config, inputs, discrete_inputs={}): # Create a unique hash for the current configuration to use as a cache key config_hash = hashlib.md5(str(hash_dict).encode("utf-8")).hexdigest() + if isinstance(cache_dir, str): + cache_dir = Path(cache_dir) + # Create a cache directory if it doesn't exist - cache_dir = Path("cache") if not cache_dir.exists(): cache_dir.mkdir(parents=True) cache_file = cache_dir / f"{config_hash}.pkl" From 985873e496e61977bd8492c18b58a00738cf4a14 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:41:31 -0700 Subject: [PATCH 16/35] bugfixes in floris model, updated floris example, added tests for floris example --- examples/floris_example/run_floris_example.py | 2 +- examples/floris_example/tech_config.yaml | 1 + h2integrate/converters/wind/floris.py | 15 ++++-- tests/h2integrate/test_all_examples.py | 50 +++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/examples/floris_example/run_floris_example.py b/examples/floris_example/run_floris_example.py index 69d327994..1a39a494b 100644 --- a/examples/floris_example/run_floris_example.py +++ b/examples/floris_example/run_floris_example.py @@ -18,7 +18,7 @@ "technology_config": tech_config, "plant_config": plant_config, } -# Create a GreenHEART model +# Create a H2I model h2i = H2IntegrateModel(h2i_config) # Run the model diff --git a/examples/floris_example/tech_config.yaml b/examples/floris_example/tech_config.yaml index 0d99c1789..3d2a634d8 100644 --- a/examples/floris_example/tech_config.yaml +++ b/examples/floris_example/tech_config.yaml @@ -11,6 +11,7 @@ technologies: performance_parameters: num_turbines: 100 hub_height: 65.0 + operational_losses: 12.83 floris_wake_config: !include "floris_v4_default_template.yaml" floris_turbine_config: !include "floris_turbine_Vestas_660kW.yaml" layout: diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index d9e0fc619..b762e7d0e 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -179,13 +179,20 @@ def format_resource_data(self, hub_height, wind_resource_data): wind_resource_data, bounding_heights, "wind_direction" ) - # TODO: add in option to weight resource data + # get turbulence intensity + # check if turbulence intensity is specified in the floris wake config default_ti = self.config.floris_wake_config.get("flow_field", {}).get( "turbulence_intensities", 0.06 ) - ti = wind_resource_data.get("turbulence_intensity", {}).get( - f"turbulence_intensity_{resource_height}m", default_ti - ) + # check if turbulence intensity is available in wind resource data + if any("turbulence_intensity_" in k for k in wind_resource_data.keys()): + for height in bounding_heights: + if f"turbulence_intensity_{height}m" in wind_resource_data: + ti = wind_resource_data.get(f"turbulence_intensity_{height}m", default_ti) + break + else: + ti = wind_resource_data.get("turbulence_intensity", default_ti) + if not isinstance(ti, (int, float)): if len(ti) == 0: ti = 0.06 diff --git a/tests/h2integrate/test_all_examples.py b/tests/h2integrate/test_all_examples.py index afe024a55..c685dae60 100644 --- a/tests/h2integrate/test_all_examples.py +++ b/tests/h2integrate/test_all_examples.py @@ -1254,3 +1254,53 @@ def test_sweeping_solar_sites_doe(subtests): with subtests.test("Unique LCOEs per case"): assert len(list(set(res_df["LCOE"].to_list()))) == len(res_df) + + +def test_floris_example(subtests): + from h2integrate.core.utilities import load_yaml + + os.chdir(EXAMPLE_DIR / "floris_example") + + driver_config = load_yaml(EXAMPLE_DIR / "floris_example" / "driver_config.yaml") + tech_config = load_yaml(EXAMPLE_DIR / "floris_example" / "tech_config.yaml") + plant_config = load_yaml(EXAMPLE_DIR / "floris_example" / "plant_config.yaml") + + h2i_config = { + "name": "H2Integrate_config", + "system_summary": "", + "driver_config": driver_config, + "technology_config": tech_config, + "plant_config": plant_config, + } + + # Create a H2I model + h2i = H2IntegrateModel(h2i_config) + + # Run the model + h2i.run() + + with subtests.test("LCOE"): + assert ( + pytest.approx( + h2i.prob.get_val("finance_subgroup_electricity.LCOE", units="USD/MW/h")[0], rel=1e-6 + ) + == 125.4133009 + ) + + with subtests.test("Wind capacity factor"): + assert pytest.approx(h2i.prob.get_val("wind.total_capacity", units="MW"), rel=1e-6) == 66.0 + + with subtests.test("Total electricity production"): + assert ( + pytest.approx( + np.sum(h2i.prob.get_val("wind.total_electricity_produced", units="MW*h/yr")), + rel=1e-6, + ) + == 102687.22266 + ) + + with subtests.test("Capacity factor"): + assert ( + pytest.approx(h2i.prob.get_val("wind.capacity_factor", units="percent")[0], rel=1e-6) + == 0.177610389263 + ) From 913e659a1e5f0c5c7c95111853e65200e1752bdd Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:00:23 -0700 Subject: [PATCH 17/35] minor update to example tech config --- examples/floris_example/tech_config.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/floris_example/tech_config.yaml b/examples/floris_example/tech_config.yaml index 3d2a634d8..3b43d2c7a 100644 --- a/examples/floris_example/tech_config.yaml +++ b/examples/floris_example/tech_config.yaml @@ -14,6 +14,10 @@ technologies: operational_losses: 12.83 floris_wake_config: !include "floris_v4_default_template.yaml" floris_turbine_config: !include "floris_turbine_Vestas_660kW.yaml" + resource_data_averaging_method: "weighted_average" + floris_operation_model: "cosine-loss" + enable_caching: True + cache_dir: "cache" layout: layout_mode: "basicgrid" layout_options: From a6786a1ec2c36755c455702c061d69588f4c9775 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:12:52 -0700 Subject: [PATCH 18/35] added comments and updated changelog --- CHANGELOG.md | 1 + examples/floris_example/tech_config.yaml | 18 +++++++++--------- tests/h2integrate/test_all_examples.py | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a64c168..01b6e37b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ - Added standalone iron mine performance and cost model - Added solar resource models for Meteosat Prime Meridian and Himawari datasets available through NSRDB - Improved readability of the postprocessing printout by simplifying numerical representation, especially for years +- Added FLORIS wind performance model ## 0.4.0 [October 1, 2025] diff --git a/examples/floris_example/tech_config.yaml b/examples/floris_example/tech_config.yaml index 3b43d2c7a..555c9aaa1 100644 --- a/examples/floris_example/tech_config.yaml +++ b/examples/floris_example/tech_config.yaml @@ -9,15 +9,15 @@ technologies: model: "atb_wind_cost" model_inputs: performance_parameters: - num_turbines: 100 - hub_height: 65.0 - operational_losses: 12.83 - floris_wake_config: !include "floris_v4_default_template.yaml" - floris_turbine_config: !include "floris_turbine_Vestas_660kW.yaml" - resource_data_averaging_method: "weighted_average" - floris_operation_model: "cosine-loss" - enable_caching: True - cache_dir: "cache" + num_turbines: 100 #number of turbines in the farm + hub_height: 65.0 # turbine hub-height + operational_losses: 12.83 #percentage of non-wake losses + floris_wake_config: !include "floris_v4_default_template.yaml" #floris wake model file + floris_turbine_config: !include "floris_turbine_Vestas_660kW.yaml" #turbine model file formatted for floris + resource_data_averaging_method: "weighted_average" #"weighted_average", "average" or "nearest" + floris_operation_model: "cosine-loss" #turbine operation model + enable_caching: True #whether to use cached results + cache_dir: "cache" #directory to save or load cached data layout: layout_mode: "basicgrid" layout_options: diff --git a/tests/h2integrate/test_all_examples.py b/tests/h2integrate/test_all_examples.py index c685dae60..482f3c994 100644 --- a/tests/h2integrate/test_all_examples.py +++ b/tests/h2integrate/test_all_examples.py @@ -1287,7 +1287,7 @@ def test_floris_example(subtests): == 125.4133009 ) - with subtests.test("Wind capacity factor"): + with subtests.test("Wind plant capacity"): assert pytest.approx(h2i.prob.get_val("wind.total_capacity", units="MW"), rel=1e-6) == 66.0 with subtests.test("Total electricity production"): From b90b25a63532ac552ffbd4a7ce37246d2cc26e8e Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:35:04 -0700 Subject: [PATCH 19/35] added floris to technology overview --- docs/technology_models/technology_overview.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/technology_models/technology_overview.md b/docs/technology_models/technology_overview.md index 610967676..27b4b3c8d 100644 --- a/docs/technology_models/technology_overview.md +++ b/docs/technology_models/technology_overview.md @@ -127,6 +127,7 @@ Below summarizes the available performance, cost, and financial models for each - `wind`: wind turbine - performance models: + `'pysam_wind_plant_performance'` + + `'floris_wind_plant_performance'` - cost models: + `'atb_wind_cost'` - `solar`: solar-PV panels From 0190a8baa56672946e472776c84e65c841976425 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:20:49 -0700 Subject: [PATCH 20/35] minor updates from review feedback --- examples/floris_example/tech_config.yaml | 1 + h2integrate/converters/wind/floris.py | 47 +++++++++---------- .../converters/wind/test/test_floris_wind.py | 8 +--- h2integrate/core/validators.py | 18 ------- library/floris_v4_default_template.yaml | 1 - 5 files changed, 24 insertions(+), 51 deletions(-) diff --git a/examples/floris_example/tech_config.yaml b/examples/floris_example/tech_config.yaml index 555c9aaa1..fc6de0de2 100644 --- a/examples/floris_example/tech_config.yaml +++ b/examples/floris_example/tech_config.yaml @@ -16,6 +16,7 @@ technologies: floris_turbine_config: !include "floris_turbine_Vestas_660kW.yaml" #turbine model file formatted for floris resource_data_averaging_method: "weighted_average" #"weighted_average", "average" or "nearest" floris_operation_model: "cosine-loss" #turbine operation model + default_turbulence_intensity: 0.06 enable_caching: True #whether to use cached results cache_dir: "cache" #directory to save or load cached data layout: diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index b762e7d0e..905500bab 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -3,11 +3,11 @@ import dill import numpy as np -from attrs import field, define +from attrs import field, define, validators from floris import TimeSeries, FlorisModel from h2integrate.core.utilities import BaseConfig, merge_shared_inputs, make_cache_hash_filename -from h2integrate.core.validators import gt_val, gt_zero, contains, range_val +from h2integrate.core.validators import gt_zero, contains, range_val from h2integrate.converters.wind.tools.resource_tools import ( average_wind_data_for_hubheight, weighted_average_wind_data_for_hubheight, @@ -34,6 +34,8 @@ class FlorisWindPlantPerformanceConfig(BaseConfig): from the `floris_turbine_config`. Otherwise, is the turbine hub-height in meters. Defaults to -1. floris_operation_model (str, optional): turbine operation model. Defaults to 'cosine-loss'. + default_turbulence_intensity (float): default turbulence intensity to use if not found + in wind resource data. layout (dict): layout parameters dictionary. resource_data_averaging_method (str): string indicating what method to use to adjust or select resource data if no resource data is available at a height @@ -57,8 +59,9 @@ class FlorisWindPlantPerformanceConfig(BaseConfig): num_turbines: int = field(converter=int, validator=gt_zero) floris_wake_config: dict = field() floris_turbine_config: dict = field() + default_turbulence_intensity: float = field() operational_losses: float = field(validator=range_val(0.0, 100.0)) - hub_height: float = field(default=-1, validator=gt_val(-1)) + hub_height: float = field(default=-1, validator=validators.ge(-1)) floris_operation_model: str = field(default="cosine-loss") layout: dict = field(default={}) resource_data_averaging_method: str = field( @@ -111,7 +114,8 @@ def setup(self): if self.config.hybrid_turbine_design: raise NotImplementedError( - "H2I does not currently support running multiple wind turbines with Floris." + "H2I does not currently support running multiple different wind turbine " + "designs with Floris." ) self.add_input( @@ -125,7 +129,7 @@ def setup(self): "hub_height", val=self.config.hub_height, units="m", - desc="turbine hub-height in meters", + desc="turbine hub-height", ) self.add_output( @@ -134,9 +138,7 @@ def setup(self): units="kW*h/year", desc="Annual energy production from WindPlant", ) - self.add_output( - "total_capacity", val=0.0, units="kW", desc="Wind farm rated capacity in kW" - ) + self.add_output("total_capacity", val=0.0, units="kW", desc="Wind farm rated capacity") self.add_output( "capacity_factor", val=0.0, units="percent", desc="Wind farm capacity factor" @@ -180,30 +182,19 @@ def format_resource_data(self, hub_height, wind_resource_data): ) # get turbulence intensity - # check if turbulence intensity is specified in the floris wake config - default_ti = self.config.floris_wake_config.get("flow_field", {}).get( - "turbulence_intensities", 0.06 - ) + # check if turbulence intensity is available in wind resource data if any("turbulence_intensity_" in k for k in wind_resource_data.keys()): for height in bounding_heights: if f"turbulence_intensity_{height}m" in wind_resource_data: - ti = wind_resource_data.get(f"turbulence_intensity_{height}m", default_ti) + ti = wind_resource_data.get( + f"turbulence_intensity_{height}m", self.config.default_turbulence_intensity + ) break else: - ti = wind_resource_data.get("turbulence_intensity", default_ti) - - if not isinstance(ti, (int, float)): - if len(ti) == 0: - ti = 0.06 - if not isinstance(ti, (float, int)) and len(ti) == 1: - ti = float(ti[0]) - if not isinstance(ti, (float, int)) and len(ti) != self.n_timesteps: - msg = ( - f"Turbulence intensity is length {len(ti)} but must be " - f"either be length {self.n_timesteps} or a float" - ) - raise ValueError(msg) + ti = wind_resource_data.get( + "turbulence_intensity", self.config.default_turbulence_intensity + ) time_series = TimeSeries( wind_directions=winddir, @@ -247,6 +238,9 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # update the turbine hub-height in the floris turbine config turbine_design.update({"hub_height": inputs["hub_height"][0]}) + # update the operation model in the floris turbine config + turbine_design.update({"operation_model": self.config.floris_operation_model}) + # format resource data and input into model time_series = self.format_resource_data( inputs["hub_height"][0], discrete_inputs["wind_resource_data"] @@ -263,6 +257,7 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): floris_config["farm"].update(floris_farm) # initialize FLORIS + floris_config["flow_field"].update({"turbulence_intensities": []}) self.fi = FlorisModel(floris_config) # set the layout and wind data in Floris diff --git a/h2integrate/converters/wind/test/test_floris_wind.py b/h2integrate/converters/wind/test/test_floris_wind.py index fd0909716..be452a45d 100644 --- a/h2integrate/converters/wind/test/test_floris_wind.py +++ b/h2integrate/converters/wind/test/test_floris_wind.py @@ -17,6 +17,8 @@ def floris_config(): "num_turbines": 20, "floris_wake_config": floris_wake_config, "floris_turbine_config": floris_turbine_config, + "default_turbulence_intensity": 0.06, + "floris_operation_model": "cosine-loss", "hub_height": -1, "layout": { "layout_mode": "basicgrid", @@ -88,12 +90,6 @@ def test_floris_wind_performance(plant_config, floris_config, subtests): prob.setup() prob.run_model() - with subtests.test("wind farm capacity"): - assert ( - pytest.approx(prob.get_val("wind_plant.total_capacity", units="kW")[0], rel=1e-6) - == 660 * 20 - ) - with subtests.test("wind farm capacity"): assert ( pytest.approx(prob.get_val("wind_plant.total_capacity", units="kW")[0], rel=1e-6) diff --git a/h2integrate/core/validators.py b/h2integrate/core/validators.py index 0fb4401d8..6fcf0b763 100644 --- a/h2integrate/core/validators.py +++ b/h2integrate/core/validators.py @@ -25,24 +25,6 @@ def validator(instance, attribute, value): return validator -def gt_val(min_val): - """Validates that an attribute's value is greater than some minumum value.""" - - def validator(instance, attribute, value): - if value is None: - if attribute.default < min_val: - raise ValueError( - f"{attribute.name} must be greater than {min_val} (but has value {value})" - ) - else: - if value < min_val: - raise ValueError( - f"{attribute.name} must be greater than {min_val} (but has value {value})" - ) - - return validator - - def range_val_or_none(min_val, max_val): """Validates that an attribute's value is between two values, inclusive ([min_val, max_val]). Ignores None type values.""" diff --git a/library/floris_v4_default_template.yaml b/library/floris_v4_default_template.yaml index cd48a9626..4797b6083 100644 --- a/library/floris_v4_default_template.yaml +++ b/library/floris_v4_default_template.yaml @@ -19,7 +19,6 @@ flow_field: wind_shear: 0.33 wind_speeds: [] wind_veer: 0.0 - turbulence_intensities: [] wake: model_strings: From 0c9cb7756073e58c07235a99a685ded6912f214e Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:46:00 -0700 Subject: [PATCH 21/35] added error message if layout is provided in floris config --- h2integrate/converters/wind/floris.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 905500bab..12ef04105 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -82,6 +82,19 @@ def __attrs_post_init__(self): if self.hub_height < 0 and not self.hybrid_turbine_design: self.hub_height = self.floris_turbine_config.get("hub_height") + # check that user did not provide a layout in the floris_wake_config + gave_x_coords = len(self.floris_wake_config.get("farm", {}).get("layout_x", [])) > 0 + gave_y_coords = len(self.floris_wake_config.get("farm", {}).get("layout_y", [])) > 0 + if gave_x_coords or gave_y_coords: + msg = ( + "Layout provided in `floris_wake_config['farm']` but layout will be created " + "based on the `layout_mode` and `layout_options` provided in the " + "`layout` dictionary. Please set the layout in " + "floris_wake_config['farm']['layout_x'] and floris_wake_config['farm']['layout_y']" + " to empty lists" + ) + raise ValueError(msg) + class FlorisWindPlantPerformanceModel(WindPerformanceBaseClass): """ From c19a68891a26742a4a9c47dad4a7713bb5a0bc92 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:28:30 -0700 Subject: [PATCH 22/35] updated cache dir to only be created if caching is enabled --- h2integrate/core/utilities.py | 44 +---------------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/h2integrate/core/utilities.py b/h2integrate/core/utilities.py index 75110b4fa..6644b9679 100644 --- a/h2integrate/core/utilities.py +++ b/h2integrate/core/utilities.py @@ -1,7 +1,6 @@ import re import csv import copy -import hashlib import operator from typing import Any from pathlib import Path @@ -214,7 +213,7 @@ def __attrs_post_init__(self): self.cache_dir = Path(self.cache_dir) # Create a cache directory if it doesn't exist - if not self.cache_dir.exists(): + if self.enable_caching and not self.cache_dir.exists(): self.cache_dir.mkdir(parents=True, exist_ok=True) @@ -952,44 +951,3 @@ def _structured(meta_list): "explicit_outputs": _structured(explicit_meta), "implicit_outputs": _structured(implicit_meta), } - - -def make_cache_hash_filename(config, inputs, discrete_inputs={}, cache_dir=Path("cache")): - """Make valid filepath to a pickle file with a filename that is unique based on information - available in the config, inputs, and discrete inputs. - - Args: - config (object | dict): configuration object that inherits `BaseConfig` or dictionary. - inputs (om.vectors.default_vector.DefaultVector): OM inputs to `compute()` method - discrete_inputs (om.core.component._DictValues, optional): OM discrete inputs to `compute()` - method. Defaults to {}. - cache_dir (str | Path, optional): folder for cached files. Defaults to Path("cache"). - - Returns: - Path: filepath to pickle file with filename as unique cache key. - """ - # NOTE: maybe would be good to add a string input that can specify what model this cache is for, - # like "hopp" or "floris", this could be used in the cache filename but perhaps unnecessary - - if not isinstance(config, dict): - hash_dict = config.as_dict() - else: - hash_dict = copy.deepcopy(config) - - input_dict = dict(inputs.items()) - discrete_input_dict = dict(discrete_inputs.items()) - - hash_dict.update(input_dict) - hash_dict.update(discrete_input_dict) - - # Create a unique hash for the current configuration to use as a cache key - config_hash = hashlib.md5(str(hash_dict).encode("utf-8")).hexdigest() - - if isinstance(cache_dir, str): - cache_dir = Path(cache_dir) - - # Create a cache directory if it doesn't exist - if not cache_dir.exists(): - cache_dir.mkdir(parents=True) - cache_file = cache_dir / f"{config_hash}.pkl" - return cache_file From c3933573c5441f407a7f5524a26b969052bc3536 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:28:59 -0700 Subject: [PATCH 23/35] updated floris to use cache baseclass and added tests --- .../converters/hopp/test/test_hopp_caching.py | 3 + h2integrate/converters/wind/floris.py | 63 +++---- .../converters/wind/test/test_floris_wind.py | 160 +++++++++++++++++- 3 files changed, 178 insertions(+), 48 deletions(-) diff --git a/h2integrate/converters/hopp/test/test_hopp_caching.py b/h2integrate/converters/hopp/test/test_hopp_caching.py index 5db630aa9..2579826ed 100644 --- a/h2integrate/converters/hopp/test/test_hopp_caching.py +++ b/h2integrate/converters/hopp/test/test_hopp_caching.py @@ -72,3 +72,6 @@ def test_hopp_wrapper_cache_filenames(subtests, plant_config, tech_config): with subtests.test("Check unique filename with modified config"): assert len(cache_filename_new) > 0 + + # Delete cache files and the testing cache dir + shutil.rmtree(cache_dir) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 12ef04105..8e7445526 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -1,13 +1,12 @@ import copy -from pathlib import Path -import dill import numpy as np from attrs import field, define, validators from floris import TimeSeries, FlorisModel -from h2integrate.core.utilities import BaseConfig, merge_shared_inputs, make_cache_hash_filename +from h2integrate.core.utilities import CacheBaseConfig, merge_shared_inputs from h2integrate.core.validators import gt_zero, contains, range_val +from h2integrate.core.model_baseclasses import CacheBaseClass from h2integrate.converters.wind.tools.resource_tools import ( average_wind_data_for_hubheight, weighted_average_wind_data_for_hubheight, @@ -20,7 +19,7 @@ @define -class FlorisWindPlantPerformanceConfig(BaseConfig): +class FlorisWindPlantPerformanceConfig(CacheBaseConfig): """Configuration class for FlorisWindPlantPerformanceModel. Attributes: @@ -67,12 +66,11 @@ class FlorisWindPlantPerformanceConfig(BaseConfig): resource_data_averaging_method: str = field( default="weighted_average", validator=contains(["weighted_average", "average", "nearest"]) ) - enable_caching: bool = field(default=True) - cache_dir: str | Path = field(default="cache") hybrid_turbine_design: bool = field(default=False) # if using multiple turbines, then need to specify resource reference height def __attrs_post_init__(self): + super().__attrs_post_init__() n_turbine_types = len(self.floris_wake_config.get("farm", {}).get("turbine_type", [])) n_pos = len(self.floris_wake_config.get("farm", {}).get("layout_x", [])) if n_turbine_types > 1 and n_turbine_types != n_pos: @@ -96,7 +94,7 @@ def __attrs_post_init__(self): raise ValueError(msg) -class FlorisWindPlantPerformanceModel(WindPerformanceBaseClass): +class FlorisWindPlantPerformanceModel(WindPerformanceBaseClass, CacheBaseClass): """ An OpenMDAO component that wraps a Floris model. It takes wind turbine model parameters and wind resource data as input and @@ -104,7 +102,7 @@ class FlorisWindPlantPerformanceModel(WindPerformanceBaseClass): """ def setup(self): - super().setup() + self.n_timesteps = int(self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]) performance_inputs = self.options["tech_config"]["model_inputs"]["performance_parameters"] @@ -157,7 +155,7 @@ def setup(self): "capacity_factor", val=0.0, units="percent", desc="Wind farm capacity factor" ) - self.n_timesteps = int(self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]) + super().setup() power_curve = self.config.floris_turbine_config.get("power_thrust_table").get("power") self.wind_turbine_rating_kW = np.max(power_curve) @@ -222,26 +220,17 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): # in the resource data. # would need to duplicate the ``calculate_air_density`` function from HOPP - # Check if the results for the current configuration are already cached - if self.config.enable_caching: - config_dict = self.config.as_dict() - config_dict.update({"wind_turbine_size_kw": self.wind_turbine_rating_kW}) - cache_filename = make_cache_hash_filename( - config_dict, inputs, discrete_inputs, cache_dir=self.config.cache_dir - ) + # 1. Check if the results for the current configuration are already cached + config_dict = self.config.as_dict() + config_dict.update({"wind_turbine_size_kw": self.wind_turbine_rating_kW}) + loaded_results = self.load_outputs( + inputs, outputs, discrete_inputs, discrete_outputs={}, config_dict=config_dict + ) + if loaded_results: + # Case has been run before and outputs have been set, can exit this function + return - if Path(cache_filename).exists(): - # Load the cached results - cache_path = Path(cache_filename) - with cache_path.open("rb") as f: - cached_data = dill.load(f) - outputs["electricity_out"] = cached_data["electricity_out"] - outputs["total_capacity"] = cached_data["total_capacity"] - outputs["total_electricity_produced"] = cached_data["total_electricity_produced"] - outputs["capacity_factor"] = cached_data["capacity_factor"] - return - - # If caching is not enabled or a cache file does not exist, run FLORIS + # 2. If caching is not enabled or a cache file does not exist, run FLORIS n_turbs = int(np.round(inputs["num_turbines"][0])) # Copy main config files @@ -285,6 +274,7 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): operational_efficiency = (100 - self.config.operational_losses) / 100 gen = power_farm * operational_efficiency / 1000 # kW + # set outputs outputs["electricity_out"] = gen outputs["total_capacity"] = n_turbs * self.wind_turbine_rating_kW @@ -292,16 +282,7 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): outputs["total_electricity_produced"] = np.sum(gen) outputs["capacity_factor"] = np.sum(gen) / max_production - # Cache the results for future use - if self.config.enable_caching: - cache_path = Path(cache_filename) - with cache_path.open("wb") as f: - floris_results = { - "electricity_out": outputs["electricity_out"], - "total_capacity": outputs["total_capacity"], - "total_electricity_produced": outputs["total_electricity_produced"], - "capacity_factor": outputs["capacity_factor"], - "layout_x": x_pos, - "layout_y": y_pos, - } - dill.dump(floris_results, f) + # 3. Cache the results for future use if enabled + self.cache_outputs( + inputs, outputs, discrete_inputs, discrete_outputs={}, config_dict=config_dict + ) diff --git a/h2integrate/converters/wind/test/test_floris_wind.py b/h2integrate/converters/wind/test/test_floris_wind.py index be452a45d..2a7029b08 100644 --- a/h2integrate/converters/wind/test/test_floris_wind.py +++ b/h2integrate/converters/wind/test/test_floris_wind.py @@ -1,9 +1,11 @@ +import shutil + import numpy as np import pytest import openmdao.api as om from pytest import fixture -from h2integrate import H2I_LIBRARY_DIR +from h2integrate import ROOT_DIR, H2I_LIBRARY_DIR from h2integrate.core.utilities import load_yaml from h2integrate.converters.wind.floris import FlorisWindPlantPerformanceModel from h2integrate.resource.wind.openmeteo_wind import OpenMeteoHistoricalWindResource @@ -29,6 +31,7 @@ def floris_config(): }, "operational_losses": 12.83, "enable_caching": False, + "cache_dir": None, "resource_data_averaging_method": "nearest", } return floris_performance_dict @@ -58,15 +61,9 @@ def plant_config(): def test_floris_wind_performance(plant_config, floris_config, subtests): - cost_dict = { - "capex_per_kW": 1000, # overnight capital cost - "opex_per_kW_per_year": 5, # fixed operations and maintenance expenses - "cost_year": 2022, - } tech_config_dict = { "model_inputs": { "performance_parameters": floris_config, - "cost_parameters": cost_dict, } } @@ -109,3 +106,152 @@ def test_floris_wind_performance(plant_config, floris_config, subtests): assert pytest.approx( np.sum(prob.get_val("wind_plant.electricity_out", units="kW")), rel=1e-6 ) == prob.get_val("wind_plant.total_electricity_produced", units="kW*h/year") + + +def test_floris_caching_changed_config(plant_config, floris_config, subtests): + cache_dir = ROOT_DIR.parent / "test_cache_floris" + + # delete cache dir if it exists + if cache_dir.exists(): + shutil.rmtree(cache_dir) + + floris_config["enable_caching"] = True + floris_config["cache_dir"] = cache_dir + + tech_config_dict = { + "model_inputs": { + "performance_parameters": floris_config, + } + } + + # Run FLORIS and get cache filename + prob = om.Problem() + + wind_resource_config = plant_config["site"]["resource"]["wind_resource"]["resource_parameters"] + wind_resource = OpenMeteoHistoricalWindResource( + plant_config=plant_config, + resource_config=wind_resource_config, + driver_config={}, + ) + + wind_plant = FlorisWindPlantPerformanceModel( + plant_config=plant_config, + tech_config=tech_config_dict, + driver_config={}, + ) + + prob.model.add_subsystem("wind_resource", wind_resource, promotes=["*"]) + prob.model.add_subsystem("wind_plant", wind_plant, promotes=["*"]) + prob.setup() + prob.run_model() + + cache_filename_init = list(cache_dir.glob("*.pkl")) + + with subtests.test("Check that cache file was created"): + assert len(cache_filename_init) == 1 + + # Modify something in the config and check that cache filename is different + floris_config["operational_losses"] = 10.0 + prob = om.Problem() + wind_resource = OpenMeteoHistoricalWindResource( + plant_config=plant_config, + resource_config=wind_resource_config, + driver_config={}, + ) + + wind_plant = FlorisWindPlantPerformanceModel( + plant_config=plant_config, + tech_config=tech_config_dict, + driver_config={}, + ) + + prob.model.add_subsystem("wind_resource", wind_resource, promotes=["*"]) + prob.model.add_subsystem("wind_plant", wind_plant, promotes=["*"]) + prob.setup() + prob.run_model() + + cache_filenames = list(cache_dir.glob("*.pkl")) + cache_filename_new = [file for file in cache_filenames if file not in cache_filename_init] + + with subtests.test("Check unique filename with modified config"): + assert len(cache_filename_new) > 0 + + with subtests.test("Check two cache files exist"): + assert len(cache_filenames) == 2 + + # Delete cache files and the testing cache dir + shutil.rmtree(cache_dir) + + +def test_floris_caching_changed_inputs(plant_config, floris_config, subtests): + cache_dir = ROOT_DIR.parent / "test_cache_floris" + + # delete cache dir if it exists + if cache_dir.exists(): + shutil.rmtree(cache_dir) + + floris_config["enable_caching"] = True + floris_config["cache_dir"] = cache_dir + + tech_config_dict = { + "model_inputs": { + "performance_parameters": floris_config, + } + } + + # Run FLORIS and get cache filename + prob = om.Problem() + + wind_resource_config = plant_config["site"]["resource"]["wind_resource"]["resource_parameters"] + wind_resource = OpenMeteoHistoricalWindResource( + plant_config=plant_config, + resource_config=wind_resource_config, + driver_config={}, + ) + + wind_plant = FlorisWindPlantPerformanceModel( + plant_config=plant_config, + tech_config=tech_config_dict, + driver_config={}, + ) + + prob.model.add_subsystem("wind_resource", wind_resource, promotes=["*"]) + prob.model.add_subsystem("wind_plant", wind_plant, promotes=["*"]) + prob.setup() + prob.run_model() + + wind_resource_data = dict(prob.get_val("wind_resource.wind_resource_data")) + + cache_filename_init = list(cache_dir.glob("*.pkl")) + + with subtests.test("Check that cache file was created"): + assert len(cache_filename_init) == 1 + + # Modify the wind resource data, rerun floris, and check that a new file was created + # wind_resource_data['wind_speed_100m'][10] += 1 #this wont trigger a new cache file + wind_resource_data["site_lat"] = 44.04218107666016 + + prob = om.Problem() + + wind_plant = FlorisWindPlantPerformanceModel( + plant_config=plant_config, + tech_config=tech_config_dict, + driver_config={}, + ) + + prob.model.add_subsystem("wind_plant", wind_plant) + prob.setup() + prob.set_val("wind_plant.wind_resource_data", wind_resource_data) + prob.run_model() + + cache_filenames = list(cache_dir.glob("*.pkl")) + cache_filename_new = [file for file in cache_filenames if file not in cache_filename_init] + + with subtests.test("Check unique filename with modified config"): + assert len(cache_filename_new) > 0 + + with subtests.test("Check two cache files exist"): + assert len(cache_filenames) == 2 + + # Delete cache files and the testing cache dir + shutil.rmtree(cache_dir) From d1aae36602a37b3b9850dc7f3537c9da777a7c4a Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:47:30 -0700 Subject: [PATCH 24/35] added test for preprocessing turbine tools with floris --- h2integrate/converters/wind/floris.py | 2 +- .../test/test_wind_turbine_file_tools.py | 66 ++++++++++++++++++- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 8e7445526..7b1702272 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -152,7 +152,7 @@ def setup(self): self.add_output("total_capacity", val=0.0, units="kW", desc="Wind farm rated capacity") self.add_output( - "capacity_factor", val=0.0, units="percent", desc="Wind farm capacity factor" + "capacity_factor", val=0.0, units="unitless", desc="Wind farm capacity factor" ) super().setup() diff --git a/h2integrate/preprocess/test/test_wind_turbine_file_tools.py b/h2integrate/preprocess/test/test_wind_turbine_file_tools.py index 772010619..aa8c0f1fd 100644 --- a/h2integrate/preprocess/test/test_wind_turbine_file_tools.py +++ b/h2integrate/preprocess/test/test_wind_turbine_file_tools.py @@ -4,6 +4,7 @@ from h2integrate import EXAMPLE_DIR from h2integrate.core.utilities import load_yaml +from h2integrate.converters.wind.floris import FlorisWindPlantPerformanceModel from h2integrate.core.inputs.validation import load_tech_yaml, load_plant_yaml from h2integrate.converters.wind.wind_pysam import PYSAMWindPlantPerformanceModel from h2integrate.preprocess.wind_turbine_file_tools import ( @@ -97,6 +98,65 @@ def test_floris_turbine_export(subtests): assert output_fpath.exists() assert output_fpath.is_file() - # TODO: add test with floris model - # with subtests.test("File runs with FLORIS model"): - # pass + floris_options = load_yaml(output_fpath) + + plant_config_path = EXAMPLE_DIR / "05_wind_h2_opt" / "plant_config.yaml" + tech_config_path = EXAMPLE_DIR / "floris_example" / "tech_config.yaml" + + plant_config = load_plant_yaml(plant_config_path) + tech_config = load_tech_yaml(tech_config_path) + + updated_parameters = { + "hub_height": -1, + "floris_turbine_config": floris_options, + } + + tech_config["technologies"]["wind"]["model_inputs"]["performance_parameters"].update( + updated_parameters + ) + + prob = om.Problem() + wind_resource = WTKNRELDeveloperAPIWindResource( + plant_config=plant_config, + resource_config=plant_config["site"]["resources"]["wind_resource"]["resource_parameters"], + driver_config={}, + ) + + wind_plant = FlorisWindPlantPerformanceModel( + plant_config=plant_config, + tech_config=tech_config["technologies"]["wind"], + driver_config={}, + ) + + prob.model.add_subsystem("wind_resource", wind_resource, promotes=["*"]) + prob.model.add_subsystem("wind_plant", wind_plant, promotes=["*"]) + prob.setup() + prob.run_model() + + with subtests.test("File runs with Floris, check total capacity"): + assert ( + pytest.approx(prob.get_val("wind_plant.total_capacity", units="MW"), rel=1e-6) == 600.0 + ) + + with subtests.test("File runs with Floris, check turbine size"): + assert ( + pytest.approx(prob.get_val("wind_plant.num_turbines", units="unitless"), rel=1e-6) + == 100 + ) + + with subtests.test("File runs with Floris, check hub-height"): + assert pytest.approx(prob.get_val("wind_plant.hub_height", units="m"), rel=1e-6) == 140.0 + + with subtests.test("File runs with Floris, check capacity factor"): + assert ( + pytest.approx(prob.get_val("wind_plant.capacity_factor", units="percent")[0], rel=1e-6) + == 53.556784 + ) + + with subtests.test("File runs with Floris, check AEP"): + assert ( + pytest.approx( + prob.get_val("wind_plant.total_electricity_produced", units="MW*h/yr")[0], rel=1e-6 + ) + == 2814944.574 + ) From 099e7ba388c5ef8e2c455477fb4a9ba166d5b2f2 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:07:19 -0700 Subject: [PATCH 25/35] updated turb preprocessing doc page --- ...turbine_models_library_preprocessing.ipynb | 376 +++++++++++++++++- 1 file changed, 364 insertions(+), 12 deletions(-) diff --git a/docs/misc_resources/turbine_models_library_preprocessing.ipynb b/docs/misc_resources/turbine_models_library_preprocessing.ipynb index 7cf4a5267..f1a97c374 100644 --- a/docs/misc_resources/turbine_models_library_preprocessing.ipynb +++ b/docs/misc_resources/turbine_models_library_preprocessing.ipynb @@ -28,9 +28,31 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 1, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/egrant/opt/anaconda3/envs/h2i_env/lib/python3.11/site-packages/polars/_cpu_check.py:250: RuntimeWarning: Missing required CPU features.\n", + "\n", + "The following required CPU features were not detected:\n", + " avx, avx2, fma, bmi1, bmi2, lzcnt, movbe\n", + "Continuing to use this version of Polars on this processor will likely result in a crash.\n", + "Install `polars[rtcompat]` instead of `polars` to run Polars with better compatibility.\n", + "\n", + "Hint: If you are on an Apple ARM machine (e.g. M1) this is likely due to running Python under Rosetta.\n", + "It is recommended to install a native version of Python that does not run under Rosetta x86-64 emulation.\n", + "\n", + "If you believe this warning to be a false positive, you can set the `POLARS_SKIP_CPU_CHECK` environment variable to bypass this check.\n", + "\n", + " warnings.warn(\n", + "/Users/egrant/opt/anaconda3/envs/h2i_env/lib/python3.11/site-packages/fastkml/__init__.py:28: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.\n", + " from pkg_resources import DistributionNotFound\n" + ] + } + ], "source": [ "import os\n", "import numpy as np\n", @@ -52,7 +74,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -104,14 +126,14 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 3, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "/Users/gstarke/Documents/Research_Programs/H2I/H2Integrate/library/pysam_options_NREL_5MW.yaml\n" + "/Users/egrant/Documents/projects/GreenHEART/library/pysam_options_NREL_5MW.yaml\n" ] } ], @@ -125,7 +147,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 4, "metadata": {}, "outputs": [ { @@ -367,7 +389,7 @@ " 'wind_turbine_hub_ht': 90}}" ] }, - "execution_count": 11, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -380,7 +402,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 5, "metadata": {}, "outputs": [ { @@ -633,7 +655,7 @@ " 'layout_shape': 'square'}}}" ] }, - "execution_count": 12, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -666,7 +688,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 6, "metadata": {}, "outputs": [ { @@ -712,7 +734,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 7, "metadata": {}, "outputs": [ { @@ -774,7 +796,337 @@ "source": [ "## Turbine Model Pre-Processing with FLORIS\n", "\n", - "The function `export_turbine_to_floris_format()` will save turbine model specifications formatted for FLORIS. " + "Example XX (`floris_example`) currently uses an 660 kW turbine. This example uses the \"floris_wind_plant_performance\" performance model for the wind plant. Currently, the performance model is using an 660 kW wind turbine with a rotor diameter of 47.0 meters and a hub-height of 65 meters. In the following sections we will demonstrate how to:\n", + "\n", + "1. Save turbine model specifications for the Vestas 1.65 MW turbine in the FLORIS format using `export_turbine_to_floris_format()`\n", + "2. Load the turbine model specifications for the Vestas 1.65 MW turbine and update performance parameters for the wind technology in the `tech_config` dictionary for the Vestas 1.65 MW turbine.\n", + "3. Run H2I with the updated tech_config dictionary for the Vestas 1.65 MW turbine\n", + "\n", + "\n", + "We'll start off with Step 1 and importing the function `export_turbine_to_floris_format()`, which will save turbine model specifications of the Vestas 1.65 MW turbine formatted for FLORIS. " + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/Users/egrant/Documents/projects/GreenHEART/library/floris_turbine_Vestas_1.65MW.yaml\n" + ] + } + ], + "source": [ + "from h2integrate.preprocess.wind_turbine_file_tools import export_turbine_to_floris_format\n", + "\n", + "turbine_name = \"Vestas_1.65MW\"\n", + "\n", + "turbine_model_fpath = export_turbine_to_floris_format(turbine_name)\n", + "\n", + "print(turbine_model_fpath)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Step 2: Load the turbine model specifications for the Vestas 1.65 MW turbine and update performance parameters for the wind technology in the `tech_config` dictionary for the Vestas 1.65 MW turbine." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'num_turbines': 100,\n", + " 'hub_height': -1,\n", + " 'operational_losses': 12.83,\n", + " 'floris_wake_config': {'name': 'Gauss',\n", + " 'description': 'Onshore template',\n", + " 'floris_version': 'v4.0.0',\n", + " 'logging': {'console': {'enable': False, 'level': 'WARNING'},\n", + " 'file': {'enable': False, 'level': 'WARNING'}},\n", + " 'solver': {'type': 'turbine_grid', 'turbine_grid_points': 1},\n", + " 'flow_field': {'air_density': 1.225,\n", + " 'reference_wind_height': -1,\n", + " 'wind_directions': [],\n", + " 'wind_shear': 0.33,\n", + " 'wind_speeds': [],\n", + " 'wind_veer': 0.0},\n", + " 'wake': {'model_strings': {'combination_model': 'sosfs',\n", + " 'deflection_model': 'gauss',\n", + " 'turbulence_model': 'crespo_hernandez',\n", + " 'velocity_model': 'gauss'},\n", + " 'enable_secondary_steering': False,\n", + " 'enable_yaw_added_recovery': False,\n", + " 'enable_transverse_velocities': False,\n", + " 'wake_deflection_parameters': {'gauss': {'ad': 0.0,\n", + " 'alpha': 0.58,\n", + " 'bd': 0.0,\n", + " 'beta': 0.077,\n", + " 'dm': 1.0,\n", + " 'ka': 0.38,\n", + " 'kb': 0.004},\n", + " 'jimenez': {'ad': 0.0, 'bd': 0.0, 'kd': 0.05}},\n", + " 'wake_velocity_parameters': {'cc': {'a_f': 3.11,\n", + " 'a_s': 0.179367259,\n", + " 'alpha_mod': 1.0,\n", + " 'b_f': -0.68,\n", + " 'b_s': 0.0118889215,\n", + " 'c_f': 2.41,\n", + " 'c_s1': 0.0563691592,\n", + " 'c_s2': 0.13290157},\n", + " 'gauss': {'alpha': 0.58, 'beta': 0.077, 'ka': 0.38, 'kb': 0.004},\n", + " 'jensen': {'we': 0.05}},\n", + " 'wake_turbulence_parameters': {'crespo_hernandez': {'initial': 0.1,\n", + " 'constant': 0.5,\n", + " 'ai': 0.8,\n", + " 'downstream': -0.32}},\n", + " 'enable_active_wake_mixing': False},\n", + " 'farm': {'layout_x': [], 'layout_y': []}},\n", + " 'floris_turbine_config': {'turbine_type': 'Vestas_1.65MW',\n", + " 'hub_height': 70.0,\n", + " 'TSR': 8.0,\n", + " 'rotor_diameter': 82,\n", + " 'power_thrust_table': {'wind_speed': [0.0,\n", + " 1.0,\n", + " 2.0,\n", + " 3.0,\n", + " 4.0,\n", + " 5.0,\n", + " 6.0,\n", + " 7.0,\n", + " 8.0,\n", + " 9.0,\n", + " 10.0,\n", + " 11.0,\n", + " 12.0,\n", + " 13.0,\n", + " 14.0,\n", + " 15.0,\n", + " 16.0,\n", + " 17.0,\n", + " 18.0,\n", + " 19.0,\n", + " 20.0,\n", + " 21.0,\n", + " 22.0,\n", + " 23.0,\n", + " 24.0,\n", + " 25.0,\n", + " 26.0,\n", + " 27.0,\n", + " 28.0,\n", + " 29.0,\n", + " 30.0,\n", + " 31.0,\n", + " 32.0,\n", + " 33.0,\n", + " 34.0,\n", + " 35.0,\n", + " 36.0,\n", + " 37.0,\n", + " 38.0,\n", + " 39.0,\n", + " 40.0,\n", + " 41.0,\n", + " 42.0,\n", + " 43.0,\n", + " 44.0,\n", + " 45.0,\n", + " 46.0,\n", + " 47.0,\n", + " 48.0,\n", + " 49.0],\n", + " 'power': [0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 28.0,\n", + " 144.0,\n", + " 309.0,\n", + " 511.0,\n", + " 758.0,\n", + " 1017.0,\n", + " 1285.0,\n", + " 1504.0,\n", + " 1637.0,\n", + " 1650.0,\n", + " 1650.0,\n", + " 1650.0,\n", + " 1650.0,\n", + " 1650.0,\n", + " 1650.0,\n", + " 1650.0,\n", + " 1650.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0],\n", + " 'thrust_coefficient': [0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.979,\n", + " 1.111,\n", + " 1.014,\n", + " 0.925,\n", + " 0.843,\n", + " 0.768,\n", + " 0.701,\n", + " 0.642,\n", + " 0.578,\n", + " 0.509,\n", + " 0.438,\n", + " 0.379,\n", + " 0.334,\n", + " 0.299,\n", + " 0.272,\n", + " 0.249,\n", + " 0.232,\n", + " 0.218,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0,\n", + " 0.0],\n", + " 'ref_air_density': 1.225,\n", + " 'ref_tilt': 5.0,\n", + " 'cosine_loss_exponent_yaw': 1.88,\n", + " 'cosine_loss_exponent_tilt': 1.88}},\n", + " 'resource_data_averaging_method': 'weighted_average',\n", + " 'floris_operation_model': 'cosine-loss',\n", + " 'default_turbulence_intensity': 0.06,\n", + " 'enable_caching': True,\n", + " 'cache_dir': 'cache',\n", + " 'layout': {'layout_mode': 'basicgrid',\n", + " 'layout_options': {'row_D_spacing': 5.0,\n", + " 'turbine_D_spacing': 5.0,\n", + " 'rotation_angle_deg': 0.0,\n", + " 'row_phase_offset': 0.0,\n", + " 'layout_shape': 'square'}}}" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Load the tech config file\n", + "tech_config_path = EXAMPLE_DIR / \"floris_example\" / \"tech_config.yaml\"\n", + "tech_config = load_tech_yaml(tech_config_path)\n", + "\n", + "# Load the turbine model file formatted for FLORIS\n", + "floris_options = load_yaml(turbine_model_fpath)\n", + "\n", + "# Create dictionary of updated inputs for the new turbine formatted for\n", + "# the \"floris_wind_plant_performance\" model\n", + "updated_parameters = {\n", + " \"hub_height\": -1, # -1 indicates to use the hub-height in the floris_turbine_config\n", + " \"floris_turbine_config\": floris_options,\n", + "}\n", + "\n", + "# Update wind performance parameters in the tech config\n", + "tech_config[\"technologies\"][\"wind\"][\"model_inputs\"][\"performance_parameters\"].update(\n", + " updated_parameters\n", + ")\n", + "\n", + "# The technology input for the updated wind turbine model\n", + "tech_config[\"technologies\"][\"wind\"][\"model_inputs\"][\"performance_parameters\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Step 3: Run H2I with the updated tech_config dictionary for the Vestas 1.65 MW turbine" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Wind LCOE is $120.87/MWh\n" + ] + } + ], + "source": [ + "# Create the top-level config input dictionary for H2I\n", + "h2i_config = {\n", + " \"driver_config\": EXAMPLE_DIR / \"floris_example\" / \"driver_config.yaml\",\n", + " \"technology_config\": tech_config,\n", + " \"plant_config\": EXAMPLE_DIR / \"floris_example\" / \"plant_config.yaml\",\n", + "}\n", + "\n", + "# Create a H2Integrate model with the updated tech config\n", + "h2i = H2IntegrateModel(h2i_config)\n", + "\n", + "# Run the model\n", + "h2i.run()\n", + "\n", + "# Get LCOE of wind plant\n", + "wind_lcoe = h2i.model.get_val(\"finance_subgroup_electricity.LCOE\", units=\"USD/MW/h\")\n", + "print(f\"Wind LCOE is ${wind_lcoe[0]:.2f}/MWh\")" ] } ], From 3bcfb9820b7f32c8f088c8a2512b688d81792de7 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:09:40 -0700 Subject: [PATCH 26/35] fixed failing test --- examples/test/test_all_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/test/test_all_examples.py b/examples/test/test_all_examples.py index ca7894979..368f78784 100644 --- a/examples/test/test_all_examples.py +++ b/examples/test/test_all_examples.py @@ -1306,7 +1306,7 @@ def test_floris_example(subtests): with subtests.test("Capacity factor"): assert ( pytest.approx(h2i.prob.get_val("wind.capacity_factor", units="percent")[0], rel=1e-6) - == 0.177610389263 + == 17.7610389263 ) From 5681f7022f93bf99fae6be8d00f52a3f88759d53 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:33:25 -0700 Subject: [PATCH 27/35] added in air density adjustment and renamed operation model --- examples/floris_example/tech_config.yaml | 2 +- h2integrate/converters/wind/floris.py | 16 ++++++++++++--- .../converters/wind/test/test_floris_wind.py | 2 +- .../converters/wind/tools/resource_tools.py | 20 ------------------- 4 files changed, 15 insertions(+), 25 deletions(-) diff --git a/examples/floris_example/tech_config.yaml b/examples/floris_example/tech_config.yaml index fc6de0de2..833bf636d 100644 --- a/examples/floris_example/tech_config.yaml +++ b/examples/floris_example/tech_config.yaml @@ -15,7 +15,7 @@ technologies: floris_wake_config: !include "floris_v4_default_template.yaml" #floris wake model file floris_turbine_config: !include "floris_turbine_Vestas_660kW.yaml" #turbine model file formatted for floris resource_data_averaging_method: "weighted_average" #"weighted_average", "average" or "nearest" - floris_operation_model: "cosine-loss" #turbine operation model + operation_model: "cosine-loss" #turbine operation model default_turbulence_intensity: 0.06 enable_caching: True #whether to use cached results cache_dir: "cache" #directory to save or load cached data diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index 0cb2ab198..e0a974a81 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -8,6 +8,7 @@ from h2integrate.core.validators import gt_zero, contains, range_val from h2integrate.core.model_baseclasses import CacheBaseClass, CacheBaseConfig from h2integrate.converters.wind.tools.resource_tools import ( + calculate_air_density, average_wind_data_for_hubheight, weighted_average_wind_data_for_hubheight, ) @@ -32,7 +33,7 @@ class FlorisWindPlantPerformanceConfig(CacheBaseConfig): hub_height (float | int, optional): a value of -1 indicates to use the hub-height from the `floris_turbine_config`. Otherwise, is the turbine hub-height in meters. Defaults to -1. - floris_operation_model (str, optional): turbine operation model. Defaults to 'cosine-loss'. + operation_model (str, optional): turbine operation model. Defaults to 'cosine-loss'. default_turbulence_intensity (float): default turbulence intensity to use if not found in wind resource data. layout (dict): layout parameters dictionary. @@ -61,7 +62,8 @@ class FlorisWindPlantPerformanceConfig(CacheBaseConfig): default_turbulence_intensity: float = field() operational_losses: float = field(validator=range_val(0.0, 100.0)) hub_height: float = field(default=-1, validator=validators.ge(-1)) - floris_operation_model: str = field(default="cosine-loss") + adjust_air_density_for_elevation: bool = field(default=False) + operation_model: str = field(default="cosine-loss") layout: dict = field(default={}) resource_data_averaging_method: str = field( default="weighted_average", validator=contains(["weighted_average", "average", "nearest"]) @@ -241,7 +243,7 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): turbine_design.update({"hub_height": inputs["hub_height"][0]}) # update the operation model in the floris turbine config - turbine_design.update({"operation_model": self.config.floris_operation_model}) + turbine_design.update({"operation_model": self.config.operation_model}) # format resource data and input into model time_series = self.format_resource_data( @@ -258,6 +260,14 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): floris_config["farm"].update(floris_farm) + # adjust air density + if ( + self.config.adjust_air_density_for_elevation + and "elevation" in discrete_inputs["wind_resource_data"] + ): + rho = calculate_air_density(discrete_inputs["elevation"]) + floris_config["flow_field"].update({"air_density": rho}) + # initialize FLORIS floris_config["flow_field"].update({"turbulence_intensities": []}) self.fi = FlorisModel(floris_config) diff --git a/h2integrate/converters/wind/test/test_floris_wind.py b/h2integrate/converters/wind/test/test_floris_wind.py index 2a7029b08..ee73803aa 100644 --- a/h2integrate/converters/wind/test/test_floris_wind.py +++ b/h2integrate/converters/wind/test/test_floris_wind.py @@ -20,7 +20,7 @@ def floris_config(): "floris_wake_config": floris_wake_config, "floris_turbine_config": floris_turbine_config, "default_turbulence_intensity": 0.06, - "floris_operation_model": "cosine-loss", + "operation_model": "cosine-loss", "hub_height": -1, "layout": { "layout_mode": "basicgrid", diff --git a/h2integrate/converters/wind/tools/resource_tools.py b/h2integrate/converters/wind/tools/resource_tools.py index 064086538..2557a3838 100644 --- a/h2integrate/converters/wind/tools/resource_tools.py +++ b/h2integrate/converters/wind/tools/resource_tools.py @@ -41,26 +41,6 @@ def calculate_air_density(elevation_m: float) -> float: return rho -def calculate_air_density_losses(elevation_m: float) -> float: - """Calculate loss (%) from air density drop at site elevation. - - Args: - elevation_m (float): site elevation in meters - - Returns: - float: percentage loss associated with air density decrease at elevation. - """ - - if elevation_m <= 0.0: - return 0.0 - - air_density = calculate_air_density(elevation_m) - loss_ratio = 1 - (air_density / RHO_0) - loss_percent = loss_ratio * 100 - - return loss_percent - - def weighted_average_wind_data_for_hubheight( wind_resource_data: dict, bounding_resource_heights: tuple[int] | list[int], From d3fc28bf1ac175340b9c7392d5851dc90ee50552 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:50:15 -0700 Subject: [PATCH 28/35] added test for air density adjustment in floris --- h2integrate/converters/wind/floris.py | 2 +- .../converters/wind/test/test_floris_wind.py | 139 ++++++++++++++++-- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index e0a974a81..ec4b8da00 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -265,7 +265,7 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): self.config.adjust_air_density_for_elevation and "elevation" in discrete_inputs["wind_resource_data"] ): - rho = calculate_air_density(discrete_inputs["elevation"]) + rho = calculate_air_density(discrete_inputs["wind_resource_data"]["elevation"]) floris_config["flow_field"].update({"air_density": rho}) # initialize FLORIS diff --git a/h2integrate/converters/wind/test/test_floris_wind.py b/h2integrate/converters/wind/test/test_floris_wind.py index ee73803aa..4a0e0d0d5 100644 --- a/h2integrate/converters/wind/test/test_floris_wind.py +++ b/h2integrate/converters/wind/test/test_floris_wind.py @@ -9,6 +9,7 @@ from h2integrate.core.utilities import load_yaml from h2integrate.converters.wind.floris import FlorisWindPlantPerformanceModel from h2integrate.resource.wind.openmeteo_wind import OpenMeteoHistoricalWindResource +from h2integrate.resource.wind.nrel_developer_wtk_api import WTKNRELDeveloperAPIWindResource @fixture @@ -38,7 +39,7 @@ def floris_config(): @fixture -def plant_config(): +def plant_config_openmeteo(): site_config = { "latitude": 44.04218, "longitude": -95.19757, @@ -60,7 +61,30 @@ def plant_config(): return d -def test_floris_wind_performance(plant_config, floris_config, subtests): +@fixture +def plant_config_wtk(): + site_config = { + "latitude": 35.2018863, + "longitude": -101.945027, + "resource": { + "wind_resource": { + "resource_model": "wind_toolkit_v2_api", + "resource_parameters": { + "resource_year": 2012, + }, + } + }, + } + plant_dict = { + "plant_life": 30, + "simulation": {"n_timesteps": 8760, "dt": 3600, "start_time": "01/01 00:30:00"}, + } + + d = {"site": site_config, "plant": plant_dict} + return d + + +def test_floris_wind_performance(plant_config_openmeteo, floris_config, subtests): tech_config_dict = { "model_inputs": { "performance_parameters": floris_config, @@ -69,15 +93,17 @@ def test_floris_wind_performance(plant_config, floris_config, subtests): prob = om.Problem() - wind_resource_config = plant_config["site"]["resource"]["wind_resource"]["resource_parameters"] + wind_resource_config = plant_config_openmeteo["site"]["resource"]["wind_resource"][ + "resource_parameters" + ] wind_resource = OpenMeteoHistoricalWindResource( - plant_config=plant_config, + plant_config=plant_config_openmeteo, resource_config=wind_resource_config, driver_config={}, ) wind_plant = FlorisWindPlantPerformanceModel( - plant_config=plant_config, + plant_config=plant_config_openmeteo, tech_config=tech_config_dict, driver_config={}, ) @@ -108,7 +134,7 @@ def test_floris_wind_performance(plant_config, floris_config, subtests): ) == prob.get_val("wind_plant.total_electricity_produced", units="kW*h/year") -def test_floris_caching_changed_config(plant_config, floris_config, subtests): +def test_floris_caching_changed_config(plant_config_openmeteo, floris_config, subtests): cache_dir = ROOT_DIR.parent / "test_cache_floris" # delete cache dir if it exists @@ -127,15 +153,17 @@ def test_floris_caching_changed_config(plant_config, floris_config, subtests): # Run FLORIS and get cache filename prob = om.Problem() - wind_resource_config = plant_config["site"]["resource"]["wind_resource"]["resource_parameters"] + wind_resource_config = plant_config_openmeteo["site"]["resource"]["wind_resource"][ + "resource_parameters" + ] wind_resource = OpenMeteoHistoricalWindResource( - plant_config=plant_config, + plant_config=plant_config_openmeteo, resource_config=wind_resource_config, driver_config={}, ) wind_plant = FlorisWindPlantPerformanceModel( - plant_config=plant_config, + plant_config=plant_config_openmeteo, tech_config=tech_config_dict, driver_config={}, ) @@ -154,13 +182,13 @@ def test_floris_caching_changed_config(plant_config, floris_config, subtests): floris_config["operational_losses"] = 10.0 prob = om.Problem() wind_resource = OpenMeteoHistoricalWindResource( - plant_config=plant_config, + plant_config=plant_config_openmeteo, resource_config=wind_resource_config, driver_config={}, ) wind_plant = FlorisWindPlantPerformanceModel( - plant_config=plant_config, + plant_config=plant_config_openmeteo, tech_config=tech_config_dict, driver_config={}, ) @@ -183,7 +211,7 @@ def test_floris_caching_changed_config(plant_config, floris_config, subtests): shutil.rmtree(cache_dir) -def test_floris_caching_changed_inputs(plant_config, floris_config, subtests): +def test_floris_caching_changed_inputs(plant_config_openmeteo, floris_config, subtests): cache_dir = ROOT_DIR.parent / "test_cache_floris" # delete cache dir if it exists @@ -202,15 +230,17 @@ def test_floris_caching_changed_inputs(plant_config, floris_config, subtests): # Run FLORIS and get cache filename prob = om.Problem() - wind_resource_config = plant_config["site"]["resource"]["wind_resource"]["resource_parameters"] + wind_resource_config = plant_config_openmeteo["site"]["resource"]["wind_resource"][ + "resource_parameters" + ] wind_resource = OpenMeteoHistoricalWindResource( - plant_config=plant_config, + plant_config=plant_config_openmeteo, resource_config=wind_resource_config, driver_config={}, ) wind_plant = FlorisWindPlantPerformanceModel( - plant_config=plant_config, + plant_config=plant_config_openmeteo, tech_config=tech_config_dict, driver_config={}, ) @@ -234,7 +264,7 @@ def test_floris_caching_changed_inputs(plant_config, floris_config, subtests): prob = om.Problem() wind_plant = FlorisWindPlantPerformanceModel( - plant_config=plant_config, + plant_config=plant_config_openmeteo, tech_config=tech_config_dict, driver_config={}, ) @@ -255,3 +285,80 @@ def test_floris_caching_changed_inputs(plant_config, floris_config, subtests): # Delete cache files and the testing cache dir shutil.rmtree(cache_dir) + + +def test_floris_wind_performance_air_dens(plant_config_wtk, floris_config, subtests): + tech_config_dict = { + "model_inputs": { + "performance_parameters": floris_config, + } + } + + prob = om.Problem() + + wind_resource_config = plant_config_wtk["site"]["resource"]["wind_resource"][ + "resource_parameters" + ] + wind_resource = WTKNRELDeveloperAPIWindResource( + plant_config=plant_config_wtk, + resource_config=wind_resource_config, + driver_config={}, + ) + + wind_plant = FlorisWindPlantPerformanceModel( + plant_config=plant_config_wtk, + tech_config=tech_config_dict, + driver_config={}, + ) + + prob.model.add_subsystem("wind_resource", wind_resource, promotes=["*"]) + prob.model.add_subsystem("wind_plant", wind_plant, promotes=["*"]) + prob.setup() + prob.run_model() + + wind_resource_data = dict(prob.get_val("wind_resource.wind_resource_data")) + + initial_aep = prob.get_val("wind_plant.total_electricity_produced", units="kW*h/year")[0] + with subtests.test("wind farm capacity"): + assert ( + pytest.approx(prob.get_val("wind_plant.total_capacity", units="kW")[0], rel=1e-6) + == 660 * 20 + ) + + with subtests.test("AEP"): + assert ( + pytest.approx( + prob.get_val("wind_plant.total_electricity_produced", units="kW*h/year")[0], + rel=1e-6, + ) + == 37007.33639643173 * 1e3 + ) + + with subtests.test("total electricity_out"): + assert pytest.approx( + np.sum(prob.get_val("wind_plant.electricity_out", units="kW")), rel=1e-6 + ) == prob.get_val("wind_plant.total_electricity_produced", units="kW*h/year") + + # Add elevation to the resource data and rerun floris + floris_config["adjust_air_density_for_elevation"] = True + wind_resource_data["elevation"] = 1133.0 + + prob = om.Problem() + + wind_plant = FlorisWindPlantPerformanceModel( + plant_config=plant_config_wtk, + tech_config=tech_config_dict, + driver_config={}, + ) + + prob.model.add_subsystem("wind_plant", wind_plant) + prob.setup() + prob.set_val("wind_plant.wind_resource_data", wind_resource_data) + prob.run_model() + + adjusted_aep = prob.get_val("wind_plant.total_electricity_produced", units="kW*h/year")[0] + with subtests.test("reduced AEP with air density adjustment"): + assert adjusted_aep < initial_aep + + with subtests.test("AEP with air density adjustment"): + assert pytest.approx(adjusted_aep, rel=1e-6) == 34392.58173437373 * 1e3 From 176e345cabc1989887d0b84973dc56ca25bd76b2 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:52:36 -0700 Subject: [PATCH 29/35] removed note about including air density adjustment --- h2integrate/converters/wind/floris.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/h2integrate/converters/wind/floris.py b/h2integrate/converters/wind/floris.py index ec4b8da00..623b68322 100644 --- a/h2integrate/converters/wind/floris.py +++ b/h2integrate/converters/wind/floris.py @@ -218,10 +218,6 @@ def format_resource_data(self, hub_height, wind_resource_data): return time_series def compute(self, inputs, outputs, discrete_inputs, discrete_outputs): - # NOTE: could update air density based on elevation if elevation is included - # in the resource data. - # would need to duplicate the ``calculate_air_density`` function from HOPP - # 1. Check if the results for the current configuration are already cached config_dict = self.config.as_dict() config_dict.update({"wind_turbine_size_kw": self.wind_turbine_rating_kW}) From 47e0fd02b13b58a69e50e01755c803b984d59e67 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 9 Jan 2026 09:53:35 -0700 Subject: [PATCH 30/35] updated wind resource units for is_day and added some tests for wind resource tools --- .../wind/tools/test/test_resource_tools.py | 129 ++++++++++++++++++ .../resource/wind/wind_resource_base.py | 2 +- 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 h2integrate/converters/wind/tools/test/test_resource_tools.py diff --git a/h2integrate/converters/wind/tools/test/test_resource_tools.py b/h2integrate/converters/wind/tools/test/test_resource_tools.py new file mode 100644 index 000000000..5a0682314 --- /dev/null +++ b/h2integrate/converters/wind/tools/test/test_resource_tools.py @@ -0,0 +1,129 @@ +import numpy as np +import pytest +import CoolProp +import openmdao.api as om +from pytest import fixture + +from h2integrate.converters.wind.tools.resource_tools import ( + calculate_air_density, + average_wind_data_for_hubheight, + weighted_average_wind_data_for_hubheight, +) +from h2integrate.resource.wind.nrel_developer_wtk_api import WTKNRELDeveloperAPIWindResource + + +@fixture +def wind_resource_data(): + plant_config = { + "site": { + "latitude": 34.22, + "longitude": -102.75, + "resources": { + "wind_resource": { + "resource_model": "wind_toolkit_v2_api", + "resource_parameters": { + "latitude": 35.2018863, + "longitude": -101.945027, + "resource_year": 2012, # 2013, + }, + } + }, + }, + "plant": { + "plant_life": 30, + "simulation": { + "dt": 3600, + "n_timesteps": 8760, + "start_time": "01/01/1900 00:30:00", + "timezone": 0, + }, + }, + } + + prob = om.Problem() + comp = WTKNRELDeveloperAPIWindResource( + plant_config=plant_config, + resource_config=plant_config["site"]["resources"]["wind_resource"]["resource_parameters"], + driver_config={}, + ) + prob.model.add_subsystem("resource", comp) + prob.setup() + prob.run_model() + wtk_data = prob.get_val("resource.wind_resource_data") + + return wtk_data + + +def test_air_density_calcs(subtests): + z = 0 + T = 288.15 - 0.0065 * z + P = 101325 * (1 - 2.25577e-5 * z) ** 5.25588 + rho0 = CoolProp.CoolProp.PropsSI("D", "T", T, "P", P, "Air") + rho0_calc = calculate_air_density(z) + with subtests.test("air density at sea level"): + assert pytest.approx(rho0_calc, abs=1e-3) == rho0 + + z = 500 + T = 288.15 - 0.0065 * z + P = 101325 * (1 - 2.25577e-5 * z) ** 5.25588 + rho500m = CoolProp.CoolProp.PropsSI("D", "T", T, "P", P, "Air") + rho500m_calc = calculate_air_density(z) + with subtests.test("air density at 1000m"): + assert pytest.approx(rho500m_calc, abs=1e-3) == rho500m + + +def test_resource_averaging(wind_resource_data, subtests): + avg_windspeed = average_wind_data_for_hubheight(wind_resource_data, [100, 140], "wind_speed") + i_lb_less_than_ub = np.argwhere( + wind_resource_data["wind_speed_100m"] < wind_resource_data["wind_speed_140m"] + ).flatten() + with subtests.test("100m wind speed < average wind speed"): + np.testing.assert_array_less( + wind_resource_data["wind_speed_100m"][i_lb_less_than_ub], + avg_windspeed[i_lb_less_than_ub], + ) + + with subtests.test("140m wind speed > average wind speed"): + np.testing.assert_array_less( + avg_windspeed[i_lb_less_than_ub], + wind_resource_data["wind_speed_140m"][i_lb_less_than_ub], + ) + + with subtests.test("Wind speed at t=0 is average"): + mean_ws = np.mean( + [wind_resource_data["wind_speed_100m"][0], wind_resource_data["wind_speed_140m"][0]] + ) + assert pytest.approx(avg_windspeed[0], rel=1e-6) == mean_ws + + with subtests.test("Avg Wind speed at t=0"): + assert pytest.approx(avg_windspeed[0], rel=1e-6) == 15.78 + + +def test_resource_equal_weighted_averaging(wind_resource_data, subtests): + hub_height = 120 + avg_windspeed = weighted_average_wind_data_for_hubheight( + wind_resource_data, [100, 140], hub_height, "wind_speed" + ) + i_lb_less_than_ub = np.argwhere( + wind_resource_data["wind_speed_100m"] < wind_resource_data["wind_speed_140m"] + ).flatten() + with subtests.test("100m wind speed < average wind speed"): + np.testing.assert_array_less( + wind_resource_data["wind_speed_100m"][i_lb_less_than_ub], + avg_windspeed[i_lb_less_than_ub], + ) + + with subtests.test("140m wind speed > average wind speed"): + np.testing.assert_array_less( + avg_windspeed[i_lb_less_than_ub], + wind_resource_data["wind_speed_140m"][i_lb_less_than_ub], + ) + + with subtests.test("Wind speed at t=0 is average"): + mean_ws = np.mean( + [wind_resource_data["wind_speed_100m"][0], wind_resource_data["wind_speed_140m"][0]] + ) + assert pytest.approx(avg_windspeed[0], rel=1e-6) == mean_ws + + with subtests.test("Avg Wind speed at t=0"): + assert pytest.approx(avg_windspeed[0], rel=1e-6) == 15.78 diff --git a/h2integrate/resource/wind/wind_resource_base.py b/h2integrate/resource/wind/wind_resource_base.py index b416a46ce..f45876a97 100644 --- a/h2integrate/resource/wind/wind_resource_base.py +++ b/h2integrate/resource/wind/wind_resource_base.py @@ -14,7 +14,7 @@ def setup(self): "pressure": "atm", "precipitation_rate": "mm/h", "relative_humidity": "percent", - "is_day": "percent", + "is_day": "unitless", } def compare_units_and_correct(self, data, data_units): From 84d1302459e3bcf1ac4bcbc1b38705957979674f Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:22:55 -0700 Subject: [PATCH 31/35] bugfix and added test for weighted average wind speed --- .../converters/wind/tools/resource_tools.py | 6 ++-- .../wind/tools/test/test_resource_tools.py | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/h2integrate/converters/wind/tools/resource_tools.py b/h2integrate/converters/wind/tools/resource_tools.py index 2557a3838..2272812c9 100644 --- a/h2integrate/converters/wind/tools/resource_tools.py +++ b/h2integrate/converters/wind/tools/resource_tools.py @@ -75,8 +75,10 @@ def weighted_average_wind_data_for_hubheight( ) raise ValueError(msg) - weight1 = np.abs(height_lower - hub_height) - weight2 = np.abs(height_upper - hub_height) + # weight1 is the weight applied to the lower-bound height + weight1 = np.abs(height_upper - hub_height) + # weight2 is the weight applied to the upper-bound height + weight2 = np.abs(height_lower - hub_height) weighted_wind_resource = ( (weight1 * wind_resource_data[f"{wind_resource_spec}_{height_lower}m"]) diff --git a/h2integrate/converters/wind/tools/test/test_resource_tools.py b/h2integrate/converters/wind/tools/test/test_resource_tools.py index 5a0682314..50a7c2dd0 100644 --- a/h2integrate/converters/wind/tools/test/test_resource_tools.py +++ b/h2integrate/converters/wind/tools/test/test_resource_tools.py @@ -127,3 +127,31 @@ def test_resource_equal_weighted_averaging(wind_resource_data, subtests): with subtests.test("Avg Wind speed at t=0"): assert pytest.approx(avg_windspeed[0], rel=1e-6) == 15.78 + + +def test_resource_unequal_weighted_averaging(wind_resource_data, subtests): + hub_height = 135 + weighted_avg_windspeed = weighted_average_wind_data_for_hubheight( + wind_resource_data, [100, 140], hub_height, "wind_speed" + ) + + lb_to_avg_diff = np.abs(weighted_avg_windspeed - wind_resource_data["wind_speed_100m"]) + + avg_to_ub_diff = np.abs(weighted_avg_windspeed - wind_resource_data["wind_speed_140m"]) + + i_nonzero_diff = np.argwhere( + (wind_resource_data["wind_speed_100m"] - wind_resource_data["wind_speed_140m"]) != 0 + ).flatten() + + # the weighted_avg_windspeed should be closer to the 140m height than the 100m + with subtests.test("Weighted avg wind speed is closer to 140m wind speed than 100m wind speed"): + np.testing.assert_array_less(avg_to_ub_diff[i_nonzero_diff], lb_to_avg_diff[i_nonzero_diff]) + + with subtests.test("Weighted avg wind speed at t=0 is greater than equal average"): + mean_ws = np.mean( + [wind_resource_data["wind_speed_100m"][0], wind_resource_data["wind_speed_140m"][0]] + ) + assert weighted_avg_windspeed[0] > mean_ws + + with subtests.test("Avg Wind speed at t=0"): + assert pytest.approx(weighted_avg_windspeed[0], rel=1e-6) == 16.56 From 50e2e4f1d43f89d0b70e1fcfc18c718c1d0f0406 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:19:23 -0700 Subject: [PATCH 32/35] draft stage of estimating wind speed using shear --- .../converters/wind/tools/resource_tools.py | 159 ++++++++++++++++++ .../wind/tools/test/test_resource_tools.py | 19 +++ 2 files changed, 178 insertions(+) diff --git a/h2integrate/converters/wind/tools/resource_tools.py b/h2integrate/converters/wind/tools/resource_tools.py index 2272812c9..45921681d 100644 --- a/h2integrate/converters/wind/tools/resource_tools.py +++ b/h2integrate/converters/wind/tools/resource_tools.py @@ -1,4 +1,5 @@ import numpy as np +import scipy from scipy.constants import R, g, convert_temperature @@ -129,3 +130,161 @@ def average_wind_data_for_hubheight( averaged_data = combined_data.mean(axis=0) return averaged_data + + +# def height_to_winspeed_func(height, a, b, c): +# ws = a*np.log(b*height - c) +# return ws + + +def height_to_winspeed_func(ur_zr_z, psi): + ur, zr, z = ur_zr_z + u = ur * (z / zr) ** psi + return u + + +def estimate_wind_speed_with_curve_fit( + wind_resource_data: dict, + bounding_resource_heights: tuple[int] | list[int], + hub_height: float | int, + run_per_timestep: bool = False, +): + """Estimate the wind resource data at the hub-height using a curve-fit. + + Args: + wind_resource_data (dict): dictionary of wind resource data + hub_height (float | int): wind turbine hub-height in meters. + + Returns: + np.ndarray: wind resource data estimated at the hub-height + """ + ws_dict = {k: v for k, v in wind_resource_data.items() if "wind_speed" in k} + # ws_heights = np.array([int(ws_h.split("wind_speed_")[-1].strip("m")) for ws_h + # in list(ws_dict.keys())]) + # ws_speeds = np.array([ws_dict[f"wind_speed_{int(height)}m"] for + # height in ws_heights]) + # n_timesteps = len(ws_dict[f"wind_speed_{int(ws_heights[0])}m"]) + + ws_heights = np.array(bounding_resource_heights) + np.array([ws_dict[f"wind_speed_{int(height)}m"] for height in ws_heights]) + n_timesteps = len(ws_dict[f"wind_speed_{int(ws_heights[0])}m"]) + + # calc closest height + ub_diff = np.abs(np.max(ws_heights) - hub_height) + lb_diff = np.abs(np.min(ws_heights) - hub_height) + + if ub_diff >= lb_diff: + # lower-bound is closer, use lower bound as reference and upper bound as input + z_ref = np.min(ws_heights) * np.ones(n_timesteps) + ws_ref = ws_dict[f"wind_speed_{int(np.min(ws_heights))}m"] + z = np.max(ws_heights) * np.ones(n_timesteps) + ws = ws_dict[f"wind_speed_{int(np.max(ws_heights))}m"] + + else: + # upper bound is closer, use upper bound as reference and lower bound as input + z_ref = np.max(ws_heights) * np.ones(n_timesteps) + ws_ref = ws_dict[f"wind_speed_{int(np.max(ws_heights))}m"] + z = np.min(ws_heights) * np.ones(n_timesteps) + ws = ws_dict[f"wind_speed_{int(np.min(ws_heights))}m"] + + if not run_per_timestep: + curve_coeff, curve_cov = scipy.optimize.curve_fit( + height_to_winspeed_func, + (ws_ref, z_ref, z), + ws, + p0=(1.0), + # bounds = [np.floor(np. + # min(ws_speeds[:,i])),np.ceil(np.max(ws_speeds[:,i]))] + ) + ws_at_hubheight = height_to_winspeed_func( + (ws_ref, z_ref, hub_height * np.ones(n_timesteps)), *curve_coeff + ) + return ws_at_hubheight + + ws_at_hubheight = np.zeros(n_timesteps) + for i in range(n_timesteps): + curve_coeff, curve_cov = scipy.optimize.curve_fit( + height_to_winspeed_func, + (np.array(ws_ref[i]), np.array(z_ref[i]), np.array(z[i])), + np.array(ws[i]), + p0=(1.0), + ) + ws_at_hubheight[i] = height_to_winspeed_func( + (ws_ref[i], z_ref[i], hub_height), *curve_coeff + ) + + # ws_at_hubheight = np.zeros(n_timesteps) + # for i in range(n_timesteps): + # curve_coeff, curve_cov = scipy.optimize.curve_fit( + # height_to_winspeed_func, + # (ws_heights, + # ws_speeds[:,i], + # p0=(1.0, 1.0, 1.0), + # # bounds = [np.floor(np.min(ws_speeds[:,i])),np.ceil(np.max(ws_speeds[:,i]))] + # ) + + # ws_at_hubheight = height_to_winspeed_func((ws_ref,z_ref,hub_height*np.ones(n_timesteps)), + # *curve_coeff) + + return ws_at_hubheight + + +# if __name__ == "__main__": +# from h2integrate import ROOT_DIR +# import openmdao.api as om +# import matplotlib.pyplot as plt +# from h2integrate.resource.wind.nrel_developer_wtk_api import WTKNRELDeveloperAPIWindResource + +# plant_config = { +# "site": { +# "latitude": 34.22, +# "longitude": -102.75, +# "resources": { +# "wind_resource": { +# "resource_model": "wind_toolkit_v2_api", +# "resource_parameters": { +# "latitude": 35.2018863, +# "longitude": -101.945027, +# "resource_year": 2012, # 2013, +# }, +# } +# }, +# }, +# "plant": { +# "plant_life": 30, +# "simulation": { +# "dt": 3600, +# "n_timesteps": 8760, +# "start_time": "01/01/1900 00:30:00", +# "timezone": 0, +# }, +# }, +# } + +# prob = om.Problem() +# comp = WTKNRELDeveloperAPIWindResource( +# plant_config=plant_config, +# resource_config=plant_config["site"]["resources"]["wind_resource"]["resource_parameters"], +# driver_config={}, +# ) +# prob.model.add_subsystem("resource", comp) +# prob.setup() +# prob.run_model() +# wtk_data = prob.get_val("resource.wind_resource_data") + +# ws_est = wind_speed_adjustment_for_hubheight(wtk_data,120) + +# # fig, ax = plt.subplots(1,1) + +# # ws_vals0 = ws_df.iloc[0].values +# # ws_vals_est0 = ws_est_df.iloc[0].values + +# # ax.scatter(ws_heights, ws_vals0, c='tab:blue', label='measured') +# # ax.plot(ws_heights, ws_vals_est0, c='tab:red', label='estimated') + + +# # ax.set_ylabel("wind_speed") +# # ax.set_xlabel("height") + + +# fig.savefig(ROOT_DIR.parent/"ws_vs_height.png",bbox_inches="tight") diff --git a/h2integrate/converters/wind/tools/test/test_resource_tools.py b/h2integrate/converters/wind/tools/test/test_resource_tools.py index 50a7c2dd0..6167cf198 100644 --- a/h2integrate/converters/wind/tools/test/test_resource_tools.py +++ b/h2integrate/converters/wind/tools/test/test_resource_tools.py @@ -7,6 +7,7 @@ from h2integrate.converters.wind.tools.resource_tools import ( calculate_air_density, average_wind_data_for_hubheight, + estimate_wind_speed_with_curve_fit, weighted_average_wind_data_for_hubheight, ) from h2integrate.resource.wind.nrel_developer_wtk_api import WTKNRELDeveloperAPIWindResource @@ -155,3 +156,21 @@ def test_resource_unequal_weighted_averaging(wind_resource_data, subtests): with subtests.test("Avg Wind speed at t=0"): assert pytest.approx(weighted_avg_windspeed[0], rel=1e-6) == 16.56 + + +def test_wind_speed_curve_fit_estimate(wind_resource_data, subtests): + hub_height = 120 + + wind_speed_actual = wind_resource_data[f"wind_speed_{hub_height}m"] + + with subtests.test("Estimated wind speed is close to actual, single curve fit"): + wind_speed_est = estimate_wind_speed_with_curve_fit( + wind_resource_data, [100, 120], hub_height, run_per_timestep=False + ) + np.testing.assert_array_almost_equal(wind_speed_est, wind_speed_actual) + + with subtests.test("Estimated wind speed is close to actual, multiple curve fit"): + wind_speed_est = estimate_wind_speed_with_curve_fit( + wind_resource_data, [100, 120], hub_height, run_per_timestep=True + ) + np.testing.assert_array_almost_equal(wind_speed_est, wind_speed_actual) From 17d6ac6a112d89333f88a909fea7dc24960bcdb8 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:46:48 -0700 Subject: [PATCH 33/35] removed wind shear estimations --- .../converters/wind/tools/resource_tools.py | 159 ------------------ .../wind/tools/test/test_resource_tools.py | 19 --- 2 files changed, 178 deletions(-) diff --git a/h2integrate/converters/wind/tools/resource_tools.py b/h2integrate/converters/wind/tools/resource_tools.py index 45921681d..2272812c9 100644 --- a/h2integrate/converters/wind/tools/resource_tools.py +++ b/h2integrate/converters/wind/tools/resource_tools.py @@ -1,5 +1,4 @@ import numpy as np -import scipy from scipy.constants import R, g, convert_temperature @@ -130,161 +129,3 @@ def average_wind_data_for_hubheight( averaged_data = combined_data.mean(axis=0) return averaged_data - - -# def height_to_winspeed_func(height, a, b, c): -# ws = a*np.log(b*height - c) -# return ws - - -def height_to_winspeed_func(ur_zr_z, psi): - ur, zr, z = ur_zr_z - u = ur * (z / zr) ** psi - return u - - -def estimate_wind_speed_with_curve_fit( - wind_resource_data: dict, - bounding_resource_heights: tuple[int] | list[int], - hub_height: float | int, - run_per_timestep: bool = False, -): - """Estimate the wind resource data at the hub-height using a curve-fit. - - Args: - wind_resource_data (dict): dictionary of wind resource data - hub_height (float | int): wind turbine hub-height in meters. - - Returns: - np.ndarray: wind resource data estimated at the hub-height - """ - ws_dict = {k: v for k, v in wind_resource_data.items() if "wind_speed" in k} - # ws_heights = np.array([int(ws_h.split("wind_speed_")[-1].strip("m")) for ws_h - # in list(ws_dict.keys())]) - # ws_speeds = np.array([ws_dict[f"wind_speed_{int(height)}m"] for - # height in ws_heights]) - # n_timesteps = len(ws_dict[f"wind_speed_{int(ws_heights[0])}m"]) - - ws_heights = np.array(bounding_resource_heights) - np.array([ws_dict[f"wind_speed_{int(height)}m"] for height in ws_heights]) - n_timesteps = len(ws_dict[f"wind_speed_{int(ws_heights[0])}m"]) - - # calc closest height - ub_diff = np.abs(np.max(ws_heights) - hub_height) - lb_diff = np.abs(np.min(ws_heights) - hub_height) - - if ub_diff >= lb_diff: - # lower-bound is closer, use lower bound as reference and upper bound as input - z_ref = np.min(ws_heights) * np.ones(n_timesteps) - ws_ref = ws_dict[f"wind_speed_{int(np.min(ws_heights))}m"] - z = np.max(ws_heights) * np.ones(n_timesteps) - ws = ws_dict[f"wind_speed_{int(np.max(ws_heights))}m"] - - else: - # upper bound is closer, use upper bound as reference and lower bound as input - z_ref = np.max(ws_heights) * np.ones(n_timesteps) - ws_ref = ws_dict[f"wind_speed_{int(np.max(ws_heights))}m"] - z = np.min(ws_heights) * np.ones(n_timesteps) - ws = ws_dict[f"wind_speed_{int(np.min(ws_heights))}m"] - - if not run_per_timestep: - curve_coeff, curve_cov = scipy.optimize.curve_fit( - height_to_winspeed_func, - (ws_ref, z_ref, z), - ws, - p0=(1.0), - # bounds = [np.floor(np. - # min(ws_speeds[:,i])),np.ceil(np.max(ws_speeds[:,i]))] - ) - ws_at_hubheight = height_to_winspeed_func( - (ws_ref, z_ref, hub_height * np.ones(n_timesteps)), *curve_coeff - ) - return ws_at_hubheight - - ws_at_hubheight = np.zeros(n_timesteps) - for i in range(n_timesteps): - curve_coeff, curve_cov = scipy.optimize.curve_fit( - height_to_winspeed_func, - (np.array(ws_ref[i]), np.array(z_ref[i]), np.array(z[i])), - np.array(ws[i]), - p0=(1.0), - ) - ws_at_hubheight[i] = height_to_winspeed_func( - (ws_ref[i], z_ref[i], hub_height), *curve_coeff - ) - - # ws_at_hubheight = np.zeros(n_timesteps) - # for i in range(n_timesteps): - # curve_coeff, curve_cov = scipy.optimize.curve_fit( - # height_to_winspeed_func, - # (ws_heights, - # ws_speeds[:,i], - # p0=(1.0, 1.0, 1.0), - # # bounds = [np.floor(np.min(ws_speeds[:,i])),np.ceil(np.max(ws_speeds[:,i]))] - # ) - - # ws_at_hubheight = height_to_winspeed_func((ws_ref,z_ref,hub_height*np.ones(n_timesteps)), - # *curve_coeff) - - return ws_at_hubheight - - -# if __name__ == "__main__": -# from h2integrate import ROOT_DIR -# import openmdao.api as om -# import matplotlib.pyplot as plt -# from h2integrate.resource.wind.nrel_developer_wtk_api import WTKNRELDeveloperAPIWindResource - -# plant_config = { -# "site": { -# "latitude": 34.22, -# "longitude": -102.75, -# "resources": { -# "wind_resource": { -# "resource_model": "wind_toolkit_v2_api", -# "resource_parameters": { -# "latitude": 35.2018863, -# "longitude": -101.945027, -# "resource_year": 2012, # 2013, -# }, -# } -# }, -# }, -# "plant": { -# "plant_life": 30, -# "simulation": { -# "dt": 3600, -# "n_timesteps": 8760, -# "start_time": "01/01/1900 00:30:00", -# "timezone": 0, -# }, -# }, -# } - -# prob = om.Problem() -# comp = WTKNRELDeveloperAPIWindResource( -# plant_config=plant_config, -# resource_config=plant_config["site"]["resources"]["wind_resource"]["resource_parameters"], -# driver_config={}, -# ) -# prob.model.add_subsystem("resource", comp) -# prob.setup() -# prob.run_model() -# wtk_data = prob.get_val("resource.wind_resource_data") - -# ws_est = wind_speed_adjustment_for_hubheight(wtk_data,120) - -# # fig, ax = plt.subplots(1,1) - -# # ws_vals0 = ws_df.iloc[0].values -# # ws_vals_est0 = ws_est_df.iloc[0].values - -# # ax.scatter(ws_heights, ws_vals0, c='tab:blue', label='measured') -# # ax.plot(ws_heights, ws_vals_est0, c='tab:red', label='estimated') - - -# # ax.set_ylabel("wind_speed") -# # ax.set_xlabel("height") - - -# fig.savefig(ROOT_DIR.parent/"ws_vs_height.png",bbox_inches="tight") diff --git a/h2integrate/converters/wind/tools/test/test_resource_tools.py b/h2integrate/converters/wind/tools/test/test_resource_tools.py index 6167cf198..50a7c2dd0 100644 --- a/h2integrate/converters/wind/tools/test/test_resource_tools.py +++ b/h2integrate/converters/wind/tools/test/test_resource_tools.py @@ -7,7 +7,6 @@ from h2integrate.converters.wind.tools.resource_tools import ( calculate_air_density, average_wind_data_for_hubheight, - estimate_wind_speed_with_curve_fit, weighted_average_wind_data_for_hubheight, ) from h2integrate.resource.wind.nrel_developer_wtk_api import WTKNRELDeveloperAPIWindResource @@ -156,21 +155,3 @@ def test_resource_unequal_weighted_averaging(wind_resource_data, subtests): with subtests.test("Avg Wind speed at t=0"): assert pytest.approx(weighted_avg_windspeed[0], rel=1e-6) == 16.56 - - -def test_wind_speed_curve_fit_estimate(wind_resource_data, subtests): - hub_height = 120 - - wind_speed_actual = wind_resource_data[f"wind_speed_{hub_height}m"] - - with subtests.test("Estimated wind speed is close to actual, single curve fit"): - wind_speed_est = estimate_wind_speed_with_curve_fit( - wind_resource_data, [100, 120], hub_height, run_per_timestep=False - ) - np.testing.assert_array_almost_equal(wind_speed_est, wind_speed_actual) - - with subtests.test("Estimated wind speed is close to actual, multiple curve fit"): - wind_speed_est = estimate_wind_speed_with_curve_fit( - wind_resource_data, [100, 120], hub_height, run_per_timestep=True - ) - np.testing.assert_array_almost_equal(wind_speed_est, wind_speed_actual) From ea5c3f419cf67d0b0d2fd479e1cb9f915ea1a17e Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:10:04 -0700 Subject: [PATCH 34/35] updated test values for floris example test due to fig in weighted avg resource data method --- examples/test/test_all_examples.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/test/test_all_examples.py b/examples/test/test_all_examples.py index 368f78784..5e7f6e436 100644 --- a/examples/test/test_all_examples.py +++ b/examples/test/test_all_examples.py @@ -1288,7 +1288,7 @@ def test_floris_example(subtests): pytest.approx( h2i.prob.get_val("finance_subgroup_electricity.LCOE", units="USD/MW/h")[0], rel=1e-6 ) - == 125.4133009 + == 99.872209 ) with subtests.test("Wind plant capacity"): @@ -1300,13 +1300,13 @@ def test_floris_example(subtests): np.sum(h2i.prob.get_val("wind.total_electricity_produced", units="MW*h/yr")), rel=1e-6, ) - == 102687.22266 + == 128948.21977 ) with subtests.test("Capacity factor"): assert ( pytest.approx(h2i.prob.get_val("wind.capacity_factor", units="percent")[0], rel=1e-6) - == 17.7610389263 + == 22.30320668 ) From f4f519d50507a3e69405387e26e8578fa9921ce2 Mon Sep 17 00:00:00 2001 From: elenya-grant <116225007+elenya-grant@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:10:46 -0700 Subject: [PATCH 35/35] renamed floris example and updated relevant tests --- .../turbine_models_library_preprocessing.ipynb | 8 ++++---- examples/{floris_example => 26_floris}/driver_config.yaml | 0 examples/{floris_example => 26_floris}/plant_config.yaml | 0 .../{floris_example => 26_floris}/run_floris_example.py | 0 examples/{floris_example => 26_floris}/tech_config.yaml | 0 examples/test/test_all_examples.py | 8 ++++---- .../preprocess/test/test_wind_turbine_file_tools.py | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) rename examples/{floris_example => 26_floris}/driver_config.yaml (100%) rename examples/{floris_example => 26_floris}/plant_config.yaml (100%) rename examples/{floris_example => 26_floris}/run_floris_example.py (100%) rename examples/{floris_example => 26_floris}/tech_config.yaml (100%) diff --git a/docs/misc_resources/turbine_models_library_preprocessing.ipynb b/docs/misc_resources/turbine_models_library_preprocessing.ipynb index f1a97c374..b8819ee7f 100644 --- a/docs/misc_resources/turbine_models_library_preprocessing.ipynb +++ b/docs/misc_resources/turbine_models_library_preprocessing.ipynb @@ -796,7 +796,7 @@ "source": [ "## Turbine Model Pre-Processing with FLORIS\n", "\n", - "Example XX (`floris_example`) currently uses an 660 kW turbine. This example uses the \"floris_wind_plant_performance\" performance model for the wind plant. Currently, the performance model is using an 660 kW wind turbine with a rotor diameter of 47.0 meters and a hub-height of 65 meters. In the following sections we will demonstrate how to:\n", + "Example 26 (`26_floris`) currently uses an 660 kW turbine. This example uses the \"floris_wind_plant_performance\" performance model for the wind plant. Currently, the performance model is using an 660 kW wind turbine with a rotor diameter of 47.0 meters and a hub-height of 65 meters. In the following sections we will demonstrate how to:\n", "\n", "1. Save turbine model specifications for the Vestas 1.65 MW turbine in the FLORIS format using `export_turbine_to_floris_format()`\n", "2. Load the turbine model specifications for the Vestas 1.65 MW turbine and update performance parameters for the wind technology in the `tech_config` dictionary for the Vestas 1.65 MW turbine.\n", @@ -1068,7 +1068,7 @@ ], "source": [ "# Load the tech config file\n", - "tech_config_path = EXAMPLE_DIR / \"floris_example\" / \"tech_config.yaml\"\n", + "tech_config_path = EXAMPLE_DIR / \"26_floris\" / \"tech_config.yaml\"\n", "tech_config = load_tech_yaml(tech_config_path)\n", "\n", "# Load the turbine model file formatted for FLORIS\n", @@ -1113,9 +1113,9 @@ "source": [ "# Create the top-level config input dictionary for H2I\n", "h2i_config = {\n", - " \"driver_config\": EXAMPLE_DIR / \"floris_example\" / \"driver_config.yaml\",\n", + " \"driver_config\": EXAMPLE_DIR / \"26_floris\" / \"driver_config.yaml\",\n", " \"technology_config\": tech_config,\n", - " \"plant_config\": EXAMPLE_DIR / \"floris_example\" / \"plant_config.yaml\",\n", + " \"plant_config\": EXAMPLE_DIR / \"26_floris\" / \"plant_config.yaml\",\n", "}\n", "\n", "# Create a H2Integrate model with the updated tech config\n", diff --git a/examples/floris_example/driver_config.yaml b/examples/26_floris/driver_config.yaml similarity index 100% rename from examples/floris_example/driver_config.yaml rename to examples/26_floris/driver_config.yaml diff --git a/examples/floris_example/plant_config.yaml b/examples/26_floris/plant_config.yaml similarity index 100% rename from examples/floris_example/plant_config.yaml rename to examples/26_floris/plant_config.yaml diff --git a/examples/floris_example/run_floris_example.py b/examples/26_floris/run_floris_example.py similarity index 100% rename from examples/floris_example/run_floris_example.py rename to examples/26_floris/run_floris_example.py diff --git a/examples/floris_example/tech_config.yaml b/examples/26_floris/tech_config.yaml similarity index 100% rename from examples/floris_example/tech_config.yaml rename to examples/26_floris/tech_config.yaml diff --git a/examples/test/test_all_examples.py b/examples/test/test_all_examples.py index 5e7f6e436..18e51e5c4 100644 --- a/examples/test/test_all_examples.py +++ b/examples/test/test_all_examples.py @@ -1263,11 +1263,11 @@ def test_sweeping_solar_sites_doe(subtests): def test_floris_example(subtests): from h2integrate.core.utilities import load_yaml - os.chdir(EXAMPLE_DIR / "floris_example") + os.chdir(EXAMPLE_DIR / "26_floris") - driver_config = load_yaml(EXAMPLE_DIR / "floris_example" / "driver_config.yaml") - tech_config = load_yaml(EXAMPLE_DIR / "floris_example" / "tech_config.yaml") - plant_config = load_yaml(EXAMPLE_DIR / "floris_example" / "plant_config.yaml") + driver_config = load_yaml(EXAMPLE_DIR / "26_floris" / "driver_config.yaml") + tech_config = load_yaml(EXAMPLE_DIR / "26_floris" / "tech_config.yaml") + plant_config = load_yaml(EXAMPLE_DIR / "26_floris" / "plant_config.yaml") h2i_config = { "name": "H2Integrate_config", diff --git a/h2integrate/preprocess/test/test_wind_turbine_file_tools.py b/h2integrate/preprocess/test/test_wind_turbine_file_tools.py index aa8c0f1fd..21735265a 100644 --- a/h2integrate/preprocess/test/test_wind_turbine_file_tools.py +++ b/h2integrate/preprocess/test/test_wind_turbine_file_tools.py @@ -101,7 +101,7 @@ def test_floris_turbine_export(subtests): floris_options = load_yaml(output_fpath) plant_config_path = EXAMPLE_DIR / "05_wind_h2_opt" / "plant_config.yaml" - tech_config_path = EXAMPLE_DIR / "floris_example" / "tech_config.yaml" + tech_config_path = EXAMPLE_DIR / "26_floris" / "tech_config.yaml" plant_config = load_plant_yaml(plant_config_path) tech_config = load_tech_yaml(tech_config_path)