From 5699915bbd461d76ef61163840a29724077a1736 Mon Sep 17 00:00:00 2001 From: stephenholleran Date: Thu, 12 Feb 2026 17:40:30 +0000 Subject: [PATCH 01/11] prep for next release --- CHANGELOG.md | 15 +++++++++++++++ brightwind/__init__.py | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1baf5c75..86223a87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ Given a version number MAJOR.MINOR.PATCH, increment the: Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format. +--- +## [2.6.0-dev] +XX-Xxx-2026 + +### New Features and Enhancements +1. + +### Deprecated +1. + +### Bug Fixes +1. + --- ## [2.5.0] 12-Feb-2026 @@ -23,6 +36,8 @@ Additional labels for pre-release and build metadata are available as extensions ### Bug Fixes 1. Fixed two bugs on `apply_device_orientation_offset()` reported table and prints. ([#571](https://github.com/brightwind-dev/brightwind/issues/571)) 2. Fixed bug on `apply_wind_vane_deadband_offset()` reported table and prints. ([#569](https://github.com/brightwind-dev/brightwind/issues/569)) + +--- ## [2.4.0] ### New Features and Enhancements diff --git a/brightwind/__init__.py b/brightwind/__init__.py index cc8c2ade..78574bdb 100644 --- a/brightwind/__init__.py +++ b/brightwind/__init__.py @@ -34,4 +34,4 @@ __all__ = ['analyse', 'transform', 'export', 'load', 'demo_datasets'] -__version__ = '2.5.0' +__version__ = '2.6.0-dev' From 0053d4c190bf6971f8f1b32fa8b7d30b2747df9f Mon Sep 17 00:00:00 2001 From: Ilirmc Date: Thu, 19 Mar 2026 14:27:54 +0000 Subject: [PATCH 02/11] iss #305 TWS file export function (#593) * iss #305Added export_tws_file to export.py, and relevant test to test_export.py. * iss #305 Updated changelog * iss #305 Added a test file to compare test_export against. * iss #305 Added type checks * iss #305 Updated based on reviewer comments * Update CHANGELOG.md * iss #305 addressing OB and SH comments * iss #305 remove accidental test file * iss #305 reorder inputs --------- Co-authored-by: stephenholleran Co-authored-by: Olivia Bentley --- CHANGELOG.md | 2 +- brightwind/demo_datasets/demo_tws_file.tws | 26 ++++ brightwind/export/export.py | 140 ++++++++++++++++++++- tests/test_export.py | 16 +++ 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 brightwind/demo_datasets/demo_tws_file.tws diff --git a/CHANGELOG.md b/CHANGELOG.md index 86223a87..3a7886c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Additional labels for pre-release and build metadata are available as extensions XX-Xxx-2026 ### New Features and Enhancements -1. +1. Added `export_tws_file()` to allow export of a WindSim .tws formated timeseries climatology file. ([#305](https://github.com/brightwind-dev/brightwind/issues/305)). ### Deprecated 1. diff --git a/brightwind/demo_datasets/demo_tws_file.tws b/brightwind/demo_datasets/demo_tws_file.tws new file mode 100644 index 00000000..64192fef --- /dev/null +++ b/brightwind/demo_datasets/demo_tws_file.tws @@ -0,0 +1,26 @@ +export_tws_file created using brightwind version 2.6.0-dev at 2026-03-05 15:16:51. +version : 48 +site name : brightwind_site +measurement period : January 16 - January 16 +site position : 10 10 +coordinate system : 3 +measurement height : 80 + +rec nr: year: mon: date: hour: min: dir: speed: SDspeed: +1 2016 01 09 17 10 115.6 6.252 0.933 +2 2016 01 09 17 20 113.6 8.140 0.676 +3 2016 01 09 17 30 117.4 8.330 0.842 +4 2016 01 09 17 40 115.9 7.972 0.595 +5 2016 01 09 17 50 118.9 7.649 0.575 +6 2016 01 09 18 00 111.4 7.578 0.529 +7 2016 01 09 18 10 113.8 7.791 0.750 +8 2016 01 09 18 20 117.4 9.570 0.636 +9 2016 01 09 18 30 113.2 8.940 0.798 +10 2016 01 09 18 40 109.3 9.840 0.877 +11 2016 01 09 18 50 106.0 9.830 1.079 +12 2016 01 09 19 00 100.7 9.910 0.748 +13 2016 01 09 19 10 99.9 9.240 0.889 +14 2016 01 09 19 20 105.2 9.270 0.775 +15 2016 01 09 19 30 118.6 10.510 0.534 +16 2016 01 09 19 40 119.2 9.260 0.722 +17 2016 01 09 19 50 112.8 6.995 1.667 diff --git a/brightwind/export/export.py b/brightwind/export/export.py index f3e3d8f3..acffb7da 100644 --- a/brightwind/export/export.py +++ b/brightwind/export/export.py @@ -4,7 +4,7 @@ import re import brightwind -__all__ = ['export_tab_file', 'export_csv'] +__all__ = ['export_tab_file', 'export_csv', 'export_tws_file'] def _calc_mean_speed_of_freq_tab(freq_tab): @@ -104,6 +104,144 @@ def export_tab_file(freq_tab, height, lat, long, file_name=None, folder_path=Non ''.format(str(file_name), "{:.3f}".format(mean_wind_speed), str(lat), str(long), str(height))) + +def export_tws_file( + wspd_series, direction_series, eastings, northings, height, wspd_std_series=None, site_name=None, + file_name=None, folder_path=None + ): + """ + Export a WindSim timeseries tws file using a timeseries of wind speed, wind direction and optionally + standard deviation of wind speed. + + :param wspd_series: Series of wind speed variable with datetimeindex. + :type wspd_series: pandas.Series + :param direction_series: Series of wind direction variable between [0-360] with datetimeindex. + :type direction_series: pandas.Series + :param eastings: Eastings of the measurement location in meters. + :type eastings: float + :param northings: Northings of the measurement location in meters. + :type northings: float + :param height: Height that the timeseries represents in meters. + :type height: float + :param wspd_std_series: Series of wind speed standard deviations with datetimeindex. + :type wspd_std_series: pandas.Series + :param site_name: The site name to include in the file, or use the default "brightwind_site", + i.e 'Demo Data'. + :type site_name: str + :param file_name: The file name under which the tws file will be saved, or use the default, + i.e '2019-06-07_brightwind_tws_export.tws'. + :type file_name: str + :param folder_path: The directory where the tws file will be saved, default is the working directory. + :type folder_path: str + :return: Creates a WindSim timeseries tws file which can be used in the WindSim software. + + **Example Usage** + :: + import brightwind as bw + data = bw.load_csv(bw.demo_datasets.demo_data) + data = bw.apply_cleaning(data, bw.demo_datasets.demo_cleaning_file) + + bw.export_tws_file( + data.Spd40mN, data.Dir38mS, 626100, 827971, 80, data.Spd40mNStd, site_name='Demo Data', file_name='demo.tws' + ) + bw.export_tws_file( + data.Spd40mN, data.Dir38mS, 626100, 827971, 80, site_name='Demo Data', file_name='demo_no_std.tws' + ) + """ + + + # Type checks + names = ['eastings', 'northings', 'height', 'wspd_series', 'direction_series'] + objs = [ eastings, northings, height, wspd_series, direction_series] + expected = [(int,float), (int,float), (int,float), pd.Series, pd.Series] + + for name, obj, exp in zip(names, objs, expected): + if not isinstance(obj, exp): + raise TypeError(f"'{name}' must be type: {exp}, received: {type(obj).__name__}") + + if wspd_std_series is not None and not isinstance(wspd_std_series, pd.Series): + raise TypeError(f"'wspd_std_series' must be type: {pd.Series}, received: {type(wspd_std_series).__name__}") + if len(wspd_series) != len(direction_series): + raise ValueError("The lengths of 'wspd_series' and 'direction_series' are not the same.") + if wspd_std_series is not None and len(wspd_series) != len(wspd_std_series): + raise ValueError("The length of 'wspd_std_series' is not equal to 'wspd_series' and 'direction_series'.") + + + # Value checks + if not direction_series.dropna().between(0, 360).all(): + raise ValueError("direction_series values must be between 0 and 360") + + if height not in range(0, 400): + raise ValueError("height value must be between 0 and 400") + + # Optional inputs + site_name = 'brightwind_site' if site_name is None else site_name + + file_name = str(datetime.datetime.now().strftime("%Y-%m-%d")) + '_brightwind_tws_export.tws' if file_name is None else file_name + + folder_path = os.getcwd() if folder_path is None else folder_path + + file_name = file_name + '.tws' if file_name[-4:] != '.tws' else file_name + + include_sd = True if wspd_std_series is not None else False + + # Setup + file_name_print = os.path.splitext(file_name)[0] + file_path = os.path.join(folder_path, file_name) + + local_wspd_series = brightwind.analyse.analyse._convert_df_to_series(wspd_series.copy()) + local_direction_series = brightwind.analyse.analyse._convert_df_to_series(direction_series.copy()) + if include_sd: local_wspd_sd_series = brightwind.analyse.analyse._convert_df_to_series(wspd_std_series.copy()) + + # Get header info + current_timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + version = brightwind.__version__ + + tws_string = f"{file_name_print} created using brightwind version {version} at {current_timestamp}.\n" + + # Prepare timeseries output + tws_output = pd.concat([local_direction_series, local_wspd_series],axis=1, keys=['dir:','speed:']) + if include_sd: + tws_output['SDspeed:'] = local_wspd_sd_series + + tws_output = tws_output.dropna() + + tws_output['rec nr:'] = range(1, len(tws_output)+1) + start_id, end_id = tws_output.index[0].strftime('%B %y'), tws_output.index[-1].strftime('%B %y') + tws_output['year:'], tws_output['mon:'], tws_output['date:'] = tws_output.index.strftime('%Y'), tws_output.index.strftime('%m'), tws_output.index.strftime('%d') + tws_output['hour:'], tws_output['min:'] = tws_output.index.strftime('%H'), tws_output.index.strftime('%M') + tws_output.set_index('rec nr:', inplace=True) + + if include_sd: + tws_output = tws_output[['year:','mon:','date:','hour:','min:','dir:','speed:', 'SDspeed:']] + else: + tws_output = tws_output[['year:','mon:','date:','hour:','min:','dir:','speed:']] + + tws_string += 'version : 48\n' + tws_string += 'site name : ' + site_name + '\n' + tws_string += 'measurement period : ' + start_id + ' - ' + end_id + '\n' + tws_string += 'site position : ' + f"{eastings:.0f}" + ' ' + f"{northings:.0f}" + '\n' + tws_string += 'coordinate system : 3\n' + tws_string += 'measurement height : ' + str(height) + '\n\n' + + if include_sd: + tws_string += 'rec nr: year: mon: date: hour: min: dir: speed: SDspeed:\n' + else: + tws_string += 'rec nr: year: mon: date: hour: min: dir: speed:\n' + + tws_output['speed:'] = tws_output['speed:'].map('{:.3f}'.format) + if include_sd: + tws_output['SDspeed:'] = tws_output['SDspeed:'].map('{:.3f}'.format) + tws_output['dir:'] = tws_output['dir:'].map('{:.1f}'.format) + + # Write to file + with open(str(file_path), "w") as file: + file.write(tws_string) + tws_output.to_csv(file, header=False, sep=" ", mode='a', lineterminator='\n') + + # Print success message + print(f'Export of tws file {file_name} successful.\nEastings: {eastings:.0f} E, Northings: {northings:.0f} N, Height: {height} m\n') + def export_csv(data, file_name=None, folder_path=None, **kwargs): """ diff --git a/tests/test_export.py b/tests/test_export.py index 8ed492d6..24071e0a 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -20,6 +20,22 @@ def test_export_tab_file(): assert True +def test_export_tws_file(): + # Generate the files + bw.export_tws_file( + DATA.Spd40mN.iloc[:20], DATA.Dir38mS.iloc[:20], 10, 10, 80, DATA.Spd40mNStd.iloc[:20], + file_name='export_tws_file.tws', folder_path=TEMP_FOLDER + ) + + # Load the export file and test that it matches the test file + with open(r"..\brightwind\demo_datasets\demo_tws_file.tws", "r") as f: + test_file = f.read() + + with open('temp\export_tws_file.tws', "r") as f: + new_file = f.read() + assert new_file[85:] == test_file[85:] # skipping header, as timestamp is present, which will never match + + def test_export_to_csv(): bw.export_csv(DATA, file_name='export_to_csv', folder_path=TEMP_FOLDER) From 6c4e74aaa68b6482220d1ce2b60c9928fa813e68 Mon Sep 17 00:00:00 2001 From: olivia-bentley Date: Thu, 19 Mar 2026 15:11:58 +0000 Subject: [PATCH 03/11] iss #362 add mast section geometry extraction (#579) * iss #576 allow return of lists and dictionaries from raised child when requested * iss #576 add mast section geometry extraction * iss #362 updated changelog and added get_table property --------- Co-authored-by: BiancaMorandi Co-authored-by: stephenholleran --- CHANGELOG.md | 1 + brightwind/load/station.py | 46 +++++++++++++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a7886c0..53243ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Additional labels for pre-release and build metadata are available as extensions XX-Xxx-2026 ### New Features and Enhancements +1. Updated `MeasurementStation` function `_mast_section_geometry()` to extract mast section geometry properties. This is called by a user with `MeasurementStation.mast_section_geometry`. ([#362](https://github.com/brightwind-dev/brightwind/issues/362)) 1. Added `export_tws_file()` to allow export of a WindSim .tws formated timeseries climatology file. ([#305](https://github.com/brightwind-dev/brightwind/issues/305)). ### Deprecated diff --git a/brightwind/load/station.py b/brightwind/load/station.py index cf8571a3..bba0d1d2 100644 --- a/brightwind/load/station.py +++ b/brightwind/load/station.py @@ -119,7 +119,8 @@ def _rename_to_title(list_or_dict, schema): list_special_cases_no_prefix = ['logger_measurement_config.slope', 'logger_measurement_config.offset', 'logger_measurement_config.sensitivity', 'logger_measurement_config.height_m', - 'calibration.slope', 'calibration.offset', 'calibration.sensitivity'] + 'calibration.slope', 'calibration.offset', 'calibration.sensitivity', + 'mast_properties.mast_section_geometry'] if isinstance(list_or_dict, dict): renamed_dict = {} for k, v in list_or_dict.items(): @@ -396,7 +397,8 @@ def __init__(self, wra_data_model): self.__logger_main_configs = _LoggerMainConfigs(meas_loc_dm=self.__meas_loc_data_model, schema=self.__schema, station_type=self.type) self.__measurements = _Measurements(meas_loc_dm=self.__meas_loc_data_model, schema=self.__schema) - # self.__mast_section_geometry = _MastSectionGeometry() + self.__mast_section_geometry = _MastSectionGeometry(meas_loc_dm=self.__meas_loc_data_model, + schema=self.__schema) def __getitem__(self, item): return self.__meas_loc_properties[item] @@ -564,8 +566,7 @@ def measurements(self): @property def mast_section_geometry(self): - return 'Not yet implemented.' - # return self.__mast_section_geometry + return self.__mast_section_geometry class _Header: @@ -1189,5 +1190,38 @@ def get_heights(self, names=None, measurement_type_id=None): class _MastSectionGeometry: - def __init__(self): - raise NotImplementedError + def __init__(self, meas_loc_dm, schema): + self._mast_section_geometry = meas_loc_dm.get('mast_properties', {}).get('mast_section_geometry', []) + self._schema = schema + + def __getitem__(self, item): + return self._mast_section_geometry[item] + + def __iter__(self): + return iter(self._mast_section_geometry) + + def __repr__(self): + return repr(self._mast_section_geometry) + + @property + def data_model(self): + return self._mast_section_geometry + + + def get_table(self): + """ + Get a table representation of the mast section geometry. + + :return: A table showing all the information for the mast section geometry. + :rtype: pd.io.formats.style.Styler + """ + list_for_df = self._mast_section_geometry + + df = pd.DataFrame() + for idx, row in enumerate(list_for_df): + titles = list(_rename_to_title(list_or_dict=row, schema=self._schema).keys()) + df_temp = pd.DataFrame({idx + 1: list(row.values())}, index=titles) + df = pd.concat([df, df_temp], axis=1, sort=False) + df = df.style.set_properties(**{'text-align': 'left'}) + df = df.set_table_styles([dict(selector='th', props=[('text-align', 'left')])]) + return df From 5cd9e9bddca31f5693abf53302fb6a12cae524e1 Mon Sep 17 00:00:00 2001 From: olivia-bentley Date: Thu, 19 Mar 2026 16:13:31 +0000 Subject: [PATCH 04/11] Iss575 applyadj to associated types apply wspd slope offset adjapply wspd slope offset adj (#585) * iss #575 initial approach based on assumed column name mapping * iss #575 updated changelog and added test * iss #575 make optional argument and update docstring and print statements * iss #575 add gust * iss #575 applying correcttion to additional stats * iss #575 update changelog * iss #575 update test * iss #575 fixed typos doctring and added test * iss #575 fixed test * iss #575 fixed bug * iss #575 comment responses * iss #575 fixed message * Update CHANGELOG.md --------- Co-authored-by: BiancaMorandi Co-authored-by: stephenholleran --- CHANGELOG.md | 1 + brightwind/transform/transform.py | 215 +++++++++++++++++++++--------- tests/test_transform.py | 17 +++ 3 files changed, 170 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53243ad3..d45f46e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Additional labels for pre-release and build metadata are available as extensions XX-Xxx-2026 ### New Features and Enhancements +1. Updated `apply_wspd_slope_offset_adj()` in order to also apply slope and offset adjustments to related columns (for 'avg', 'min', 'max', 'gust', 'median', 'mode', 'range' and 'sd' statistic types) in addition to the main variable itself. It should be noted given lack of required information available no correction is applied to 'ti' 'ti30sec' or 'sum'. ([#575](https://github.com/brightwind-dev/brightwind/issues/575)). 1. Updated `MeasurementStation` function `_mast_section_geometry()` to extract mast section geometry properties. This is called by a user with `MeasurementStation.mast_section_geometry`. ([#362](https://github.com/brightwind-dev/brightwind/issues/362)) 1. Added `export_tws_file()` to allow export of a WindSim .tws formated timeseries climatology file. ([#305](https://github.com/brightwind-dev/brightwind/issues/305)). diff --git a/brightwind/transform/transform.py b/brightwind/transform/transform.py index ccf7517e..9d496bbc 100644 --- a/brightwind/transform/transform.py +++ b/brightwind/transform/transform.py @@ -1061,7 +1061,7 @@ def _get_consistent_properties_format(measurements, measurement_type_id): return property_list -def apply_wspd_slope_offset_adj(data, measurements, inplace=False): +def apply_wspd_slope_offset_adj(data, measurements, inplace=False, apply_to_related_statistics=False): """ Automatically apply wind speed calibration slope and offset adjustments to the timeseries data when they differ from the logger programmed slope and offsets. The slope and offset information for each measurement @@ -1072,18 +1072,34 @@ def apply_wspd_slope_offset_adj(data, measurements, inplace=False): Note: Be careful not to run this more than once in an assessment, when using inplace=True, as it will apply the adjustment again. - :param data: Timeseries data. - :type data: pd.DataFrame or pd.Series - :param measurements: Measurement information extracted from a WRA Data Model using bw.MeasurementStation - :type measurements: list or dict or _Measurements - :param inplace: If 'inplace' is True, the original direction data, contained in 'data', will be - modified and replaced with the adjusted direction data. If 'inplace' is False, the - original data will not be touched and instead a new DataFrame containing the adjusted - direction data is created. To store this adjusted direction data, please ensure it is - assigned to a new variable. - :type inplace: bool - :return: Data with adjusted wind speeds. - :rtype: pd.DataFrame or pd.Series + :param data: Timeseries data. + :type data: pd.DataFrame or pd.Series + :param measurements: Measurement information extracted from a WRA Data Model using + bw.MeasurementStation + :type measurements: list or dict or _Measurements + :param inplace: If True, the original data is modified in place. If False, the original data + will not be touched and instead a new DataFrame containing the adjusted data + is returned. To store this, please ensure it is assigned to a new variable. + Note: 'inplace=True' does not work when a subset of columns is passed as + 'data' (e.g. data[['Spd80mS', 'Spd60mS']]) as pandas returns a copy in that + case — use 'inplace=False' and assign the result instead. + :type inplace: bool + :param apply_to_related_statistics: If True, apply the adjustment to related statistics (e.g. if Spd60mS is + adjusted, also adjust Spd60mS_max, Spd60mS_min, Spd60mS_gust, Spd60mS_sd, + Spd60mS_median, Spd60mS_mode and Spd60mS_range). Statistics such as + Spd60mS_ti, Spd60mS_ti30sec and Spd60mS_sum are not adjusted. + If False, only apply the adjustment to the specific wind speed properties. + If True then the function expects the column name convention where the average + has nothing appended, max is appended with '_max', min is appended with '_min', + gust is appended with '_gust', sd is appended with '_sd', median is appended + with '_median', mode is appended with '_mode' and range is appended with + '_range'. + If the column name convention is different, set this parameter to False and the + adjustment will only be applied to the specific wind speed properties or rename + your data columns. Defaults to False. + :type apply_to_related_statistics: bool + :return: Data with adjusted wind speeds. + :rtype: pd.DataFrame or pd.Series **Example usage** :: @@ -1105,74 +1121,140 @@ def apply_wspd_slope_offset_adj(data, measurements, inplace=False): Send a specific wind speed property:: bw.apply_wspd_slope_offset_adj(data, mm1.measurements['Spd60mS'], inplace=True) - Send a specific wind direction property and data column:: + Send a specific wind speed property and data column:: bw.apply_wspd_slope_offset_adj(data['Spd60mS'], mm1.measurements['Spd60mS'], inplace=True) + Send a specific wind speed property and data column and adjust related statistics:: + data_edited = data.copy(deep=True) + data_edited = data_edited.rename(columns={'Spd80mSMax': 'Spd80mS_max', 'Spd80mSStd': 'Spd80mS_sd'}) + data_calib_adj = bw.apply_wspd_slope_offset_adj(data_edited[['Spd80mS', 'Spd80mS_max', 'Spd80mS_sd']], + mm1.measurements['Spd80mS'], inplace=False, + apply_to_related_statistics=True) + """ # Depending on what is sent, get wspd properties into a list of properties wspd_properties = _get_consistent_properties_format(measurements, 'wind_speed') if not wspd_properties: - raise ValueError('No wind speed measurements found.') + raise ValueError("No wind speed measurements found in the 'measurements' input. " + "No slope and offset adjustments can be applied.") # copy the data if needed data = data.copy(deep=True) if inplace is False else data wspd_in_dataset = False df = pd.DataFrame(data) if type(data) == pd.Series else data + col_not_in_data = [] # Apply the adjustment for wspd_prop in wspd_properties: name = wspd_prop['name'] - if name in df.columns: - wspd_in_dataset = True - date_to = wspd_prop.get('date_to') - date_from = wspd_prop.get('date_from') - if date_to is None or date_to == DATE_INSTEAD_OF_NONE: - date_to_txt = 'the end of dataset' + if not apply_to_related_statistics: + associated_statistics = [""] + uncorrected_associated_statistics = [] + else: + associated_statistics = [ + "" if prop["statistic_type_id"] == "avg" else prop["statistic_type_id"] + for prop in wspd_prop["logger_measurement_config.column_name"] + if prop["statistic_type_id"] in ["avg", "max", "min", "gust", "sd", "median", "mode", "range"] + ] + uncorrected_var_names = [ + f"{name}_{stat}" for stat in ["ti", "ti30sec", "sum"] + if f"{name}_{stat}" in df.columns + ] + if uncorrected_var_names: + print( + f"Found {', '.join(uncorrected_var_names)} in data, these will not be corrected for slope and " + "offset adjustments." + ) + + date_to = wspd_prop.get('date_to') + date_from = wspd_prop.get('date_from') + date_to_txt = 'the end of dataset' if (date_to is None or date_to == DATE_INSTEAD_OF_NONE) else date_to + + variables = { + 'slope': 'logger_measurement_config.slope', + 'offset': 'logger_measurement_config.offset', + 'cal_slope': 'calibration.slope', + 'cal_offset': 'calibration.offset' + } + none_variables = [v for v in variables.values() if wspd_prop.get(v) is None] + + # Split stats into those found / not found in df + stats_in_data = [] + for stat in associated_statistics: + # This assumed variable naming is based on what BrightHub uses + var_name = f"{name}_{stat}" if stat else name + if var_name in df.columns: + stats_in_data.append((stat, var_name)) else: - date_to_txt = date_to - - variables = { - 'slope': 'logger_measurement_config.slope', - 'offset': 'logger_measurement_config.offset', - 'cal_slope': 'calibration.slope', - 'cal_offset': 'calibration.offset' - } - none_variables = [v for v in variables.values() if wspd_prop.get(v) is None] - - if none_variables: - print("{} has {} value set as None. Slope and offset adjustment can't be applied " - "from {} to {}.\n".format(utils.bold(name), utils.bold(', '.join(none_variables)), - utils.bold(date_from), - utils.bold(date_to_txt))) - elif float(wspd_prop[variables['slope']]) != float(wspd_prop[variables['cal_slope']]) or \ - float(wspd_prop[variables['offset']]) != float(wspd_prop[variables['cal_offset']]): + col_not_in_data.append(var_name) + + if not stats_in_data: + continue + + wspd_in_dataset = True + if none_variables: + all_var_names = ', '.join(var_name for _, var_name in stats_in_data) + print("{} has {} value set as None. Slope and offset adjustment can't be applied " + "from {} to {}.\n".format(utils.bold(all_var_names), utils.bold(', '.join(none_variables)), + utils.bold(date_from), utils.bold(date_to_txt))) + elif float(wspd_prop[variables['slope']]) != float(wspd_prop[variables['cal_slope']]) or \ + float(wspd_prop[variables['offset']]) != float(wspd_prop[variables['cal_offset']]): + adjusted_slope_offset = [] + adjusted_slope_only = [] + for stat, var_name in stats_in_data: try: - df.loc[date_from:date_to, name] = \ - adjust_slope_offset(df[name][date_from:date_to], - current_slope=float(wspd_prop[variables['slope']]), - current_offset=float(wspd_prop[variables['offset']]), - new_slope=float(wspd_prop[variables['cal_slope']]), - new_offset=float(wspd_prop[variables['cal_offset']])) - print('{} has slope and offset adjustment applied from {} to {}.\n' - .format(utils.bold(name), utils.bold(date_from), - utils.bold(date_to_txt))) + current_offset = 0 if stat in ["sd", "range"] else float(wspd_prop[variables['offset']]) + new_offset = 0 if stat in ["sd", "range"] else float(wspd_prop[variables['cal_offset']]) + df.loc[date_from:date_to, var_name] = adjust_slope_offset( + df[var_name][date_from:date_to], + current_slope=float(wspd_prop[variables['slope']]), + current_offset=current_offset, + new_slope=float(wspd_prop[variables['cal_slope']]), + new_offset=new_offset + ) + (adjusted_slope_only if stat in ["sd", "range"] else adjusted_slope_offset).append(var_name) + except TypeError: print('{} has TypeError with logger or calibration slope and offset values. Skipping.\n' - .format(utils.bold(name))) + .format(utils.bold(var_name))) except Exception as error_msg: print(error_msg) - else: - print('{} logger slope and offsets are equal to calibration slope and offsets from ' - '{} to {}.\n'.format(utils.bold(name), - utils.bold(date_from), - utils.bold(date_to_txt))) + if adjusted_slope_offset and adjusted_slope_only: + print('{} has slope and offset adjustment applied and {} has slope adjustment applied ' + 'from {} to {}.\n' + .format(utils.bold(', '.join(adjusted_slope_offset)), + utils.bold(', '.join(adjusted_slope_only)), + utils.bold(date_from), utils.bold(date_to_txt))) + elif adjusted_slope_offset: + print('{} has slope and offset adjustment applied from {} to {}.\n' + .format(utils.bold(', '.join(adjusted_slope_offset)), + utils.bold(date_from), utils.bold(date_to_txt))) + elif adjusted_slope_only: + print('{} has slope adjustment applied from {} to {}.\n' + .format(utils.bold(', '.join(adjusted_slope_only)), + utils.bold(date_from), utils.bold(date_to_txt))) else: - print('{} is not found in data.\n'.format(utils.bold(name))) + all_var_names = ', '.join(var_name for _, var_name in stats_in_data) + print( + f'{utils.bold(all_var_names)} logger slope and offsets are equal to calibration slope and offsets from ' + f'{utils.bold(date_from)} to {utils.bold(date_to_txt)}. No adjustment applied.\n' + ) if wspd_in_dataset is False: - print('No wind speed measurement type found in the configurations.') - # if a Series is sent, send back a Series + print( + 'None of the wind speed measurements reported in the "measurements" input is found in the data. ' + 'No slope and offset adjustments can be applied.\n' + ) + if col_not_in_data and wspd_in_dataset: + print( + f"Following wind speed measurement(s) reported in the 'measurements' input not found in the data: " + f"{utils.bold(str(col_not_in_data))}." + ) + + # if a Series is sent, write back inplace and send back a Series if type(data) == pd.Series: + if inplace: + data[:] = df[df.columns[0]] df = df[df.columns[0]] return df @@ -1268,11 +1350,12 @@ def apply_wind_vane_deadband_offset( :param measurements: Measurement information extracted from a WRA Data Model using bw.MeasurementStation :type measurements: list or dict or _Measurements - :param inplace: If 'inplace' is True, the original direction data, contained in 'data', will be - modified and replaced with the adjusted direction data. If 'inplace' is False, - the original data will not be touched and instead a new DataFrame containing the - adjusted direction data is created. To store this adjusted direction data, - please ensure it is assigned to a new variable. + :param inplace: If True, the original data is modified in place. If False, the original data + will not be touched and instead a new DataFrame containing the adjusted data + is returned. To store this, please ensure it is assigned to a new variable. + Note: 'inplace=True' does not work when a subset of columns is passed as + 'data' (e.g. data[['Dir78mS', 'Dir60mS']]) as pandas returns a copy in that + case — use 'inplace=False' and assign the result instead. :type inplace: bool :param return_results_table: Optional key to return a dataframe containing deadband offset, logger offset and applied offset for each directional sensor and the time period it is @@ -1747,8 +1830,14 @@ def apply_device_orientation_offset( :param wdir_cols: Wind direction column names to apply the offset to. If empty, all wind direction columns in the data are used. Default is an empty list. :type wdir_cols: list - :param inplace: If True, modifies `data` in place. If False, returns a new - DataFrame/Series with adjusted values. Default is False. + :param inplace: If True, the original data is modified in place. If False, the + original data will not be touched and instead a new DataFrame + containing the adjusted data is returned. To store this, please + ensure it is assigned to a new variable. + Note: 'inplace=True' does not work when a subset of columns is + passed as 'data' (e.g. data[['col1', 'col2']]) as pandas returns + a copy in that case — use 'inplace=False' and assign the result + instead. Default is False. :type inplace: bool, optional :param return_results_table: If True, returns a DataFrame containing the device orientation, logger orientation and offset applied for each relevant time period. diff --git a/tests/test_transform.py b/tests/test_transform.py index 02adee7e..a5b58478 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -154,6 +154,22 @@ def test_apply_wspd_slope_offset_adj(): data1 = bw.apply_wspd_slope_offset_adj(DATA['Spd60mS'], STATION.measurements['Spd60mS']) assert (data1.fillna(0).round(10) == DATA_ADJUSTED['Spd60mS'].fillna(0).round(10)).all() + + DATA['Spd80mS_max'] = DATA['Spd80mS'] + 10 + data = bw.apply_wspd_slope_offset_adj(DATA, STATION.measurements, apply_to_related_statistics=True) + assert np.allclose( + data['Spd80mS_max'], + bw.adjust_slope_offset(DATA['Spd80mS_max'], 0.8445, 0.321, 0.84449, 0.3209), + equal_nan=True + ) + + DATA['Spd80mS_sd'] = DATA['Spd80mS'] + 10 + data = bw.apply_wspd_slope_offset_adj(DATA, STATION.measurements, apply_to_related_statistics=True) + assert np.allclose( + data['Spd80mS_sd'], + bw.adjust_slope_offset(DATA['Spd80mS_sd'], 0.8445, 0, 0.84449, 0), + equal_nan=True + ) def test_offset_wind_direction_float(): @@ -436,6 +452,7 @@ def test_apply_device_orientation_offset_table_returned(): 'Date To': None}]).set_index('Name') pd.testing.assert_frame_equal(table, expected_table) + def test_apply_device_orientation_offset(): actual_series_result = bw.apply_device_orientation_offset( From a27457986a7979a9e53a88d7793ea3b7b8a413e0 Mon Sep 17 00:00:00 2001 From: olivia-bentley Date: Thu, 19 Mar 2026 18:45:01 +0000 Subject: [PATCH 05/11] Iss406 fix for speedsort dropna (#595) * iss #406 fix for dropna issue * iss #406 make speedsort handle 360+ for direction * iss #406 fix for the other branch where ref dir not specified * iss #406 updated changelog * iss #406 added docstring for synthesize * iss #406 update docstring * Update CHANGELOG.md --------- Co-authored-by: BiancaMorandi Co-authored-by: stephenholleran --- CHANGELOG.md | 2 +- brightwind/analyse/correlation.py | 31 +++++++++++++++++++---- tests/test_correlation.py | 41 +++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d45f46e6..f34d3767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ XX-Xxx-2026 1. ### Bug Fixes -1. +1. Fixed two bugs on `SpeedSort.synthesize()` accounting for NaNs and when direction is 360. ([#406](https://github.com/brightwind-dev/brightwind/issues/406)). --- ## [2.5.0] diff --git a/brightwind/analyse/correlation.py b/brightwind/analyse/correlation.py index 63a0181e..cb068d08 100644 --- a/brightwind/analyse/correlation.py +++ b/brightwind/analyse/correlation.py @@ -286,9 +286,10 @@ def synthesize(self, ext_input=None, ref_coverage_threshold=None, target_coverag coverage_threshold=ref_coverage_threshold, return_coverage=False) synth_data = ref_averaged[self._ref_spd_col_name].copy() * np.nan + ref_dir_normalised = ref_averaged[self._ref_dir_col_name] % 360 for params_dict in self.params: if params_dict['num_data_points'] > 1: - logic_sect = self._get_logic_dir_sector(ref_dir=ref_averaged[self._ref_dir_col_name], + logic_sect = self._get_logic_dir_sector(ref_dir=ref_dir_normalised, sector_min=params_dict['sector_min'], sector_max=params_dict['sector_max']) @@ -1091,7 +1092,7 @@ def _linear_interpolation(xa, xb, ya, yb, xc): def _predict_dir(self, x_dir): - x_dir = x_dir.dropna().rename('dir') + x_dir = (x_dir.dropna() % 360).rename('dir') sector_min = [] sector_max = [] @@ -1222,7 +1223,27 @@ def _predict(self, x_spd, x_dir): return prediction.sort_index() def synthesize(self, input_spd=None, input_dir=None): - + """ + Apply the derived SpeedSort correlation model to produce a synthesized speed and direction dataset. + + When called with no arguments, the model is applied to the original reference speed and direction data. + This is first averaged to the averaging period requested when `SpeedSort` is initialised. The resulting + synthesized dataset is then spliced with the target dataset — where a target value is available it is used + instead of the synthesized value. + + When `input_spd` and `input_dir` are provided, the model is applied to those inputs directly and + no splicing with the target dataset is performed. The output index matches the input index. + + :param input_spd: Optional external speed series to apply the model to instead of the original + reference speed. Must be provided together with `input_dir`. + :type input_spd: pd.Series or None + :param input_dir: Optional external direction series to apply the model to instead of the original + reference direction. Must be provided together with `input_spd`. + :type input_dir: pd.Series or None + :return: DataFrame with two columns: synthesized speed (named `_Synthesized`) + and synthesized direction (named `_Synthesized`). + :rtype: pd.DataFrame + """ if input_spd is None and input_dir is None: ref_start_date, target_start_date = self._get_synth_start_dates() @@ -1241,8 +1262,8 @@ def synthesize(self, input_spd=None, input_dir=None): return_coverage=False).combine_first(dir_output) else: - output = self._predict(input_spd, input_dir) - dir_output = self._predict_dir(input_dir) + output = self._predict(input_spd, input_dir).reindex(input_spd.index) + dir_output = self._predict_dir(input_dir).reindex(input_dir.index) output[output < 0] = 0 return pd.concat([output.rename(self._tar_spd_col_name + "_Synthesized"), dir_output.rename(self._tar_dir_col_name + "_Synthesized")], axis=1, join='outer') diff --git a/tests/test_correlation.py b/tests/test_correlation.py index 1708168a..f3892b32 100644 --- a/tests/test_correlation.py +++ b/tests/test_correlation.py @@ -357,6 +357,47 @@ def test_synthesize(): assert ss_cor._ref_spd_col_name == 'Spd80mN_ref' assert ss_cor._tar_spd_col_name == 'Spd80mN' + # Test that synthesize(input_spd, input_dir) preserves NaN timestamps — output length must + # equal input length, and rows where input speed or direction is NaN must be NaN in output. + ss_cor = bw.Correl.SpeedSort(data_test['Spd80mN'], data_test['Dir78mS'], data_test['Spd60mN'], data_test['Dir58mS'], + averaging_prd='10min') + ss_cor.run(show_params=False) + data_synt_ext = ss_cor.synthesize(input_spd=data_test['Spd80mN'], input_dir=data_test['Dir78mS']) + # Output index must match the input index exactly + assert len(data_synt_ext) == len(data_test['Spd80mN']) + # Confirm the input direction is actually NaN at the timestamps we're testing against + assert data_test['Dir78mS']['2016-01-09 17:10:00':'2016-01-09 17:50:00'].isnull().all() + # Timestamps where input direction (Dir78mS) is NaN must produce NaN in both output columns + dir_nan_slice = data_synt_ext['2016-01-09 17:10:00':'2016-01-09 17:50:00'] + assert dir_nan_slice['Spd60mN_Synthesized'].isnull().all() + assert dir_nan_slice['Dir58mS_Synthesized'].isnull().all() + + # Test that a direction value of exactly 360 (= 0°, North) does not produce NaN in the output. + data_test_360 = DATA_CLND[['Spd80mN', 'Spd60mN', 'Dir78mS', 'Dir58mS']].copy() + data_test_360.loc['2016-01-09 18:00:00', 'Dir78mS'] = 360 + ss_cor = bw.Correl.SpeedSort(data_test_360['Spd80mN'], data_test_360['Dir78mS'], + data_test_360['Spd60mN'], data_test_360['Dir58mS'], + averaging_prd='10min') + ss_cor.run(show_params=False) + data_synt_360 = ss_cor.synthesize(input_spd=data_test_360['Spd80mN'], input_dir=data_test_360['Dir78mS']) + assert data_test_360.loc['2016-01-09 18:00:00', 'Dir78mS'] == 360 # confirm input has 360 + assert not pd.isnull(data_synt_360.loc['2016-01-09 18:00:00', 'Dir58mS_Synthesized']) + + # Test that a ref direction of exactly 360 in the splice path (synthesize() no args) does not + # produce NaN. With 10min averaging period each period has one data point so the averaged ref + # direction equals the raw value, making it straightforward to inject exactly 360. + data_test_splice_360 = DATA_CLND[['Spd80mN', 'Spd60mN', 'Dir78mS', 'Dir58mS']].copy() + data_test_splice_360.loc['2016-01-09 17:50:00':'2016-01-10 19:10:00', 'Dir58mS'] = np.nan + data_test_splice_360.loc['2016-01-09 17:50:00':'2016-01-10 19:10:00', 'Spd60mN'] = np.nan + data_test_splice_360.loc['2016-01-09 18:00:00', 'Dir78mS'] = 360 + ss_cor = bw.Correl.SpeedSort(data_test_splice_360['Spd80mN'], data_test_splice_360['Dir78mS'], + data_test_splice_360['Spd60mN'], data_test_splice_360['Dir58mS'], + averaging_prd='10min') + ss_cor.run(show_params=False) + data_synt_splice = ss_cor.synthesize() + assert data_test_splice_360.loc['2016-01-09 18:00:00', 'Dir78mS'] == 360 # confirm ref dir is 360 + assert not pd.isnull(data_synt_splice.loc['2016-01-09 18:00:00', 'Spd60mN_Synthesized']) + def test_orthogonal_least_squares(): correl_monthly_results = {'slope': 1.01778, 'offset': -0.13473, 'r2': 0.8098, 'num_data_points': 18} From 0d95c61dfa99587a64f08b831620949ffcdd37bf Mon Sep 17 00:00:00 2001 From: olivia-bentley Date: Wed, 1 Apr 2026 16:58:28 +0100 Subject: [PATCH 06/11] iss #562 also scale air temperature (#598) * iss #562 also scale air temperature * iss #562 minor docstring and changelog word fix * iss #562 update parameter naming --------- Co-authored-by: BiancaMorandi --- CHANGELOG.md | 1 + brightwind/transform/scale.py | 31 ++++++++++++++++++++++++++----- tests/test_scale.py | 35 +++++++++++++++++++++++++++++++---- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f34d3767..180c428e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ XX-Xxx-2026 1. Updated `apply_wspd_slope_offset_adj()` in order to also apply slope and offset adjustments to related columns (for 'avg', 'min', 'max', 'gust', 'median', 'mode', 'range' and 'sd' statistic types) in addition to the main variable itself. It should be noted given lack of required information available no correction is applied to 'ti' 'ti30sec' or 'sum'. ([#575](https://github.com/brightwind-dev/brightwind/issues/575)). 1. Updated `MeasurementStation` function `_mast_section_geometry()` to extract mast section geometry properties. This is called by a user with `MeasurementStation.mast_section_geometry`. ([#362](https://github.com/brightwind-dev/brightwind/issues/362)) 1. Added `export_tws_file()` to allow export of a WindSim .tws formated timeseries climatology file. ([#305](https://github.com/brightwind-dev/brightwind/issues/305)). +4. Updated `scale_air_pressure_to_height()` to accept air temperature and air pressure time series at different heights, the user can now specify a separate height for the reference temperature time series. This is then used to scale the time series to the target height before scaled air pressure is calculated ([#562](https://github.com/brightwind-dev/brightwind/issues/562)). ### Deprecated 1. diff --git a/brightwind/transform/scale.py b/brightwind/transform/scale.py index 6cc0497e..b6f449d0 100644 --- a/brightwind/transform/scale.py +++ b/brightwind/transform/scale.py @@ -304,7 +304,8 @@ def scale_air_temperature_to_height(ref_air_temperature: Union[float, pd.Series] def scale_air_pressure_to_height(ref_air_pressure_hPa: Union[float, pd.Series], ref_air_temp_degC: Union[float, pd.Series], ref_height_m: Union[float, int], - target_height_m: Union[float, int] + target_height_m: Union[float, int], + ref_air_temp_height_m: Union[float, int, None] = None ) -> Union[float, int, pd.Series]: """ Calculates air pressure at target height (target_height_m) using reference air pressure (ref_air_pressure_hPa) @@ -319,15 +320,29 @@ def scale_air_pressure_to_height(ref_air_pressure_hPa: Union[float, pd.Series], L = -0.0065 is the temperature lapse rate (K/m) (denoted beta in ISO:2533 notation) R = 287.05 is the specific gas constant for dry air (J/K/kg or m2/K/s2) + If air temperature is measured at a different height than air pressure, the `ref_air_temp_height_m` parameter + can be provided. In this case, the air temperature is first scaled from `ref_air_temp_height_m` to + `ref_height_m` internally using `scale_air_temperature_to_height` before the pressure scaling is applied. + :param ref_air_pressure_hPa: Reference air pressure value(s) in hPa (1mbar = 1hPa = 100Pa). :type ref_air_pressure_hPa: float or int or pandas.Series - :param ref_air_temp_degC: Reference air temperature value(s) in degrees celsius. + :param ref_air_temp_degC: Reference air temperature value(s) in degrees celsius. If + `ref_air_temp_height_m` is provided, this temperature is assumed to be + measured at `ref_air_temp_height_m` and will be scaled to `ref_height_m` + internally. Otherwise, temperature is assumed to be at `ref_height_m`. :type ref_air_temp_degC: float or int or pandas.Series - :param ref_height_m: Height (in metres) of reference air temperature (ref_air_temp_degC) - and air pressure (ref_air_pressure_hPa). + :param ref_height_m: Height (in metres) of reference air pressure (ref_air_pressure_hPa). + If `ref_air_temp_height_m` is None, this is also assumed to be the + height of ref_air_temp_degC. :type ref_height_m: float or int :param target_height_m: Height (in metres) which ref_air_pressure_hPa is scaled to. - :type target_height_m float or int + :type target_height_m: float or int + :param ref_air_temp_height_m: Height (in metres) at which ref_air_temp_degC is measured. If None + (default), air temperature is assumed to be at ref_height_m and no internal + temperature scaling is applied. If a float or int value is provided, + the air temperature is first scaled from ref_air_temp_height_m to ref_height_m + before being used in the pressure scaling calculation. + :type ref_air_temp_height_m: float or int or None :return: Air pressure at specified height of target_height_m in hPa (1mbar = 1hPa = 100Pa). Type depends on type(ref_air_pressure_hPa) and type(ref_air_temp_degC) inputs. :rtype: float or int or pandas.Series @@ -366,12 +381,18 @@ def scale_air_pressure_to_height(ref_air_pressure_hPa: Union[float, pd.Series], for var, var_name in zip([ref_height_m, target_height_m], ['ref_height_m', 'target_height_m']): if not isinstance(var, (float, int)): raise TypeError(f"{var_name} must be a float or int.") + if ref_air_temp_height_m is not None and not isinstance(ref_air_temp_height_m, (float, int)): + raise TypeError("ref_air_temp_height_m must be a float, int, or None.") # check dimensions of ref_air_pressure_hPa and ref_air_temp_degC if Series if isinstance(ref_air_pressure_hPa, pd.Series) and (isinstance(ref_air_temp_degC, pd.Series)): if len(ref_air_pressure_hPa) != len(ref_air_temp_degC): raise ValueError("ref_air_pressure_hPa and ref_air_temp_degC must have the same dimensions.") + # if air temperature is at a different height than air pressure, scale it to the air pressure height first + if ref_air_temp_height_m is not None: + ref_air_temp_degC = scale_air_temperature_to_height(ref_air_temp_degC, ref_air_temp_height_m, ref_height_m) + # Constants as outlined in ISO:2533 g = ACCEL_DUE_TO_GRAVITY # Acceleration due to gravity (m/s^2) L = TEMP_LAPSE_RATE_STANDARD_ATMOSPHERE # Temperature lapse rate (K/m) (denoted beta in ISO:2533 notation) diff --git a/tests/test_scale.py b/tests/test_scale.py index f63d66dc..cfed84b8 100644 --- a/tests/test_scale.py +++ b/tests/test_scale.py @@ -8,10 +8,10 @@ def test_scale_air_pressure_to_height(): - assert round(bw.scale_air_pressure_to_height(ref_air_pressure_hPa=1000, ref_air_temp_degC=12, - ref_height_m=10, target_height_m=200), 2) == 977.45 - assert round(bw.scale_air_pressure_to_height(ref_air_pressure_hPa=1000, ref_air_temp_degC=12, - ref_height_m=10, target_height_m=15), 2) == 999.4 + assert np.allclose(bw.scale_air_pressure_to_height(ref_air_pressure_hPa=1000, ref_air_temp_degC=12, + ref_height_m=10, target_height_m=200), 977.45) + assert np.allclose(bw.scale_air_pressure_to_height(ref_air_pressure_hPa=1000, ref_air_temp_degC=12, + ref_height_m=10, target_height_m=15), 999.4) pd.testing.assert_series_equal(bw.scale_air_pressure_to_height( ref_air_pressure_hPa=DATA['P2m'].loc['2016-01-09 17:10':'2016-01-09 18:00'], ref_air_temp_degC=DATA['T2m'].loc['2016-01-09 17:10':'2016-01-09 18:00'], @@ -21,6 +21,33 @@ def test_scale_air_pressure_to_height(): '2016-01-09 17:40:00', '2016-01-09 17:50:00', '2016-01-09 18:00:00'])), check_names=False) + assert np.allclose(bw.scale_air_pressure_to_height(ref_air_pressure_hPa=1000, ref_air_temp_degC=12, + ref_height_m=10, target_height_m=200, + ref_air_temp_height_m=10), 977.45) + + # ref_air_temp_height_m at a different height than ref_height_m, float scalar inputs + assert np.allclose(bw.scale_air_pressure_to_height(ref_air_pressure_hPa=1000, ref_air_temp_degC=12, + ref_height_m=10, target_height_m=200, + ref_air_temp_height_m=50), 977.4654461) + + # ref_air_temp_height_m at a different height than ref_height_m, Series inputs + pd.testing.assert_series_equal(bw.scale_air_pressure_to_height( + ref_air_pressure_hPa=DATA['P2m'].loc['2016-01-09 17:10':'2016-01-09 18:00'], + ref_air_temp_degC=DATA['T2m'].loc['2016-01-09 17:10':'2016-01-09 18:00'], + ref_height_m=2, target_height_m=10, + ref_air_temp_height_m=200), + pd.Series(data=[933.07343532, 933.07312917, 933.07268472, 932.07408455, 932.07381214, 932.07428959], + index=pd.to_datetime(['2016-01-09 17:10:00', '2016-01-09 17:20:00', '2016-01-09 17:30:00', + '2016-01-09 17:40:00', '2016-01-09 17:50:00', '2016-01-09 18:00:00'])), + check_names=False) + + # TypeError raised when ref_air_temp_height_m is not float, int, or None + with pytest.raises(TypeError): + bw.scale_air_pressure_to_height(ref_air_pressure_hPa=1000, ref_air_temp_degC=12, + ref_height_m=10, target_height_m=200, + ref_air_temp_height_m='10') + + # test error raising for invalid input types with pytest.raises(TypeError): bw.scale_air_pressure_to_height( From 81abdcd81342d26ca5dfa9eef770051a9459b8a2 Mon Sep 17 00:00:00 2001 From: olivia-bentley Date: Wed, 1 Apr 2026 17:13:01 +0100 Subject: [PATCH 07/11] iss #596 changes to allow return of ax (#597) * iss #596 changes to allow return of ax * iss #596 update docstring * iss #596 update changelog * iss #596 updated also plot_scatter_wspd, plot_scatter_wdir and speed sort plot_wind_directions to take as input ax --------- Co-authored-by: BiancaMorandi Co-authored-by: stephenholleran --- CHANGELOG.md | 7 +++--- brightwind/analyse/correlation.py | 17 ++++++++++----- brightwind/analyse/plot.py | 36 ++++++++++++++++++++----------- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 180c428e..b4f51bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,11 @@ Additional labels for pre-release and build metadata are available as extensions XX-Xxx-2026 ### New Features and Enhancements -1. Updated `apply_wspd_slope_offset_adj()` in order to also apply slope and offset adjustments to related columns (for 'avg', 'min', 'max', 'gust', 'median', 'mode', 'range' and 'sd' statistic types) in addition to the main variable itself. It should be noted given lack of required information available no correction is applied to 'ti' 'ti30sec' or 'sum'. ([#575](https://github.com/brightwind-dev/brightwind/issues/575)). +1. Updated `apply_wspd_slope_offset_adj()` in order to also apply slope and offset adjustments to related columns (for 'avg', 'min', 'max', 'gust', 'median', 'mode', 'range' and 'sd' statistic types) in addition to the main variable itself. It should be noted given lack of required information available no correction is applied to 'ti' 'ti30sec' or 'sum'. ([#575](https://github.com/brightwind-dev/brightwind/issues/575)) 1. Updated `MeasurementStation` function `_mast_section_geometry()` to extract mast section geometry properties. This is called by a user with `MeasurementStation.mast_section_geometry`. ([#362](https://github.com/brightwind-dev/brightwind/issues/362)) -1. Added `export_tws_file()` to allow export of a WindSim .tws formated timeseries climatology file. ([#305](https://github.com/brightwind-dev/brightwind/issues/305)). -4. Updated `scale_air_pressure_to_height()` to accept air temperature and air pressure time series at different heights, the user can now specify a separate height for the reference temperature time series. This is then used to scale the time series to the target height before scaled air pressure is calculated ([#562](https://github.com/brightwind-dev/brightwind/issues/562)). +1. Added `export_tws_file()` to allow export of a WindSim .tws formated timeseries climatology file. ([#305](https://github.com/brightwind-dev/brightwind/issues/305)) +1. Updated `plot_scatter()` to accept an `ax` parameter to support use in subplots. ([#596](https://github.com/brightwind-dev/brightwind/issues/596)) +1. Updated `scale_air_pressure_to_height()` to accept air temperature and air pressure time series at different heights, the user can now specify a separate height for the reference temperature time series. This is then used to scale the time series to the target height before scaled air pressure is calculated. ([#562](https://github.com/brightwind-dev/brightwind/issues/562)) ### Deprecated 1. diff --git a/brightwind/analyse/correlation.py b/brightwind/analyse/correlation.py index cb068d08..52c3bda0 100644 --- a/brightwind/analyse/correlation.py +++ b/brightwind/analyse/correlation.py @@ -98,7 +98,7 @@ def show_params(self): """Show the dictionary of parameters""" pprint.pprint(self.params) - def plot(self, figure_size=(10, 10.2)): + def plot(self, figure_size=(10, 10.2), ax=None): """ Plots scatter plot of reference versus target speed data. If ref_dir is given as input to the correlation then the plot is showing scatter subplots for each sector. The regression line and the line of slope 1 passing @@ -106,8 +106,10 @@ def plot(self, figure_size=(10, 10.2)): :param figure_size: Figure size in tuple format (width, height) :type figure_size: tuple + :param ax: Matplotlib ax object for a subplot. + :type ax: matplotlib.axes.Axes :returns: A matplotlib figure - :rtype: matplotlib.figure.Figure + :rtype: matplotlib.figure.Figure | None **Example usage** :: @@ -134,7 +136,7 @@ def plot(self, figure_size=(10, 10.2)): self.data[self._tar_spd_col_name], self._predict(self.data[self._ref_spd_col_name]), x_label=self._ref_spd_col_name, y_label=self._tar_spd_col_name, - line_of_slope_1=True, figure_size=figure_size) + line_of_slope_1=True, figure_size=figure_size, ax=ax) else: """For plotting scatter by sector""" return plot_scatter_by_sector(self.data[self._ref_spd_col_name], @@ -1268,16 +1270,21 @@ def synthesize(self, input_spd=None, input_dir=None): return pd.concat([output.rename(self._tar_spd_col_name + "_Synthesized"), dir_output.rename(self._tar_dir_col_name + "_Synthesized")], axis=1, join='outer') - def plot_wind_directions(self): + def plot_wind_directions(self, ax=None): """ Plots reference and target directions in a scatter plot + + :param ax: Matplotlib ax object for a subplot. + :type ax: matplotlib.axes.Axes + :returns: A matplotlib figure + :rtype: matplotlib.figure.Figure | None """ return plot_scatter_wdir( self.data[self._ref_dir_col_name][(self.data[self._ref_spd_col_name] > self.cutoff) & (self.data[self._tar_spd_col_name] > self.cutoff)], self.data[self._tar_dir_col_name][(self.data[self._ref_spd_col_name] > self.cutoff) & (self.data[self._tar_spd_col_name] > self.cutoff)], - x_label=self._ref_dir_col_name, y_label=self._tar_dir_col_name) + x_label=self._ref_dir_col_name, y_label=self._tar_dir_col_name, ax=ax) class SVR: diff --git a/brightwind/analyse/plot.py b/brightwind/analyse/plot.py index 2063f9f9..cad2b2f9 100644 --- a/brightwind/analyse/plot.py +++ b/brightwind/analyse/plot.py @@ -972,7 +972,7 @@ def _get_best_row_col_number_for_subplot(number_subplots): def plot_scatter(x, y, trendline_y=None, trendline_x=None, line_of_slope_1=False, x_label=None, y_label=None, x_limits=None, y_limits=None, axes_equal=True, figure_size=(10, 10.2), - trendline_dots=False, **kwargs): + trendline_dots=False, ax=None, **kwargs): """ Plots a scatter plot of x and y data. The trendline_y data is also shown if provided as input of the function. @@ -1001,9 +1001,11 @@ def plot_scatter(x, y, trendline_y=None, trendline_x=None, line_of_slope_1=False :type trendline_dots: Bool :param figure_size: Figure size in tuple format (width, height) :type figure_size: tuple + :param ax: Matplotlib ax object for a subplot. + :type ax: matplotlib.axes.Axes :param kwargs: Additional keyword arguments for matplotlib.pyplot.subplot :return: A scatter plot - :rtype: matplotlib.figure.Figure + :rtype: matplotlib.figure.Figure | None **Example usage** :: @@ -1064,17 +1066,23 @@ def plot_scatter(x, y, trendline_y=None, trendline_x=None, line_of_slope_1=False if line_of_slope_1 is True: legend = True - fig, axes = plt.subplots(figsize=figure_size, **kwargs) + if ax is None: + fig, ax = plt.subplots(figsize=figure_size, **kwargs) + return_fig = True + else: + fig = ax.get_figure() + return_fig = False _scatter_subplot(x, y, trendline_y=trendline_y, trendline_x=trendline_x, line_of_slope_1=line_of_slope_1, x_label=x_label, y_label=y_label, x_limits=x_limits, y_limits=y_limits, axes_equal=axes_equal, - trendline_dots=trendline_dots, legend=legend, ax=axes) + trendline_dots=trendline_dots, legend=legend, ax=ax) - plt.close() - return fig + if return_fig: + plt.close() + return fig def plot_scatter_wdir(x_wdir_series, y_wdir_series, x_label=None, y_label=None, - x_limits=(0, 360), y_limits=(0, 360)): + x_limits=(0, 360), y_limits=(0, 360), ax=None): """ Plots a scatter plot of two wind direction timeseries and adds a line from 0,0 to 360,360. @@ -1090,8 +1098,10 @@ def plot_scatter_wdir(x_wdir_series, y_wdir_series, x_label=None, y_label=None, :type x_limits: tuple :param y_limits: y-axis min and max limits. :type y_limits: tuple + :param ax: Matplotlib ax object for a subplot. + :type ax: matplotlib.axes.Axes :return: A scatter plot - :rtype: matplotlib.figure.Figure + :rtype: matplotlib.figure.Figure | None **Example usage** :: @@ -1115,13 +1125,13 @@ def plot_scatter_wdir(x_wdir_series, y_wdir_series, x_label=None, y_label=None, if y_label is None: y_label = y_wdir_series.name + ' [°]' scat_plot = plot_scatter(x_wdir_series, y_wdir_series, x_label=x_label, y_label=y_label, - x_limits=x_limits, y_limits=y_limits, line_of_slope_1=True) + x_limits=x_limits, y_limits=y_limits, line_of_slope_1=True, ax=ax) return scat_plot def plot_scatter_wspd(x_wspd_series, y_wspd_series, x_label=None, y_label=None, - x_limits=(0, 30), y_limits=(0, 30)): + x_limits=(0, 30), y_limits=(0, 30), ax=None): """ Plots a scatter plot of two wind speed timeseries and adds a reference line from 0,0 to 40,40. This should only be used for wind speeds in m/s and not when one of the wind speed series is normalised. Please use the @@ -1141,8 +1151,10 @@ def plot_scatter_wspd(x_wspd_series, y_wspd_series, x_label=None, y_label=None, :param y_limits: y-axis min and max limits. Can be set to None to let the code derive the min and max from the y_wspd_series. :type y_limits: tuple, None + :param ax: Matplotlib ax object for a subplot. + :type ax: matplotlib.axes.Axes :return: A scatter plot - :rtype: matplotlib.figure.Figure + :rtype: matplotlib.figure.Figure | None **Example usage** :: @@ -1166,7 +1178,7 @@ def plot_scatter_wspd(x_wspd_series, y_wspd_series, x_label=None, y_label=None, if y_label is None: y_label = y_wspd_series.name + ' [m/s]' scat_plot = plot_scatter(x_wspd_series, y_wspd_series, x_label=x_label, y_label=y_label, - x_limits=x_limits, y_limits=y_limits, line_of_slope_1=True) + x_limits=x_limits, y_limits=y_limits, line_of_slope_1=True, ax=ax) return scat_plot From c17bd51c293b9f2c5c4a52745613914c0a310732 Mon Sep 17 00:00:00 2001 From: BiancaMorandi <65398058+BiancaMorandi@users.noreply.github.com> Date: Thu, 2 Apr 2026 10:48:16 +0100 Subject: [PATCH 08/11] Iss596 return ax push that didn't happen (#599) * iss #596 changes to allow return of ax * iss #596 update docstring * iss #596 update changelog * iss #596 updated also plot_scatter_wspd, plot_scatter_wdir and speed sort plot_wind_directions to take as input ax * iss #596 changelog fix * missing brackets --------- Co-authored-by: Olivia Bentley Co-authored-by: stephenholleran --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f51bbd..753465da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ XX-Xxx-2026 1. Updated `apply_wspd_slope_offset_adj()` in order to also apply slope and offset adjustments to related columns (for 'avg', 'min', 'max', 'gust', 'median', 'mode', 'range' and 'sd' statistic types) in addition to the main variable itself. It should be noted given lack of required information available no correction is applied to 'ti' 'ti30sec' or 'sum'. ([#575](https://github.com/brightwind-dev/brightwind/issues/575)) 1. Updated `MeasurementStation` function `_mast_section_geometry()` to extract mast section geometry properties. This is called by a user with `MeasurementStation.mast_section_geometry`. ([#362](https://github.com/brightwind-dev/brightwind/issues/362)) 1. Added `export_tws_file()` to allow export of a WindSim .tws formated timeseries climatology file. ([#305](https://github.com/brightwind-dev/brightwind/issues/305)) -1. Updated `plot_scatter()` to accept an `ax` parameter to support use in subplots. ([#596](https://github.com/brightwind-dev/brightwind/issues/596)) +1. Updated `plot_scatter()`, `plot_scatter_wdir()`, `plot_scatter_wspd()`, `CorrelBase.plot()` and `SpeedSort.plot_wind_directions()`, to accept an `ax` parameter to support use in subplots. ([#596](https://github.com/brightwind-dev/brightwind/issues/596)) 1. Updated `scale_air_pressure_to_height()` to accept air temperature and air pressure time series at different heights, the user can now specify a separate height for the reference temperature time series. This is then used to scale the time series to the target height before scaled air pressure is calculated. ([#562](https://github.com/brightwind-dev/brightwind/issues/562)) ### Deprecated From c5daa95f35807ab6fb790a38e73fb5df83350828 Mon Sep 17 00:00:00 2001 From: stephenholleran Date: Thu, 2 Apr 2026 10:54:00 +0100 Subject: [PATCH 09/11] Clean CHANGELOG.md for v2.6.0 release --- CHANGELOG.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 753465da..b4882b3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ Given a version number MAJOR.MINOR.PATCH, increment the: Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format. --- -## [2.6.0-dev] -XX-Xxx-2026 +## [2.6.0] +02-Apr-2026 ### New Features and Enhancements 1. Updated `apply_wspd_slope_offset_adj()` in order to also apply slope and offset adjustments to related columns (for 'avg', 'min', 'max', 'gust', 'median', 'mode', 'range' and 'sd' statistic types) in addition to the main variable itself. It should be noted given lack of required information available no correction is applied to 'ti' 'ti30sec' or 'sum'. ([#575](https://github.com/brightwind-dev/brightwind/issues/575)) @@ -20,11 +20,8 @@ XX-Xxx-2026 1. Updated `plot_scatter()`, `plot_scatter_wdir()`, `plot_scatter_wspd()`, `CorrelBase.plot()` and `SpeedSort.plot_wind_directions()`, to accept an `ax` parameter to support use in subplots. ([#596](https://github.com/brightwind-dev/brightwind/issues/596)) 1. Updated `scale_air_pressure_to_height()` to accept air temperature and air pressure time series at different heights, the user can now specify a separate height for the reference temperature time series. This is then used to scale the time series to the target height before scaled air pressure is calculated. ([#562](https://github.com/brightwind-dev/brightwind/issues/562)) -### Deprecated -1. - ### Bug Fixes -1. Fixed two bugs on `SpeedSort.synthesize()` accounting for NaNs and when direction is 360. ([#406](https://github.com/brightwind-dev/brightwind/issues/406)). +1. Fixed two bugs on `SpeedSort.synthesize()` accounting for NaNs and when direction is 360. ([#406](https://github.com/brightwind-dev/brightwind/issues/406)) --- ## [2.5.0] From 1ad12095401fd985269afc027b2320054ee9fe62 Mon Sep 17 00:00:00 2001 From: stephenholleran Date: Thu, 2 Apr 2026 10:55:18 +0100 Subject: [PATCH 10/11] remove '-dev' for v2.6.0 release --- brightwind/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/brightwind/__init__.py b/brightwind/__init__.py index 78574bdb..bfd7247b 100644 --- a/brightwind/__init__.py +++ b/brightwind/__init__.py @@ -34,4 +34,4 @@ __all__ = ['analyse', 'transform', 'export', 'load', 'demo_datasets'] -__version__ = '2.6.0-dev' +__version__ = '2.6.0' From 93f137907da3bd6396ea1fcdfc797efee679cb83 Mon Sep 17 00:00:00 2001 From: stephenholleran Date: Thu, 2 Apr 2026 12:28:31 +0100 Subject: [PATCH 11/11] update url version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 863b103a..9b17df79 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ def get_version(rel_path): }, url='https://github.com/brightwind-dev/brightwind.git', # UPDATE VERSION NUMBER HERE: - download_url='https://github.com/brightwind-dev/brightwind/archive/v2.5.0.tar.gz', + download_url='https://github.com/brightwind-dev/brightwind/archive/v2.6.0.tar.gz', license='MIT', author='Stephen Holleran of BrightWind Ltd', author_email='stephen@brightwindanalysis.com',