diff --git a/ard/__init__.py b/ard/__init__.py index c2cc6a17..3491c9df 100644 --- a/ard/__init__.py +++ b/ard/__init__.py @@ -1,13 +1,4 @@ from pathlib import Path -from . import collection -from . import layout -from . import offshore -from . import wind_query -from . import utils -from . import viz - -from .viz import house_style - BASE_DIR = Path(__file__).parent.absolute() ASSET_DIR = BASE_DIR / "api" / "default_systems" diff --git a/ard/api/interface.py b/ard/api/interface.py index 22ac5792..2fea83e1 100644 --- a/ard/api/interface.py +++ b/ard/api/interface.py @@ -5,11 +5,6 @@ from openmdao.utils.file_utils import clean_outputs from ard.utils.io import load_yaml, replace_key_value from ard.utils.logging import prepend_tabs_to_stdio -from ard.cost.wisdem_wrap import ( - LandBOSSE_setup_latents, - ORBIT_setup_latents, - FinanceSE_setup_latents, -) import windIO from ard import ASSET_DIR from typing import Union @@ -163,12 +158,11 @@ def set_up_system_recursive( prob = None # Add subsystems directly from the input dictionary + indent = "\t" * _depth if hasattr(parent_group, "name") and (parent_group.name != ""): - print( - f"{''.join(' ' * 4 * _depth)}Adding {system_name} to {parent_group.name}." - ) + print(f"{indent}Adding {system_name} to {parent_group.name}.") else: - print(f"{''.join(' ' * 4 * _depth)}Adding {system_name}.") + print(f"{indent}Adding {system_name}.") if "systems" in input_dict: # Recursively add nested subsystems] if _depth > 0: group = parent_group.add_subsystem( diff --git a/ard/collection/optiwindnet_wrap.py b/ard/collection/optiwindnet_wrap.py index 726c76a6..e6c70654 100644 --- a/ard/collection/optiwindnet_wrap.py +++ b/ard/collection/optiwindnet_wrap.py @@ -4,9 +4,12 @@ import numpy as np from optiwindnet.mesh import make_planar_embedding -from optiwindnet.interarraylib import L_from_site +from optiwindnet.converting import L_from_site from optiwindnet.heuristics import constructor -from optiwindnet.MILP import OWNWarmupFailed, solver_factory, ModelOptions +from optiwindnet.types import OWNWarmupFailed +from optiwindnet.constraints import NetworkConstraints +from optiwindnet.MILP import solver_factory +from optiwindnet.MILP._core import _constraints_from_options from . import templates @@ -153,15 +156,41 @@ def initialize(self): def setup(self): """Setup of OM component.""" super().setup() + # network constraints are given either as optiwindnet `NetworkConstraints` + # arguments (`network_constraints`) or as the legacy `model_options` + collection_options = self.modeling_options["collection"] + if "network_constraints" in collection_options: + if "model_options" in collection_options: + raise ValueError( + "give either network_constraints or model_options, not both" + ) + constraints = NetworkConstraints( + **collection_options["network_constraints"] + ) + else: + # stopgap until optiwindnet v0.4, which drops the legacy options + constraints = _constraints_from_options( + **collection_options.get("model_options", {}) + ) + self.constraints = constraints + + # the heuristic warm-start only applies if the feeder count is free self.constructor_args = {} - model_options = self.modeling_options["collection"]["model_options"] - if model_options.get("feeder_limit") == "unlimited": + if all( + value is None + for value in ( + constraints.feeders, + constraints.max_feeders, + constraints.substation_feeders, + constraints.max_substation_feeders, + ) + ): self.constructor_args["straight_feeder_route"] = ( - model_options.get("feeder_route") == "straight" + constraints.feeder_route == "straight" ) - if model_options.get("topology") == "branched": + if constraints.topology == "branched": self.constructor_args["method"] = "rootlust" - elif model_options.get("topology") == "radial": + elif constraints.topology == "radial": self.constructor_args["method"] = "radial_EW" else: self.constructor_args.clear() @@ -200,7 +229,6 @@ def compute( solver = solver_factory(solver_name) - model_options = self.modeling_options["collection"]["model_options"] # start from previous solution if available, else from heuristic if it fits if self.S_previous is not None: S_warm = self.S_previous @@ -217,8 +245,8 @@ def compute( solver.set_problem( P, A, - max_turbines_per_string, - ModelOptions(**model_options), + capacity=max_turbines_per_string, + constraints=self.constraints, warmstart=S_warm, ) except OWNWarmupFailed: @@ -226,8 +254,8 @@ def compute( solver.set_problem( P, A, - max_turbines_per_string, - ModelOptions(**model_options), + capacity=max_turbines_per_string, + constraints=self.constraints, ) # do the branch-and-bound MILP search diff --git a/ard/farm_aero/floris.py b/ard/farm_aero/floris.py index 0e436a1e..e04dd33c 100644 --- a/ard/farm_aero/floris.py +++ b/ard/farm_aero/floris.py @@ -8,6 +8,7 @@ import ard.utils.logging as ard_logging import ard.farm_aero.templates as templates +import ard.farm_loads.surrogate_load_functions as ard_slf def create_FLORIS_turbine_from_windIO( @@ -197,14 +198,16 @@ def setup(self): # set up FLORIS self.fmodel = floris.FlorisModel("defaults") - data_path = self.options["data_path"] self.fmodel.set( turbine_type=[ create_FLORIS_turbine_from_windIO(self.windIO, self.modeling_options), ], - wind_shear=self.windIO["site"]["energy_resource"]["wind_resource"].get( - "shear" - ), + wind_shear=self.windIO["site"]["energy_resource"]["wind_resource"] + .get( + "shear", + {}, + ) + .get("alpha"), reference_wind_height=getattr( self.wind_query, "reference_height", @@ -489,3 +492,284 @@ def compute(self, inputs, outputs): @ard_logging.component_log_capture def setup_partials(self): FLORISFarmComponent.setup_partials(self) + + +class FLORISSurrogateDELs(FLORISAEP): + """ + Component class for computing surrogate DELs using FLORIS. + """ + + def get_raw_DELs(self): + SATI = self.fmodel.get_turbine_sector_average_TI() * 100 + SAWS = self.fmodel.get_turbine_sector_average_wind_speed() + turbine_max_powers = ( + np.array( + [ + np.max(t.power_thrust_table["power"]) + for t in self.fmodel.core.farm.turbine_map + ] + ) + * 1000.0 + ) # W + power_setpoints = self.fmodel.core.farm.power_setpoints + power_setpoints = np.where( + np.isposinf(power_setpoints), turbine_max_powers, power_setpoints + ) + turbine_powers_percent = ( + power_setpoints / turbine_max_powers + ).flatten() * 100.0 # percent + + SATI_collapsed = SATI.reshape(-1, SATI.shape[-1]) + SAWS_collapsed = SAWS.reshape(-1, SAWS.shape[-1]) + + Yaw = self.fmodel.core.farm.yaw_angles.flatten() + + # [SAWSup SAWSright SAWSdown SAWSleft SATIup SATIright SATIdown SATIleft Yaw PowerDemand[%]] + input_data = np.concatenate( + ( + SAWS_collapsed, + SATI_collapsed, + Yaw[:, None], + turbine_powers_percent[:, None], + ), + axis=1, + ) + + # prepare ANN surrogates + ANN_DEL_params = self.modeling_options["surrogate_loads"] + ANN_DEL_BladeRoot = ard_slf.ANN_DEL_factory("BladeRoot", ANN_DEL_params) + ANN_DEL_Shaft = ard_slf.ANN_DEL_factory("Shaft", ANN_DEL_params) + ANN_DEL_TowerBase = ard_slf.ANN_DEL_factory("TowerBase", ANN_DEL_params) + ANN_DEL_YawBearings = ard_slf.ANN_DEL_factory("YawBearings", ANN_DEL_params) + + # Make predictions using ANN surrogates + del_bladeroot_ann = ANN_DEL_BladeRoot(input_data) + del_shaft_ann = ANN_DEL_Shaft(input_data) + del_towerbase_ann = ANN_DEL_TowerBase(input_data) + del_yawbearings_ann = ANN_DEL_YawBearings(input_data) + + return ( + del_bladeroot_ann, + del_shaft_ann, + del_towerbase_ann, + del_yawbearings_ann, + ) + + def get_DELs(self): + del_bladeroot_ann, del_shaft_ann, del_towerbase_ann, del_yawbearings_ann = ( + self.get_raw_DELs() + ) + + # Weight predictions by frequency of wind conditions + weighted_del_bladeroot = np.zeros_like(del_towerbase_ann) + weighted_del_shaft = np.zeros_like(del_towerbase_ann) + weighted_del_towerbase = np.zeros_like(del_towerbase_ann) + weighted_del_yawbearings = np.zeros_like(del_towerbase_ann) + + n_turbs = self.N_turbines + + frequencies = self.wind_query.freq_table.flatten()[ + self.wind_query.non_zero_freq_mask.flatten() + ] + for i, f in enumerate(frequencies): + weighted_del_bladeroot[i * n_turbs : i * n_turbs + n_turbs] = ( + del_bladeroot_ann[i * n_turbs : i * n_turbs + n_turbs] * f + ) + weighted_del_shaft[i * n_turbs : i * n_turbs + n_turbs] = ( + del_shaft_ann[i * n_turbs : i * n_turbs + n_turbs] * f + ) + weighted_del_towerbase[i * n_turbs : i * n_turbs + n_turbs] = ( + del_towerbase_ann[i * n_turbs : i * n_turbs + n_turbs] * f + ) + weighted_del_yawbearings[i * n_turbs : i * n_turbs + n_turbs] = ( + del_yawbearings_ann[i * n_turbs : i * n_turbs + n_turbs] * f + ) + + return ( + weighted_del_bladeroot, + weighted_del_shaft, + weighted_del_towerbase, + weighted_del_yawbearings, + ) + + def initialize(self): + super().initialize() # run super class script first! + + def setup(self): + super().setup() # run super class script first! + self.fmodel.core.wake.enable_turbine_turbulence_grid = True + + self.add_output( + "blade_root_DEL", + 0.0, + units="kN*m", + desc="frequency weighted sum of blade root DEL across all wind conditions", + ) + self.add_output( + "shaft_DEL", + 0.0, + units="kN*m", + desc="frequency weighted sum of shaft DEL across all wind conditions", + ) + self.add_output( + "tower_base_DEL", + 0.0, + units="kN*m", + desc="frequency weighted sum of tower base DEL across all wind conditions", + ) + self.add_output( + "yaw_bearings_DEL", + 0.0, + units="kN*m", + desc="frequency weighted sum of yaw bearings DELs across all wind conditions", + ) + + def setup_partials(self): + super().setup_partials() + + def compute(self, inputs, outputs): + super().compute(inputs, outputs) + tDEL_outputs = self.get_DELs() + DEL_outputs = [np.sum(v) for v in tDEL_outputs] + outputs["blade_root_DEL"] = DEL_outputs[0] + outputs["shaft_DEL"] = DEL_outputs[1] + outputs["tower_base_DEL"] = DEL_outputs[2] + outputs["yaw_bearings_DEL"] = DEL_outputs[3] + outputs["AEP_farm"] = FLORISFarmComponent.get_AEP_farm(self) + + +class FLORISCurtailment(FLORISSurrogateDELs): + """ + Component class for computing an AEP analysis using FLORIS. + + A component class that evaluates a series of farm power and associated + quantities using FLORIS with a wind rose to make an AEP estimate. Inherits + the interface from `templates.FarmAEPTemplate` and the computational guts + from `FLORISFarmComponent`. + + Options + ------- + case_title : str + a "title" for the case, used to disambiguate runs in practice (inherited + from `FLORISFarmComponent`) + modeling_options : dict + a modeling options dictionary (inherited via + `templates.FarmAEPTemplate`) + wind_query : floris.wind_data.WindRose + a WindQuery objects that specifies the wind conditions that are to be + computed (inherited from `templates.FarmAEPTemplate`) + + Inputs + ------ + x_turbines : np.ndarray + a 1D numpy array indicating the x-dimension locations of the turbines, + with length `N_turbines` (inherited via `templates.FarmAEPTemplate`) + y_turbines : np.ndarray + a 1D numpy array indicating the y-dimension locations of the turbines, + with length `N_turbines` (inherited via `templates.FarmAEPTemplate`) + yaw_turbines : np.ndarray + a numpy array indicating the yaw angle to drive each turbine to with + respect to the ambient wind direction, with length `N_turbines` + (inherited via `templates.FarmAEPTemplate`) + + Outputs + ------- + AEP_farm : float + the AEP of the farm given by the analysis (inherited from + `templates.FarmAEPTemplate`) + power_farm : np.ndarray + an array of the farm power for each of the wind conditions that have + been queried (inherited from `templates.FarmAEPTemplate`) + power_turbines : np.ndarray + an array of the farm power for each of the turbines in the farm across + all of the conditions that have been queried on the wind rose + (`N_turbines`, `N_wind_conditions`) (inherited from + `templates.FarmAEPTemplate`) + thrust_turbines : np.ndarray + an array of the wind turbine thrust for each of the turbines in the farm + across all of the conditions that have been queried on the wind rose + (`N_turbines`, `N_wind_conditions`) (inherited from + `templates.FarmAEPTemplate`) + """ + + # TODO: Update doc strings to reflect curtailment options and outputs + + def initialize(self): + super().initialize() # run super class script first! + + @ard_logging.component_log_capture + def setup(self): + super().setup() # run super class script first! + + self.add_input( + "turbine_power_pct_setpoints", + np.ones((self.N_wind_conditions, self.N_turbines)) + * 100.0, # default to no curtailment + units="percent", + desc="power percent-of-rated setpoints to drive the turbines to for curtailment, with length `N_turbines`", + ) + + @ard_logging.component_log_capture + def setup_partials(self): + super().setup_partials() + + @ard_logging.component_log_capture + def compute(self, inputs, outputs): + + # set up and run the floris model + self.fmodel.set( + layout_x=inputs["x_turbines"], + layout_y=inputs["y_turbines"], + wind_data=self.wind_query, + yaw_angles=np.array([inputs["yaw_turbines"]]), + reference_wind_height=getattr( + self.wind_query, + "reference_height", + None, + ), + ) + self.fmodel.core.wake.enable_turbine_turbulence_grid = ( + True # needed for the SATI/SAWS + ) + if "peak_shaving_fraction" in self.modeling_options.get("floris", {}): + raise NotImplementedError( + "modeling options specifies `peak_shaving_fraction` but the curtailment module..." + ) + if ( + True + ): # TODO: figure out how to enable through ard_system.yaml or otherwise to indicate that curtailment should be used + # set high to make sure that power setpoints are set to full power by default; TODO: update from turbine info provided? + rated_turbine_power = [ + max(tdef["power_thrust_table"]["power"]) * 1.0e3 # put it in Watts + for tdef in self.fmodel.core.farm.turbine_definitions + ] + + self.fmodel.set_operation_model("simple-derating") + self.fmodel.set( + power_setpoints=inputs["turbine_power_pct_setpoints"][ + self.wind_query.non_zero_freq_mask.flatten() + ] + / 100 + * rated_turbine_power + ) + + self.fmodel.run() + + self.rDEL_outputs = self.get_raw_DELs() + tDEL_outputs = self.tDEL_outputs = self.get_DELs() + DEL_outputs = [np.sum(v) for v in tDEL_outputs] + + # dump the yaml to re-run this case on demand + FLORISFarmComponent.dump_floris_yamlfile(self, self.dir_floris) + + # FLORIS computes the powers + outputs["AEP_farm"] = FLORISFarmComponent.get_AEP_farm(self) + outputs["power_farm"] = FLORISFarmComponent.get_power_farm(self) + if self.modeling_options.get("aero", {}).get("return_turbine_output"): + outputs["power_turbines"] = FLORISFarmComponent.get_power_turbines(self) + outputs["thrust_turbines"] = FLORISFarmComponent.get_thrust_turbines(self) + + outputs["blade_root_DEL"] = DEL_outputs[0] + outputs["shaft_DEL"] = DEL_outputs[1] + outputs["tower_base_DEL"] = DEL_outputs[2] + outputs["yaw_bearings_DEL"] = DEL_outputs[3] diff --git a/ard/farm_aero/templates.py b/ard/farm_aero/templates.py index 6f4da7b0..db4735ee 100644 --- a/ard/farm_aero/templates.py +++ b/ard/farm_aero/templates.py @@ -51,6 +51,21 @@ def create_windresource_from_windIO( # get the wind resource specification out of the dictionary wind_resource = windIOdict["site"]["energy_resource"]["wind_resource"] + reference_height = wind_resource.get("reference_height") + h_ref = wind_resource.get("shear", {}).get("h_ref") + if ( + reference_height is not None + and h_ref is not None + and not np.isclose(reference_height, h_ref) + ): + raise ValueError( + "Both 'reference_height' and 'h_ref' were provided in wind_resource " + f"with different values ({reference_height} vs {h_ref})." + ) + wind_resource_reference_height = ( + reference_height if reference_height is not None else h_ref + ) + # figure out the case in play fields_wind_resource = wind_resource.keys() case_probability_based = all( @@ -122,11 +137,7 @@ def create_windresource_from_windIO( ti_table=turbulence_intensities, ) # stash some metadata for the wind resource - wind_resource_representation.reference_height = ( - wind_resource["reference_height"] - if "reference_height" in wind_resource - else None - ) + wind_resource_representation.reference_height = wind_resource_reference_height return wind_resource_representation @@ -165,11 +176,7 @@ def create_windresource_from_windIO( turbulence_intensities=turbulence_intensities, ) # stash some metadata for the wind resource - wind_resource_representation.reference_height = ( - wind_resource["reference_height"] - if "reference_height" in wind_resource - else None - ) + wind_resource_representation.reference_height = wind_resource_reference_height wind_resource_representation.time = ( wind_resource["time"] if "time" in wind_resource else None ) diff --git a/ard/farm_loads/__init__.py b/ard/farm_loads/__init__.py new file mode 100644 index 00000000..99d7bd9e --- /dev/null +++ b/ard/farm_loads/__init__.py @@ -0,0 +1 @@ +from . import surrogate_load_functions diff --git a/ard/farm_loads/surrogate_load_functions.py b/ard/farm_loads/surrogate_load_functions.py new file mode 100644 index 00000000..78e0cc79 --- /dev/null +++ b/ard/farm_loads/surrogate_load_functions.py @@ -0,0 +1,166 @@ +"""Surrogate turbine-load predictions from trained artificial neural networks. + +The module stores the normalization values and weights for blade-root, shaft, +tower-base, and yaw-bearing damage-equivalent-load (DEL) models. Each predictor +accepts ten features per sample, in this order: four sector-average wind speeds, +four sector-average turbulence intensities, yaw angle, and power demand. +""" + +import numpy as np +import warnings + + +def tansig(x): + """Apply the hyperbolic tangent sigmoid transfer function. + + Parameters + ---------- + x : array_like + Input value or array of values. + + Returns + ------- + numpy.ndarray or scalar + Values transformed to the range [-1, 1]. + """ + return 2 / (1 + np.exp(-2 * x)) - 1 + + +def mapminmax_apply(x, settings): + """Scale feature-by-sample data using mapminmax settings. + + Parameters + ---------- + x : array_like + Data with shape (N_features, N_samples). + settings : dict + Scaling values containing ``xoffset``, ``gain``, and ``ymin``. + + Returns + ------- + numpy.ndarray + Scaled data with the same shape as ``x``. + """ + return (x - settings["xoffset"][:, np.newaxis]) * settings["gain"][ + :, np.newaxis + ] + settings["ymin"] + + +def mapminmax_reverse(y, settings): + """Reverse scaling applied with :func:`mapminmax_apply`. + + Parameters + ---------- + y : array_like + Scaled data. + settings : dict + Scaling values containing ``xoffset``, ``gain``, and ``ymin``. + + Returns + ------- + numpy.ndarray + Data in the original scale. + """ + return (y - settings["ymin"]) / settings["gain"] + settings["xoffset"] + + +def ANN_DEL_factory(name_QoI, ANN_DEL_params): + """Create a predictor for one turbine damage-equivalent-load quantity. + + Parameters + ---------- + name_QoI : str + Model key in ``ANN_DEL_params``: ``"BladeRoot"``, ``"Shaft"``, + ``"TowerBase"``, or ``"YawBearings"``. + ANN_DEL_params : dict + Mapping from model keys to model parameters. The selected model entry + must contain ``zScore_norm`` and ``weights`` mappings. ``zScore_norm`` + must contain ``mu_x`` and ``sigma_x`` (each with shape (10,)), and + scalar ``mu_t`` and ``sigma_t``. ``weights`` must contain ``W1`` with + shape (60, 10), ``b1`` with shape (60,), ``W2`` with shape (1, 60), + and scalar ``b2``. Values may be Python sequences or NumPy arrays. For + example:: + + { + "BladeRoot": { + "zScore_norm": { + "mu_x": [...], + "sigma_x": [...], + "mu_t": 0.0, + "sigma_t": 1.0, + }, + "weights": { + "W1": [...], + "b1": [...], + "W2": [...], + "b2": 0.0, + }, + }, + } + + Returns + ------- + callable + A function that predicts the selected DEL for one or more samples. + Its input has ten features per sample; its output has shape (Q, 1). + """ + + warnings.warn( + f"""Generating surrogate load function `{name_QoI}` using `ANN_DEL_factory`. + This is an experimental feature that requires a fixed model architecture. + Ensure its behavior is acceptable for your use case.""" + ) + + def out_fun(x1): + """Predict the selected DEL from ten features per sample. + + Parameters + ---------- + x1 : array_like + One sample with shape (10,) or multiple samples with shape + (Q, 10), ordered as described in the module documentation. + + Returns + ------- + numpy.ndarray + Nonnegative DEL predictions with shape (Q, 1), in the units used + to train the selected model. + + Raises + ------ + ValueError + If the input does not contain exactly ten features per sample. + KeyError + If the selected model parameters are missing from + ``ANN_DEL_params``. + """ + x1 = np.array(x1, dtype=np.float64) + if x1.ndim == 1: + x1 = x1.reshape(1, -1) + if x1.ndim != 2 or x1.shape[1] != 10: + raise ValueError("Input must have shape (Q, 10)") + + # Z-score normalization parameters (fit on training data) + mu_x = np.array(ANN_DEL_params[name_QoI]["zScore_norm"]["mu_x"]) + sigma_x = np.array(ANN_DEL_params[name_QoI]["zScore_norm"]["sigma_x"]) + mu_t = ANN_DEL_params[name_QoI]["zScore_norm"]["mu_t"] + sigma_t = ANN_DEL_params[name_QoI]["zScore_norm"]["sigma_t"] + + # Network weights: Linear(10->60) + ReLU + Linear(60->1) + W1 = np.array(ANN_DEL_params[name_QoI]["weights"]["W1"]) # shape (60, 10) + b1 = np.array(ANN_DEL_params[name_QoI]["weights"]["b1"]) # shape (60,) + W2 = np.array(ANN_DEL_params[name_QoI]["weights"]["W2"]) # shape (1, 60) + b2 = np.array(ANN_DEL_params[name_QoI]["weights"]["b2"]) # shape (1,) + + # Forward pass: z-score normalize -> Linear + ReLU -> Linear -> z-score denormalize + x_norm = (x1 - mu_x) / sigma_x # (Q, 10) + a1 = np.maximum(0, x_norm @ W1.T + b1) # (Q, 60) ReLU + y_norm = a1 @ W2.T + b2 # (Q, 1) + y = y_norm * sigma_t + mu_t # (Q, 1) + + # remove negative entries + y = np.maximum(0.0, y) + + return y + + return out_fun diff --git a/ard/utils/__init__.py b/ard/utils/__init__.py index cdb78752..e69de29b 100644 --- a/ard/utils/__init__.py +++ b/ard/utils/__init__.py @@ -1 +0,0 @@ -from . import test_utils diff --git a/pyproject.toml b/pyproject.toml index 0133d5ea..3c5688a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,11 @@ include = ["ard", "ard.*"] [project] name = "ard-nrel" -version = "0.1.0-beta5" +version = "0.1.0-beta6" authors = [ - {name = "Cory Frontin", email = "cory.frontin@nrel.gov"}, - {name = "Rafael Mudafort", email = "rafael.mudafort@nrel.gov"}, - {name = "Jared Thomas", email = "jared.thomas@nrel.gov"}, + {name = "Cory Frontin", email = "cory.frontin@nlr.gov"}, + {name = "Rafael Mudafort", email = "rafael.mudafort@nlr.gov"}, + {name = "Jared Thomas", email = "jared.thomas@nlr.gov"}, ] description = "A package for multidisciplinary and/or multifidelity wind farm design" readme= "README.md" @@ -39,14 +39,14 @@ classifiers = [ ] dependencies = [ "numpy", - "floris>=4.3", - "wisdem", + "floris>=4.7", + "wisdem<4.2.8", "NLopt", "marmot-agents", - "openmdao", + "openmdao[doe]<3.45.1", "shapely", "jax", - "optiwindnet>=0.2.2", + "optiwindnet>=0.3.2,<0.4", "statsmodels", "highspy", "pyyaml", diff --git a/test/ard/system/offshore/test_mooring_packing.py b/test/ard/system/offshore/test_mooring_packing.py index 7e0b6bc0..a268afd7 100644 --- a/test/ard/system/offshore/test_mooring_packing.py +++ b/test/ard/system/offshore/test_mooring_packing.py @@ -8,6 +8,9 @@ import pytest import ard +import ard.collection +import ard.offshore.mooring_constraint +import ard.offshore.mooring_design_constant_depth import ard.utils.io import ard.layout.gridfarm diff --git a/test/ard/unit/collection/test_optiwindnet.py b/test/ard/unit/collection/test_optiwindnet.py index 3f6bdd49..52daea6e 100644 --- a/test/ard/unit/collection/test_optiwindnet.py +++ b/test/ard/unit/collection/test_optiwindnet.py @@ -173,6 +173,97 @@ def test_compute_pyrite(self, subtests): assert np.allclose(validation_data[key], pyrite_data[key], rtol=5e-3) +@pytest.mark.usefixtures("subtests") +class TestOptiWindNetCollectionNetworkConstraints: + + def setup_method(self): + + # create the farm layout specification (same as TestOptiWindNetCollection) + n_turbines = 25 + x_turbines, y_turbines = [ + 130.0 * 7 * v.flatten() + for v in np.meshgrid( + np.linspace(-2, 2, int(np.sqrt(n_turbines)), dtype=int), + np.linspace(-2, 2, int(np.sqrt(n_turbines)), dtype=int), + ) + ] + x_substations = np.array([-500.0, 500.0], dtype=np.float64) + y_substations = np.array([-500.0, 500.0], dtype=np.float64) + + self.modeling_options = make_modeling_options( + x_turbines=x_turbines, + y_turbines=y_turbines, + x_substations=x_substations, + y_substations=y_substations, + ) + + # replace the legacy model_options with the equivalent network_constraints + del self.modeling_options["collection"]["model_options"] + self.modeling_options["collection"]["network_constraints"] = dict( + topology="branched", + feeder_route="segmented", + ) + + def test_compute_pyrite(self, subtests): + + # create the OpenMDAO model + model = om.Group() + model.add_subsystem( + "collection", + ard_own.OptiwindnetCollection( + modeling_options=self.modeling_options, + ), + ) + prob = om.Problem(model) + prob.setup() + + # run optiwindnet, which should not warn (e.g. about deprecated options) + with warnings.catch_warnings(): + warnings.simplefilter("error") + prob.run_model() + + # collect data to validate + validation_data = { + "terse_links": prob.get_val("collection.terse_links"), + "length_cables": prob.get_val("collection.length_cables"), + "load_cables": prob.get_val("collection.load_cables"), + "total_length_cables": prob.get_val("collection.total_length_cables"), + "max_load_cables": prob.get_val("collection.max_load_cables"), + } + + # validate data against the same pyrite file as the model_options case + pyrite_data = ard.utils.test_utils.pyrite_validator( + validation_data, + Path(__file__).parent / "test_optiwindnet_pyrite.npz", + load_only=True, + ) + + for key in validation_data: + with subtests.test(key): + assert np.allclose(validation_data[key], pyrite_data[key], rtol=5e-3) + + def test_both_options_error(self): + + # giving both network_constraints and model_options is ambiguous + modeling_options = copy.deepcopy(self.modeling_options) + modeling_options["collection"]["model_options"] = dict( + topology="branched", + feeder_route="segmented", + feeder_limit="unlimited", + ) + + model = om.Group() + model.add_subsystem( + "collection", + ard_own.OptiwindnetCollection( + modeling_options=modeling_options, + ), + ) + prob = om.Problem(model) + with pytest.raises(ValueError, match="not both"): + prob.setup() + + class TestOptiWindNetCollection12Turbines: def setup_method(self): diff --git a/test/ard/unit/cost/test_orbit_wrap.py b/test/ard/unit/cost/test_orbit_wrap.py index 437141d9..ba3f86aa 100644 --- a/test/ard/unit/cost/test_orbit_wrap.py +++ b/test/ard/unit/cost/test_orbit_wrap.py @@ -29,7 +29,9 @@ def test_raise_error(self): "site": { "energy_resource": { "wind_resource": { - "shear": 0.2, + "shear": { + "alpha": 0.2, + }, }, }, }, @@ -211,7 +213,9 @@ def test_baseline_farm(self, subtests): "site": { "energy_resource": { "wind_resource": { - "shear": 0.2, + "shear": { + "alpha": 0.2, + }, }, }, }, @@ -408,7 +412,9 @@ def setup_method(self): "site": { "energy_resource": { "wind_resource": { - "shear": 0.2, + "shear": { + "alpha": 0.2, + }, }, }, }, diff --git a/test/ard/unit/cost/test_wisdem_wrap.py b/test/ard/unit/cost/test_wisdem_wrap.py index 8d4ae901..265b9173 100644 --- a/test/ard/unit/cost/test_wisdem_wrap.py +++ b/test/ard/unit/cost/test_wisdem_wrap.py @@ -26,7 +26,9 @@ def setup_method(self): "site": { "energy_resource": { "wind_resource": { - "shear": 0.2, + "shear": { + "alpha": 0.2, + }, }, }, }, diff --git a/test/ard/unit/farm_aero/test_floris.py b/test/ard/unit/farm_aero/test_floris.py index 387a7113..fde72952 100644 --- a/test/ard/unit/farm_aero/test_floris.py +++ b/test/ard/unit/farm_aero/test_floris.py @@ -1,4 +1,6 @@ from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock import yaml @@ -13,6 +15,316 @@ import ard.farm_aero.floris as farmaero_floris +class TestFLORISSurrogateDELs: + + def setup_method(self): + self.FLORIS = farmaero_floris.FLORISSurrogateDELs() + self.FLORIS.fmodel = Mock() + + def test_feature_order(self, monkeypatch): + """Preserve condition/turbine order and convert TI and power to percent.""" + speeds = np.arange(16.0).reshape(2, 2, 4) + intensities = (np.arange(16.0).reshape(2, 2, 4) + 1) / 100.0 + self.FLORIS.fmodel.get_turbine_sector_average_wind_speed.return_value = speeds + self.FLORIS.fmodel.get_turbine_sector_average_TI.return_value = intensities + self.FLORIS.fmodel.core.farm = SimpleNamespace( + turbine_map=[ + SimpleNamespace(power_thrust_table={"power": [0.0, 2000.0]}), + SimpleNamespace(power_thrust_table={"power": [0.0, 4000.0]}), + ], + power_setpoints=np.array([[2e6, 2e6], [0.5e6, 3e6]]), + yaw_angles=np.array([[1.0, -2.0], [3.0, -4.0]]), + ) + params = {"test": "parameters"} + self.FLORIS.modeling_options = {"surrogate_loads": params} + predictions = [np.full((4, 1), value) for value in range(1, 5)] + predictors = [Mock(return_value=value) for value in predictions] + factory = Mock(side_effect=predictors) + monkeypatch.setattr(farmaero_floris.ard_slf, "ANN_DEL_factory", factory) + + result = self.FLORIS.get_raw_DELs() + + assert [call.args[0] for call in factory.call_args_list] == [ + "BladeRoot", + "Shaft", + "TowerBase", + "YawBearings", + ] + for call in factory.call_args_list: + assert call.args[1] is params + expected = np.array( + [ + [0, 1, 2, 3, 1, 2, 3, 4, 1, 100], + [4, 5, 6, 7, 5, 6, 7, 8, -2, 50], + [8, 9, 10, 11, 9, 10, 11, 12, 3, 25], + [12, 13, 14, 15, 13, 14, 15, 16, -4, 75], + ] + ) + for predictor, actual, prediction in zip(predictors, result, predictions): + predictor.assert_called_once() + np.testing.assert_allclose(predictor.call_args.args[0], expected) + np.testing.assert_array_equal(actual, prediction) + + def test_unlimited_setpoints_are_full_demand(self, monkeypatch): + """Translate FLORIS unlimited setpoints to 100% surrogate demand.""" + self.FLORIS.fmodel.get_turbine_sector_average_wind_speed.return_value = ( + np.zeros((1, 1, 4)) + ) + self.FLORIS.fmodel.get_turbine_sector_average_TI.return_value = np.zeros( + (1, 1, 4) + ) + self.FLORIS.fmodel.core.farm = SimpleNamespace( + turbine_map=[SimpleNamespace(power_thrust_table={"power": [0.0, 2000.0]})], + power_setpoints=np.array([[np.inf]]), + yaw_angles=np.array([[0.0]]), + ) + self.FLORIS.modeling_options = {"surrogate_loads": {}} + predictor = Mock(return_value=np.zeros((1, 1))) + monkeypatch.setattr( + farmaero_floris.ard_slf, "ANN_DEL_factory", Mock(return_value=predictor) + ) + + self.FLORIS.get_raw_DELs() + + np.testing.assert_array_equal( + predictor.call_args.args[0][:, -1], + [100.0], + ) + + def test_frequency_weighting(self): + """Weight each condition's turbine block without changing raw predictions.""" + self.FLORIS.N_turbines = 2 + self.FLORIS.wind_query = SimpleNamespace( + freq_table=np.array([[0.1, 0.0], [0.3, 0.6]]), + non_zero_freq_mask=np.array([[True, False], [True, True]]), + ) + raw = tuple(np.arange(1.0, 7.0).reshape(-1, 1) * scale for scale in range(1, 5)) + originals = tuple(value.copy() for value in raw) + self.FLORIS.get_raw_DELs = Mock(return_value=raw) + + weighted = self.FLORIS.get_DELs() + + self.FLORIS.get_raw_DELs.assert_called_once_with() + for actual, original, unchanged in zip(weighted, originals, raw): + np.testing.assert_allclose( + actual, + original * np.array([0.1, 0.1, 0.3, 0.3, 0.6, 0.6])[:, None], + ) + np.testing.assert_array_equal(unchanged, original) + + def test_compute(self, monkeypatch): + """Publish all four summed DEL outputs after the parent aero computation.""" + parent_compute = Mock() + monkeypatch.setattr(farmaero_floris.FLORISAEP, "compute", parent_compute) + self.FLORIS.fmodel.get_farm_AEP.return_value = 1234.0 + self.FLORIS.get_DELs = Mock( + return_value=tuple( + np.array([[1.0], [2.0]]) * scale for scale in range(1, 5) + ) + ) + inputs, outputs = {}, {} + + self.FLORIS.compute(inputs, outputs) + + parent_compute.assert_called_once_with(inputs, outputs) + assert outputs == { + "blade_root_DEL": 3.0, + "shaft_DEL": 6.0, + "tower_base_DEL": 9.0, + "yaw_bearings_DEL": 12.0, + "AEP_farm": 1234.0, + } + + +class TestFLORISCurtailment: + + @pytest.fixture(autouse=True) + def setup_problem(self, tmp_path, monkeypatch): + """Set up a tiny farm with DEL models that return scaled power demand.""" + monkeypatch.chdir(tmp_path) + first_weights = np.zeros((60, 10)) + first_weights[0, 9] = 1.0 + second_weights = np.zeros((1, 60)) + second_weights[0, 0] = 1.0 + names = ["BladeRoot", "Shaft", "TowerBase", "YawBearings"] + params = { + name: { + "zScore_norm": { + "mu_x": np.zeros(10), + "sigma_x": np.ones(10), + "mu_t": 0.0, + "sigma_t": float(index), + }, + "weights": { + "W1": first_weights, + "b1": np.zeros(60), + "W2": second_weights, + "b2": 0.0, + }, + } + for index, name in enumerate(names, start=1) + } + turbine_path = ( + Path(ard.__file__).parents[1] + / "examples/data/windIO-plant_turbine_IEA-3.4MW-130m-RWT.yaml" + ) + with open(turbine_path) as turbine_file: + turbine = yaml.safe_load(turbine_file) + self.modeling_options = { + "windIO_plant": { + "wind_farm": {"name": "unit test farm", "turbine": turbine}, + "site": { + "energy_resource": { + "wind_resource": { + "wind_direction": [270.0], + "wind_speed": [8.0, 12.0], + "probability": {"data": [[0.25, 0.75]]}, + "turbulence_intensity": {"data": [[0.06, 0.08]]}, + "reference_height": turbine["hub_height"], + } + } + }, + }, + "layout": {"N_turbines": 2}, + "aero": {"return_turbine_output": True}, + "surrogate_loads": params, + } + self.prob = om.Problem(reports=False) + self.FLORIS = self.prob.model.add_subsystem( + "curtailment", + farmaero_floris.FLORISCurtailment( + modeling_options=self.modeling_options, + case_title="unit test", + data_path="", + ), + ) + self.prob.setup() + self.prob.final_setup() + self.prob.set_val("curtailment.x_turbines", [0.0, 1000.0]) + self.prob.set_val("curtailment.y_turbines", [0.0, 0.0]) + yield + self.prob.cleanup() + + def test_setup(self): + """Declare percent setpoints per condition and turbine and scalar DELs.""" + np.testing.assert_array_equal( + self.prob.get_val( + "curtailment.turbine_power_pct_setpoints", units="percent" + ), + np.full((2, 2), 100.0), + ) + for name in [ + "blade_root_DEL", + "shaft_DEL", + "tower_base_DEL", + "yaw_bearings_DEL", + ]: + np.testing.assert_array_equal( + self.prob.get_val(f"curtailment.{name}", units="kN*m"), [0.0] + ) + assert self.FLORIS.N_wind_conditions == 2 + + @pytest.mark.parametrize( + "setpoints", + [ + np.full((2, 2), 100.0), + np.array([[50.0, 75.0], [25.0, 100.0]]), + ], + ) + def test_compute(self, setpoints): + """Run FLORIS and the ANNs with known setpoints and weighted DEL totals.""" + self.prob.set_val("curtailment.turbine_power_pct_setpoints", setpoints) + + self.prob.run_model() + + rated_power = ( + max( + self.FLORIS.fmodel.core.farm.turbine_definitions[0][ + "power_thrust_table" + ]["power"] + ) + * 1000.0 + ) + np.testing.assert_allclose( + self.FLORIS.fmodel.core.farm.power_setpoints, + setpoints / 100.0 * rated_power, + ) + assert self.FLORIS.fmodel.core.wake.enable_turbine_turbulence_grid + assert self.FLORIS.fmodel.get_operation_model() == "simple-derating" + frequencies = np.array([[0.25], [0.75]]) + for scale, name in enumerate( + [ + "blade_root_DEL", + "shaft_DEL", + "tower_base_DEL", + "yaw_bearings_DEL", + ], + start=1, + ): + np.testing.assert_allclose( + self.FLORIS.rDEL_outputs[scale - 1], + (setpoints * scale).reshape(-1, 1), + ) + np.testing.assert_allclose( + self.FLORIS.tDEL_outputs[scale - 1], + (setpoints * frequencies * scale).reshape(-1, 1), + ) + np.testing.assert_allclose( + self.prob.get_val(f"curtailment.{name}"), + np.sum(setpoints * frequencies) * scale, + ) + powers = self.prob.get_val("curtailment.power_turbines") + assert powers.shape == (2, 2) + assert np.all(powers >= 0.0) + assert np.all(powers <= (setpoints / 100.0 * rated_power).T + 1e-6) + np.testing.assert_allclose( + self.prob.get_val("curtailment.power_farm"), powers.sum(axis=0) + ) + np.testing.assert_allclose( + self.prob.get_val("curtailment.AEP_farm"), + np.sum(powers.sum(axis=0) * frequencies.flatten()) * 8760.0, + ) + assert (self.FLORIS.dir_floris / "batch.yaml").is_file() + + def test_compute_with_zero_probability_bin(self): + """Exclude zero-probability conditions from FLORIS setpoints and DELs.""" + original_wind_query = self.FLORIS.wind_query + self.FLORIS.wind_query = floris.WindRose( + wind_directions=original_wind_query.wind_directions, + wind_speeds=original_wind_query.wind_speeds, + freq_table=np.array([[0.0, 0.75]]), + ti_table=original_wind_query.ti_table, + ) + self.FLORIS.wind_query.reference_height = original_wind_query.reference_height + setpoints = np.array([[50.0, 75.0], [25.0, 100.0]]) + self.prob.set_val("curtailment.turbine_power_pct_setpoints", setpoints) + + self.prob.run_model() + + rated_power = ( + max( + self.FLORIS.fmodel.core.farm.turbine_definitions[0][ + "power_thrust_table" + ]["power"] + ) + * 1000.0 + ) + np.testing.assert_allclose( + self.FLORIS.fmodel.core.farm.power_setpoints, + setpoints[1:] / 100.0 * rated_power, + ) + assert self.FLORIS.rDEL_outputs[0].shape == (2, 1) + np.testing.assert_allclose( + self.FLORIS.tDEL_outputs[0], self.FLORIS.rDEL_outputs[0] + ) + + def test_rejects_peak_shaving(self): + """Reject the incompatible peak-shaving option before running FLORIS.""" + self.modeling_options["floris"] = {"peak_shaving_fraction": 0.4} + with pytest.raises(NotImplementedError, match="peak_shaving_fraction"): + self.prob.run_model() + + class TestFLORISFarmComponent: def setup_method(self): @@ -59,7 +371,9 @@ def setup_method(self): "wind_speed": wind_query.get_speeds().tolist(), "turbulence_intensity": wind_query.get_TIs().tolist(), "time": np.zeros_like(wind_query.get_speeds().tolist()), - "shear": 0.585, + "shear": { + "alpha": 0.585, + }, }, "reference_height": 90.0, }, @@ -204,7 +518,9 @@ def setup_method(self): "wind_speed", ], }, - "shear": 0.585, + "shear": { + "alpha": 0.585, + }, "reference_height": 110.0, }, }, diff --git a/test/ard/unit/farm_aero/test_templates.py b/test/ard/unit/farm_aero/test_templates.py index 0e4529cd..0d148e57 100644 --- a/test/ard/unit/farm_aero/test_templates.py +++ b/test/ard/unit/farm_aero/test_templates.py @@ -7,6 +7,56 @@ import ard.wind_query as wq import ard.farm_aero.templates as templates +import ard.farm_aero.templates as farmaero_templates + + +def _build_timeseries_windio(reference_height=None, h_ref=None): + wind_resource = { + "wind_direction": [270.0, 280.0], + "wind_speed": [8.0, 9.0], + "turbulence_intensity": [0.06, 0.06], + "time": [0, 1], + } + if reference_height is not None: + wind_resource["reference_height"] = reference_height + if h_ref is not None: + wind_resource["shear"] = {"h_ref": h_ref} + + return { + "site": { + "energy_resource": { + "wind_resource": wind_resource, + }, + }, + } + + +class TestWindResourceReferenceHeightAliases: + + def test_reference_height_or_h_ref_single_key(self): + windio_ref = _build_timeseries_windio(reference_height=110.0) + resource_ref = farmaero_templates.create_windresource_from_windIO( + windio_ref, "timeseries" + ) + assert resource_ref.reference_height == 110.0 + + windio_href = _build_timeseries_windio(h_ref=95.0) + resource_href = farmaero_templates.create_windresource_from_windIO( + windio_href, "timeseries" + ) + assert resource_href.reference_height == 95.0 + + def test_reference_height_and_h_ref_same_value(self): + windio = _build_timeseries_windio(reference_height=100.0, h_ref=100.0) + resource = farmaero_templates.create_windresource_from_windIO( + windio, "timeseries" + ) + assert resource.reference_height == 100.0 + + def test_reference_height_and_h_ref_different_values_raise(self): + windio = _build_timeseries_windio(reference_height=100.0, h_ref=101.0) + with pytest.raises(ValueError, match="reference_height.*h_ref"): + farmaero_templates.create_windresource_from_windIO(windio, "timeseries") class TestFarmAeroTemplate: diff --git a/test/ard/unit/farm_loads/test_surrogate_load_functions.py b/test/ard/unit/farm_loads/test_surrogate_load_functions.py new file mode 100644 index 00000000..758845a4 --- /dev/null +++ b/test/ard/unit/farm_loads/test_surrogate_load_functions.py @@ -0,0 +1,104 @@ +import numpy as np +import pytest + +import ard.farm_loads.surrogate_load_functions as surrogate + + +@pytest.fixture +def ann_params(): + """Build a sparse network with hand-computable predictions.""" + first_weights = np.zeros((60, 10)) + first_weights[0, [0, 9]] = [2.0, 1.0] + first_weights[1, 1] = -1.0 + first_bias = np.zeros(60) + first_bias[:2] = [1.0, -0.5] + second_weights = np.zeros((1, 60)) + second_weights[0, :2] = [3.0, -2.0] + return { + "zScore_norm": { + "mu_x": np.arange(10, dtype=float), + "sigma_x": np.arange(1, 11, dtype=float), + "mu_t": 2.0, + "sigma_t": 4.0, + }, + "weights": { + "W1": first_weights, + "b1": first_bias, + "W2": second_weights, + "b2": 0.25, + }, + } + + +@pytest.mark.parametrize("name", ["BladeRoot", "Shaft", "TowerBase", "YawBearings"]) +@pytest.mark.parametrize("as_lists", [False, True]) +def test_ann_forward_pass(ann_params, name, as_lists): + """Check normalization, both linear layers, ReLU, and clipping.""" + normalized = np.zeros((3, 10)) + normalized[0, [0, 1, 9]] = [1.0, 2.0, 3.0] + normalized[1, [0, 1]] = [-1.0, -2.0] + samples = ( + normalized * ann_params["zScore_norm"]["sigma_x"] + + ann_params["zScore_norm"]["mu_x"] + ) + if as_lists: + ann_params = { + section: { + key: value.tolist() if isinstance(value, np.ndarray) else value + for key, value in values.items() + } + for section, values in ann_params.items() + } + samples = samples.tolist() + + predictor = surrogate.ANN_DEL_factory(name, {name: ann_params}) + predictions = predictor(samples) + + assert predictions.shape == (3, 1) + np.testing.assert_allclose(predictions, [[75.0], [0.0], [15.0]]) + np.testing.assert_allclose(predictor(samples[0]), [[75.0]]) + + +@pytest.mark.parametrize("shape", [(9,), (11,), (2, 9), (2, 11)]) +def test_ann_rejects_wrong_feature_count(ann_params, shape): + """Reject samples that do not provide ten features.""" + predictor = surrogate.ANN_DEL_factory("Shaft", {"Shaft": ann_params}) + with pytest.raises(ValueError, match=r"Input must have shape \(Q, 10\)"): + predictor(np.zeros(shape)) + + +def test_ann_requires_selected_model(): + """Report a missing model rather than silently using another quantity.""" + predictor = surrogate.ANN_DEL_factory("TowerBase", {}) + with pytest.raises(KeyError, match="TowerBase"): + predictor(np.zeros(10)) + + +def test_tansig(): + """Check the sigmoid against the equivalent hyperbolic tangent.""" + values = np.array([-3.0, -1.0, 0.0, 1.0, 3.0]) + np.testing.assert_allclose(surrogate.tansig(values), np.tanh(values)) + assert surrogate.tansig(0.0) == 0.0 + + +def test_mapminmax_apply(): + """Apply distinct offsets and gains to each feature across samples.""" + settings = { + "xoffset": np.array([2.0, -3.0]), + "gain": np.array([0.5, 2.0]), + "ymin": -1.0, + } + values = np.array([[2.0, 4.0, 6.0], [-3.0, -2.0, -1.0]]) + np.testing.assert_allclose( + surrogate.mapminmax_apply(values, settings), + [[-1.0, 0.0, 1.0], [-1.0, 1.0, 3.0]], + ) + + +def test_mapminmax_reverse(): + """Recover original outputs from scaled predictions.""" + settings = {"xoffset": 2.0, "gain": 0.5, "ymin": -1.0} + np.testing.assert_allclose( + surrogate.mapminmax_reverse(np.array([-1.0, 0.0, 1.0]), settings), + [2.0, 4.0, 6.0], + ) diff --git a/test/conftest.py b/test/conftest.py index 80f5bd4f..c3ddf55b 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -8,11 +8,11 @@ def pytest_sessionfinish(session, exitstatus): # for each tempdir for pytest_out_dir in Path().glob("pytest*_out"): for root, dirs, files in os.walk( - pytest_out_dir, topdown=False + str(pytest_out_dir), topdown=False ): # walk the directory root = Path(root) for name in files: - (root / name).unlink() # remove subdirectory files, and + Path(root, name).unlink() # remove subdirectory files, and for name in dirs: - (root / name).rmdir() # remove subdirectories + Path(root, name).rmdir() # remove subdirectories pytest_out_dir.rmdir() # then remove that tempdir