Vestas Theoretical Power¶
Overview¶
The FeatureCalcPowerTheoreticalVestas class is a subclass of FeatureCalculator that calculates the theoretical power of a Vestas wind turbine using the contractual methodology. This is mostly used when calculating energy based availability in Vestas AOM5000 contracts and if all the underlying data is correct must mach exactly the values calculated by the Vestas SCADA system.
Calculation Logic¶
The calculation logic is described in the constructor of the class, shown below in the Class Definition section.
Database Requirements¶
- Feature attribute
server_calc_typemust be set totheoretical_active_power_vestas. - The following object attributes for the object that is being calculated:
-
Required:
-
contractual_power_curve: A dictionary containing the wind speed and active power pairs that define the contractual power curve of the wind turbine. This should be set for the air density of the site where the wind turbine is located. See an example of this dictionary below:{ "wind_speed": [0, 1, 2, 3, 4, 5, 6, ...], "active_power": [0.0, 0.0, 0.0, 0.0, 64.0, 165.0, 310.0, ...] } -
neighbor_wind_turbines: List of wind turbines that are considered neighbors to the wind turbine object, in order of proximity. reference_met_masts: List of met masts that are considered reference met masts to the wind turbine object, in order of proximity.spe_name: Name of the SPE of the wind turbine object.- Optional:
contractual_neighbor_wind_turbines: List of wind turbines that are considered neighbors to the wind turbine object according to contract definitions. This is used to calculate the active power of the wind turbine using the active power of the neighbors. If this is not present, theneighbor_wind_turbinesattribute will be used.- The following features for the object that is being calculated:
wind_speed: Wind speed in m/s.active_power: Active power in kW.curtailment_state: Curtailment state of the wind turbine.iec_operation_state: IEC operation state of the wind turbine.
- The following features for the neighbor wind turbines, in case the calculation needs to use them:
active_power: Active power in kW.curtailment_state: Curtailment state of the wind turbine.iec_operation_state: IEC operation state of the wind turbine.
- The following features for the reference met masts, in case the calculation needs to use them:
wind_speed: Wind speed in m/s, taken from featurewind_speed_1_avg.
-
-
Class Definition¶
FeatureCalcPowerTheoreticalVestas(object_name, feature)
¶
Class used to calculate contractual theoretical active power for a Vestas wind turbine.
This follows the calculation logic for Vestas turbines as described in the Vestas contract.
It will follow the following steps trying to fill all timestamps. If one step is not enough to fill all timestamps, the next step will be used until there are no more timestamps with NaN values.
-
Step 1: Use active power from the neighbor turbines.
Neighbor will be selected based on
contractual_neighbor_wind_turbinesobject attribute if available, otherwiseneighbor_wind_turbineswill be used.Neighbor data will only be used if it meets
IEC-OperationState_10min.REPandCurtailmentState_10min.REPrequirements. -
Step 2: Use contractual power curve in wind speed measured by this turbine.
- Step 3: Use contractual power curve in wind speed measured by reference met masts.
- Step 4: Consider the average active power of all turbines of this turbine wind farm, as long as it meets
IEC-OperationState_10min.REPandCurtailmentState_10min.REPrequirements.
Parameters:
-
(object_name¶str) –Name of the object for which the feature is calculated. It must exist in performance_db.
-
(feature¶str) –Feature of the object that is calculated. It must exist in performance_db.
Source code in echo_energycalc/feature_calc_power_theoretical_vestas.py
def __init__(
self,
object_name: str,
feature: str,
) -> None:
"""Class used to calculate contractual theoretical active power for a Vestas wind turbine.
This follows the calculation logic for Vestas turbines as described in the Vestas contract.
It will follow the following steps trying to fill all timestamps. If one step is not enough to fill all timestamps, the next step will be used until there are no more timestamps with NaN values.
- **Step 1**: Use active power from the neighbor turbines.
Neighbor will be selected based on `contractual_neighbor_wind_turbines` object attribute if available, otherwise `neighbor_wind_turbines` will be used.
Neighbor data will only be used if it meets `IEC-OperationState_10min.REP` and `CurtailmentState_10min.REP` requirements.
- **Step 2**: Use contractual power curve in wind speed measured by this turbine.
- **Step 3**: Use contractual power curve in wind speed measured by reference met masts.
- **Step 4**: Consider the average active power of all turbines of this turbine wind farm, as long as it meets `IEC-OperationState_10min.REP` and `CurtailmentState_10min.REP` requirements.
Parameters
----------
object_name : str
Name of the object for which the feature is calculated. It must exist in performance_db.
feature : str
Feature of the object that is calculated. It must exist in performance_db.
"""
# initialize parent class
super().__init__(object_name, feature)
# base requirements for the feature calculator
self._add_requirement(
RequiredObjectAttributes(
{
self.object: [
"neighbor_wind_turbines",
"contractual_power_curve",
"reference_met_masts",
"spe_name",
],
},
),
)
# optional requirements for the feature calculator
self._add_requirement(
RequiredObjectAttributes(
{
self.object: [
"contractual_neighbor_wind_turbines",
],
},
optional=True,
),
)
self._get_required_data()
# converting power curve to a function
try:
pc_df = DataFrame.from_dict(self._get_requirement_data("RequiredObjectAttributes")[self.object]["contractual_power_curve"])
self._power_curve = cf.convert_curve_df_to_func(pc_df, "wind_speed", "active_power", extrapolate=True)
except Exception as e:
raise ValueError(f"{self.object} - Could not convert contractual power curve to a function") from e
# defining required features
needed_features = ["WindSpeed_10min.AVG", "ActivePower_10min.AVG", "CurtailmentState_10min.REP", "IEC-OperationState_10min.REP"]
self._add_requirement(RequiredFeatures({self.object: needed_features}))
# amount of timestamps that is acceptable to have NaN values to avoid long calculations trying to fill all NaNs
self._max_nan = 0
feature
property
¶
Feature that is calculated. This will be defined in the constructor and cannot be changed.
Returns:
-
str–Name of the feature that is calculated.
name
property
¶
Name of the feature calculator. Is defined in child classes of FeatureCalculator.
This must be equal to the "server_calc_type" attribute of the feature in performance_db.
Returns:
-
str–Name of the feature calculator.
object
property
¶
Object for which the feature is calculated. This will be defined in the constructor and cannot be changed.
Returns:
-
str–Object name for which the feature is calculated.
requirements
property
¶
List of requirements of the feature calculator. Is defined in child classes of FeatureCalculator.
Returns:
-
dict[str, list[CalculationRequirement]]–Dict of requirements.
The keys are the names of the classes of the requirements and the values are lists of requirements of that class.
For example:
{"RequiredFeatures": [RequiredFeatures(...), RequiredFeatures(...)], "RequiredObjects": [RequiredObjects(...)]}
result
property
¶
Result of the calculation. This is None until the method "calculate" is called.
Returns:
-
Series | DataFrame | None:–Result of the calculation if the method "calculate" was called. None otherwise.
calculate(period, save_into=None, cached_data=None, **kwargs)
¶
Method that will calculate the feature.
Parameters:
-
(period¶DateTimeRange) –Period for which the feature will be calculated.
-
(save_into¶Literal['all', 'performance_db'] | None, default:None) –Argument that will be passed to the method "save". The options are: - "all": The feature will be saved in performance_db and bazefield. - "performance_db": the feature will be saved only in performance_db. - None: The feature will not be saved.
By default None.
-
(cached_data¶DataFrame | None, default:None) –DataFrame with features already queried/calculated. This is useful to avoid needing to query all the data again from performance_db, making chained calculations a lot more efficient. By default None
-
(**kwargs¶dict, default:{}) –Additional arguments that will be passed to the "save" method.
Returns:
-
DataFrame–DataFrame with the ActivePowerTheoreticalContractual_10min.AVG and ActivePowerTheoreticalContractualStep_10min.REP features.
Source code in echo_energycalc/feature_calc_power_theoretical_vestas.py
def calculate(
self,
period: DateTimeRange,
save_into: Literal["all", "performance_db"] | None = None,
cached_data: DataFrame | None = None,
**kwargs,
) -> Series:
"""Method that will calculate the feature.
Parameters
----------
period : DateTimeRange
Period for which the feature will be calculated.
save_into : Literal["all", "performance_db"] | None, optional
Argument that will be passed to the method "save". The options are:
- "all": The feature will be saved in performance_db and bazefield.
- "performance_db": the feature will be saved only in performance_db.
- None: The feature will not be saved.
By default None.
cached_data : DataFrame | None, optional
DataFrame with features already queried/calculated. This is useful to avoid needing to query all the data again from performance_db, making chained calculations a lot more efficient.
By default None
**kwargs : dict, optional
Additional arguments that will be passed to the "save" method.
Returns
-------
DataFrame
DataFrame with the ActivePowerTheoreticalContractual_10min.AVG and ActivePowerTheoreticalContractualStep_10min.REP features.
"""
t0 = perf_counter()
# creating a DataFrame to store the results
features = [self.feature, "ActivePowerTheoreticalContractualStep_10min.REP"]
columns = pd.MultiIndex.from_product(
[[self.object], features],
names=["object", "feature"],
)
result = self._create_empty_result(period=period, result_type="DataFrame", columns=columns)
# * Step 1: using active power from neighbor turbines
# skipping if everything in result is filled already (no NaNs)
if result[(self.object, self._feature)].isna().sum() > self._max_nan:
# getting neighbor turbines
if "contractual_neighbor_wind_turbines" in self._get_requirement_data("RequiredObjectAttributes")[self.object]:
neighbor_turbines = self._get_requirement_data("RequiredObjectAttributes")[self.object][
"contractual_neighbor_wind_turbines"
]
else:
neighbor_turbines = self._get_requirement_data("RequiredObjectAttributes")[self.object]["neighbor_wind_turbines"]
# iterating over neighbor turbines
for neighbor_idx, neighbor in enumerate(neighbor_turbines):
# skipping if everything in result is filled already (no NaNs)
if result[(self.object, self._feature)].isna().sum() <= self._max_nan:
continue
try:
# adjusting required period to be limited to the missing periods in result
missing_period = DateTimeRange(
result[result[(self.object, self._feature)].isna()].index[0],
result[result[(self.object, self._feature)].isna()].index[-1],
)
# add neighbor features to the requirements
self._add_requirement(
RequiredFeatures(
{neighbor: ["ActivePower_10min.AVG", "CurtailmentState_10min.REP", "IEC-OperationState_10min.REP"]},
),
)
# getting neighbor features
self._get_required_data(period=missing_period, reindex="10min", cached_data=cached_data)
# adjusting features for this neighbor turbine to remove periods where turbine is not producing power or is curtailed
df = self._adjust_features(neighbor)
# getting only indexes where result is NaN and neighbor ActivePower_10min.AVG is not NaN
idx = result[result[(self.object, self._feature)].isna()].index.intersection(
df[df["ActivePower_10min.AVG"].notna()].index
)
if len(idx) == 0:
continue
# filtering df to only include the indexes
df = df.loc[idx, :].copy()
# adding neighbor active power to result
result.loc[idx, (self.object, self._feature)] = df["ActivePower_10min.AVG"]
result.loc[idx, (self.object, "ActivePowerTheoreticalContractualStep_10min.REP")] = 1 + 0.01 * (neighbor_idx + 1)
except Exception:
logger.exception(f"'{self.object}' - Error on step 1: using active power from neighbor turbine '{neighbor}'")
t1 = perf_counter()
# * Step 2: using contractual power curve in this turbine wind speed
# skipping if everything in result is filled already (no NaNs)
if result[(self.object, self._feature)].isna().sum() > self._max_nan:
# getting data for this turbine
df = self._adjust_features(self.object)
# getting only indexes where result is NaN and neighbor WindSpeed_10min.AVG is not NaN
idx = result[result[(self.object, self._feature)].isna()].index.intersection(df[df["WindSpeed_10min.AVG"].notna()].index)
if len(idx) > 0:
# filtering df to only include the indexes
df = df.loc[idx, :].copy()
# calculating expected active power using contractual power curve
df["ActivePower_10min.AVG"] = df["WindSpeed_10min.AVG"].apply(self._power_curve)
# adding active power to result
result.loc[idx, (self.object, self._feature)] = df["ActivePower_10min.AVG"]
result.loc[idx, (self.object, "ActivePowerTheoreticalContractualStep_10min.REP")] = 2
t2 = perf_counter()
# * Step 3: using reference met mast wind
# skipping if everything in result is filled already (no NaNs)
if result[(self.object, self._feature)].isna().sum() > self._max_nan:
# getting reference met mast
reference_met_masts = self._get_requirement_data("RequiredObjectAttributes")[self.object]["reference_met_masts"]
# iterating over reference met masts
for ref_mast in reference_met_masts:
# skipping if everything in result is filled already (no NaNs)
if result[(self.object, self._feature)].isna().sum() <= self._max_nan:
continue
try:
# adjusting required period to be limited to the missing periods in result
# adding 10 minutes to the start of the period as data is going to be shifted one timestamp forward
missing_period = DateTimeRange(
result[result[(self.object, self._feature)].isna()].index[0] - timedelta(minutes=10),
result[result[(self.object, self._feature)].isna()].index[-1],
)
# selecting the features that are needed for the power curve model
turbine_mast_conversion = {
"WindSpeed_10min.AVG": "WindSpeed1_10min.AVG",
}
met_features = [v for _, v in turbine_mast_conversion.items()]
# add met mast features to the requirements
self._add_requirement(RequiredFeatures({ref_mast: met_features}))
# getting ref_mast features
self._get_required_data(period=missing_period, reindex="10min", cached_data=cached_data)
# adjusting features
df = self._adjust_features(ref_mast, rename_dict={v: k for k, v in turbine_mast_conversion.items()})
# shifting one timestamp as met mast data represents the start of period
df = df.shift(periods=1)
# getting indexes where all reference data for this met mast is not NaN
idx = df[~df.isna().any(axis=1)].index
# getting only indexes where result is NaN
idx = result[result[(self.object, self._feature)].isna()].index.intersection(idx)
if len(idx) == 0:
continue
# filtering df to only include the wanted indexes
df = df.loc[idx, :].copy()
# calculating expected active power using contractual power curve
df["ActivePower_10min.AVG"] = df["WindSpeed_10min.AVG"].apply(self._power_curve)
# adding active power to result
result.loc[idx, (self.object, self._feature)] = df["ActivePower_10min.AVG"]
result.loc[idx, (self.object, "ActivePowerTheoreticalContractualStep_10min.REP")] = 3
except Exception:
logger.exception(f"'{self.object}' - Error on step 3: using reference met mast wind '{ref_mast}'")
t3 = perf_counter()
# * Step 4: using average of all turbines from the same wind farm
# skipping if everything in result is filled already (no NaNs)
if result[(self.object, self._feature)].isna().sum() > self._max_nan:
try:
# adjusting required period to be limited to the missing periods in result
missing_period = DateTimeRange(
result[result[(self.object, self._feature)].isna()].index[0],
result[result[(self.object, self._feature)].isna()].index[-1],
)
# getting all turbines from the same wind farm
wf_wtgs = list(
self._perfdb.objects.instances.get_ids(
spe_names=[self._get_requirement_data("RequiredObjectAttributes")[self.object]["spe_name"]],
object_types=["wind_turbine"],
).keys(),
)
needed_wtgs = [w for w in wf_wtgs if w != self.object]
# adding required features for all turbines from the same wind farm
self._add_requirement(
RequiredFeatures(
{w: ["ActivePower_10min.AVG", "CurtailmentState_10min.REP", "IEC-OperationState_10min.REP"] for w in needed_wtgs},
),
)
# getting all turbines from the same wind farm features
self._get_required_data(period=missing_period, reindex="10min", cached_data=cached_data)
# adjusting features
df = self._adjust_features(needed_wtgs).loc[:, IndexSlice[:, "ActivePower_10min.AVG"]].droplevel(1, axis=1).copy()
# calculating average of all turbines from the same wind farm
df["average"] = df.mean(axis=1, skipna=True)
# getting indexes where "average" is present (no NaNs)
idx = df[~df["average"].isna()].index
# getting only indexes where result is NaN
idx = result[result[(self.object, self._feature)].isna()].index.intersection(idx)
if len(idx) > 0:
# adding average to result
result.loc[idx, (self.object, self._feature)] = df.loc[idx, "average"]
result.loc[idx, (self.object, "ActivePowerTheoreticalContractualStep_10min.REP")] = 4
except Exception:
logger.exception(f"'{self.object}' - Error on step 4: using average of all turbines from the same wind farm")
t4 = perf_counter()
# * Final check to see if all values are filled
null_idx = result[result[(self.object, self._feature)].isna()].index
if len(null_idx) > 0:
logger.error(
f"'{self.object}' - Could not calculate active_power_theoretical_vestas for {len(null_idx) / len(result):.2%} (len(null_idx)) of timestamps: {null_idx.to_list()}",
)
# adding calculated feature to class result attribute
self._result = result.copy()
# saving results
self.save(save_into=save_into, **kwargs)
logger.debug(
f"'{self.object}' - Time taken to calculate: {perf_counter() - t0:.2f}s. Step 1: {t1 - t0:.2f}s. Step 2: {t2 - t1:.2f}s. Step 3: {t3 - t2:.2f}s. Step 4: {t4 - t3:.2f}s. Final check: {perf_counter() - t4:.2f}s.",
)
return result
save(save_into=None, **kwargs)
¶
Method to save the calculated feature values in performance_db.
Parameters:
-
(save_into¶Literal['all', 'performance_db'] | None, default:None) –Argument that will be passed to the method "save". The options are: - "all": The feature will be saved in performance_db and bazefield. - "performance_db": the feature will be saved only in performance_db. - None: The feature will not be saved.
By default None.
-
(**kwargs¶dict, default:{}) –Not being used at the moment. Here only for compatibility.
Source code in echo_energycalc/feature_calc_core.py
def save(
self,
save_into: Literal["all", "performance_db"] | None = None,
**kwargs, # noqa: ARG002
) -> None:
"""
Method to save the calculated feature values in performance_db.
Parameters
----------
save_into : Literal["all", "performance_db"] | None, optional
Argument that will be passed to the method "save". The options are:
- "all": The feature will be saved in performance_db and bazefield.
- "performance_db": the feature will be saved only in performance_db.
- None: The feature will not be saved.
By default None.
**kwargs : dict, optional
Not being used at the moment. Here only for compatibility.
"""
# checking arguments
if not isinstance(save_into, str | type(None)):
raise TypeError(f"save_into must be a string or None, not {type(save_into)}")
if isinstance(save_into, str) and save_into not in ["all", "performance_db"]:
raise ValueError(f"save_into must be 'all', 'performance_db' or None, not {save_into}")
# checking if calculation was done
if self.result is None:
raise ValueError(
"The calculation was not done. Cannot save the feature calculation results. Please make sure to do something like 'self._result = df[self.feature].copy()' in the method 'calculate' before calling 'self.save()'.",
)
if save_into is None:
return
if isinstance(save_into, str):
if save_into not in ["performance_db", "all"]:
raise ValueError(f"save_into must be 'performance_db' or 'all', not {save_into}.")
upload_to_bazefield = save_into == "all"
elif save_into is None:
upload_to_bazefield = False
else:
raise TypeError(f"save_into must be a string or None, not {type(save_into)}.")
# converting result series to DataFrame if needed
if isinstance(self.result, Series):
result_df = self.result.to_frame()
elif isinstance(self.result, DataFrame):
result_df = self.result.droplevel(0, axis=1)
else:
raise TypeError(f"result must be a pandas Series or DataFrame, not {type(self.result)}.")
# adjusting DataFrame to be inserted in the database
# making the columns a Multindex with levels object_name and feature_name
result_df.columns = MultiIndex.from_product([[self.object], result_df.columns], names=["object_name", "feature_name"])
self._perfdb.features.values.series.insert(
df=result_df,
on_conflict="update",
bazefield_upload=upload_to_bazefield,
)