diff --git a/.gitignore b/.gitignore index 27c4cd2..0d9a62c 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,5 @@ output/ /tests/**/*.log /tests/test_data/wedowind/Pitch Angle/ /tests/test_data/wedowind/Vortex Generator/ +# matplotlib testing +**/result_images/ diff --git a/examples/smarteole_example.py b/examples/smarteole_example.py index 3d69181..033bca3 100644 --- a/examples/smarteole_example.py +++ b/examples/smarteole_example.py @@ -296,18 +296,15 @@ def _download_data_from_zenodo(analysis_timebase_s: int, cache_dir: Path, zip_fi return SmarteoleData(scada_df=scada_df, metadata_df=metadata_df, toggle_df=toggle_df) -def main_smarteole_analysis( +def _construct_assessment_inputs( *, smarteole_data: SmarteoleData, + logger: logging.Logger, analysis_timebase_s: int = ANALYSIS_TIMEBASE_S, - check_results: bool = CHECK_RESULTS, analysis_output_dir: Path = ANALYSIS_OUTPUT_DIR, - cache_sub_dir: Path = CACHE_SUBDIR, reanalysis_file_path: Path | str = REANALYSIS_DATA_FILE_PATH, -) -> None: - setup_logger(ANALYSIS_OUTPUT_DIR / "analysis.log") - logger = logging.getLogger(__name__) - + cache_sub_dir: Path = CACHE_SUBDIR, +) -> AssessmentInputs: logger.info("Merging SMV6 yaw offset command signal into SCADA data") toggle_df_no_tz = smarteole_data.toggle_df.copy() toggle_df_no_tz.index = toggle_df_no_tz.index.tz_localize(None) @@ -317,19 +314,19 @@ def main_smarteole_analysis( scada_df["yaw_offset_command"] = scada_df["yaw_offset_command"].where(scada_df["TurbineName"] == "SMV6", 0) del toggle_df_no_tz - logger.info("Loading reference reanalysis data") - reanalysis_dataset = ReanalysisDataset( - id="ERA5T_50.00N_2.75E_100m_1hr", - data=pd.read_parquet(reanalysis_file_path), - ) - logger.info("Defining Assessment Configuration") cfg = define_smarteole_example_config( analysis_timebase_s=analysis_timebase_s, analysis_output_dir=analysis_output_dir ) plot_cfg = PlotConfig(show_plots=False, save_plots=True, plots_dir=cfg.out_dir / "plots") - assessment_inputs = AssessmentInputs.from_cfg( + logger.info("Loading reference reanalysis data") + reanalysis_dataset = ReanalysisDataset( + id="ERA5T_50.00N_2.75E_100m_1hr", + data=pd.read_parquet(reanalysis_file_path), + ) + + return AssessmentInputs.from_cfg( cfg=cfg, plot_cfg=plot_cfg, toggle_df=smarteole_data.toggle_df, @@ -338,6 +335,29 @@ def main_smarteole_analysis( reanalysis_datasets=[reanalysis_dataset], cache_dir=cache_sub_dir, ) + + +def main_smarteole_analysis( + *, + smarteole_data: SmarteoleData, + analysis_timebase_s: int = ANALYSIS_TIMEBASE_S, + check_results: bool = CHECK_RESULTS, + analysis_output_dir: Path = ANALYSIS_OUTPUT_DIR, + cache_sub_dir: Path = CACHE_SUBDIR, + reanalysis_file_path: Path | str = REANALYSIS_DATA_FILE_PATH, +) -> None: + setup_logger(analysis_output_dir / "analysis.log") + logger = logging.getLogger(__name__) + + assessment_inputs = _construct_assessment_inputs( + smarteole_data=smarteole_data, + logger=logger, + analysis_timebase_s=analysis_timebase_s, + analysis_output_dir=analysis_output_dir, + reanalysis_file_path=reanalysis_file_path, + cache_sub_dir=cache_sub_dir, + ) + results_per_test_ref_df = run_wind_up_analysis(assessment_inputs) net_p50, net_p95, net_p5 = calc_net_uplift(results_per_test_ref_df, confidence=0.9) diff --git a/tests/conftest.py b/tests/conftest.py index 364d307..bab2399 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,33 @@ from __future__ import annotations +import logging +import shutil from pathlib import Path +from typing import TYPE_CHECKING import pytest +from examples.smarteole_example import ( + SmarteoleData, + _construct_assessment_inputs, + unpack_smarteole_metadata, + unpack_smarteole_scada, + unpack_smarteole_toggle_data, +) from wind_up.models import WindUpConfig +if TYPE_CHECKING: + from wind_up.interface import AssessmentInputs + TEST_DATA_FLD = Path(__file__).parent / "test_data" TEST_CONFIG_DIR = TEST_DATA_FLD / "config" +SMARTEOLE_DATA_DIR = TEST_DATA_FLD / "smarteole" +SMARTEOLE_CACHE_DIR = Path(__file__).parent / "timebase_600" +SMARTEOLE_OUTPUT_DIR = Path(__file__).parent / "output" + +logger = logging.getLogger(__name__) + @pytest.fixture def test_lsa_t13_config() -> WindUpConfig: @@ -55,3 +74,42 @@ def test_homer_with_t00_config() -> WindUpConfig: cfg.asset.wtgs[-1].latitude = -58.601587635380 cfg.asset.wtgs[-1].longitude = 103.692588907983 return cfg + + +@pytest.fixture(scope="session") +def smarteole_assessment_inputs() -> tuple[AssessmentInputs, SmarteoleData]: + timebase_s = 600 + output_dir = SMARTEOLE_OUTPUT_DIR + output_dir.mkdir(parents=True, exist_ok=True) + cache_subdir = SMARTEOLE_CACHE_DIR + cache_subdir.mkdir(parents=True, exist_ok=True) + + scada_df = unpack_smarteole_scada( + timebase_s=timebase_s, scada_data_file=SMARTEOLE_DATA_DIR / "SMARTEOLE_WakeSteering_SCADA_1minData.csv" + ) + metadata_df = unpack_smarteole_metadata( + timebase_s=timebase_s, metadata_file=SMARTEOLE_DATA_DIR / "SMARTEOLE_WakeSteering_Coordinates_staticData.csv" + ) + toggle_df = unpack_smarteole_toggle_data( + timebase_s=timebase_s, toggle_file=SMARTEOLE_DATA_DIR / "SMARTEOLE_WakeSteering_ControlLog_1minData.csv" + ) + smarteole_data = SmarteoleData(scada_df=scada_df, metadata_df=metadata_df, toggle_df=toggle_df) + + return _construct_assessment_inputs( + smarteole_data=smarteole_data, + logger=logger, + reanalysis_file_path=SMARTEOLE_DATA_DIR / "ERA5T_50.00N_2.75E_100m_1hr_20200201_20200531.parquet", + analysis_timebase_s=timebase_s, + analysis_output_dir=output_dir, + cache_sub_dir=cache_subdir, + ), smarteole_data + + +def pytest_sessionfinish(session, exitstatus) -> None: # noqa: ARG001,ANN001 + # remove directories created during tests + plot_testing = [ + Path(__file__).parents[1] / "result_images", # if pytest run from root dir + ] + for d in [SMARTEOLE_OUTPUT_DIR, SMARTEOLE_CACHE_DIR, *plot_testing]: + if d.is_dir(): + shutil.rmtree(d) diff --git a/tests/plots/baseline_images/test_input_data/input_data_timeline_fig_prepost.png b/tests/plots/baseline_images/test_input_data/input_data_timeline_fig_prepost.png new file mode 100644 index 0000000..6eb8c06 Binary files /dev/null and b/tests/plots/baseline_images/test_input_data/input_data_timeline_fig_prepost.png differ diff --git a/tests/plots/baseline_images/test_input_data/input_data_timeline_fig_toggle.png b/tests/plots/baseline_images/test_input_data/input_data_timeline_fig_toggle.png new file mode 100644 index 0000000..208a8f0 Binary files /dev/null and b/tests/plots/baseline_images/test_input_data/input_data_timeline_fig_toggle.png differ diff --git a/tests/plots/test_input_data.py b/tests/plots/test_input_data.py new file mode 100644 index 0000000..e8c46e5 --- /dev/null +++ b/tests/plots/test_input_data.py @@ -0,0 +1,101 @@ +import copy +import logging +import re + +import numpy as np +import pandas as pd +import pytest +from matplotlib.testing.decorators import image_comparison + +from examples.smarteole_example import SmarteoleData +from wind_up.interface import AssessmentInputs +from wind_up.plots.input_data import plot_input_data_timeline + +logger = logging.getLogger(__name__) + + +class TestInputDataTimeline: + @pytest.mark.slow + @pytest.mark.filterwarnings("ignore") + @pytest.mark.parametrize("exclusion_period_attribute_name", ["yaw_data_exclusions_utc", "exclusion_periods_utc"]) + def test_data_is_present_within_an_exclusion_period( + self, exclusion_period_attribute_name: str, smarteole_assessment_inputs: tuple[AssessmentInputs, SmarteoleData] + ) -> None: + """Test that a ValueError is raised if any non-NaN data is present within any exclusion period.""" + + assessment_inputs = copy.deepcopy(smarteole_assessment_inputs[0]) + + setattr( + assessment_inputs.cfg, + exclusion_period_attribute_name, + [ + ("SMV1", pd.Timestamp("2020-03-01T00:00:00+0000"), pd.Timestamp("2020-03-03T00:00:00+0000")), + ], + ) + + with pytest.raises( + ValueError, + match=re.escape( + "Data is not all NaN within exclusion period " + f"{getattr(assessment_inputs.cfg, exclusion_period_attribute_name)[0]}" + ), + ): + plot_input_data_timeline(assessment_inputs) + + @pytest.mark.slow + @pytest.mark.filterwarnings("ignore") + @image_comparison( + baseline_images=["input_data_timeline_fig_toggle"], remove_text=False, extensions=["png"], style="mpl20" + ) + def test_toggle(self, smarteole_assessment_inputs: tuple[AssessmentInputs, SmarteoleData]) -> None: + """Test plotting timeline of input data on the Smarteole wind farm.""" + + assessment_inputs = copy.deepcopy(smarteole_assessment_inputs[0]) + + assessment_inputs.cfg.yaw_data_exclusions_utc = [ + ("SMV1", pd.Timestamp("2020-03-01T00:00:00+0000"), pd.Timestamp("2020-03-03T00:00:00+0000")), + ("SMV4", pd.Timestamp("2020-04-02T00:00:00+0000"), pd.Timestamp("2020-05-20T00:00:00+0000")), + ] + + assessment_inputs.cfg.exclusion_periods_utc = [ + ("SMV3", pd.Timestamp("2020-04-01T00:00:00+0000"), pd.Timestamp("2020-04-10T00:00:00+0000")), + ("SMV6", pd.Timestamp("2020-03-10T00:00:00+0000"), pd.Timestamp("2020-03-12T00:00:00+0000")), + ] + + for exclusion in [*assessment_inputs.cfg.yaw_data_exclusions_utc, *assessment_inputs.cfg.exclusion_periods_utc]: + turbine_name = exclusion[0] + start, end = exclusion[1], exclusion[2] + assessment_inputs.wf_df.loc[pd.IndexSlice[turbine_name, start:end], "ActivePowerMean"] = np.nan + + plot_input_data_timeline(assessment_inputs) + + @pytest.mark.slow + @pytest.mark.filterwarnings("ignore") + @image_comparison( + baseline_images=["input_data_timeline_fig_prepost"], remove_text=False, extensions=["png"], style="mpl20" + ) + def test_prepost(self, smarteole_assessment_inputs: tuple[AssessmentInputs, SmarteoleData]) -> None: + """Test plotting timeline of input data on the Smarteole wind farm.""" + + assessment_inputs = copy.deepcopy(smarteole_assessment_inputs[0]) + + # manual adjustments to the configuration for the test + assessment_inputs.cfg.toggle = None + assessment_inputs.cfg.upgrade_first_dt_utc_start = pd.Timestamp("2020-03-01T00:00:00+0000") + + assessment_inputs.cfg.yaw_data_exclusions_utc = [ + ("SMV1", pd.Timestamp("2020-03-01T00:00:00+0000"), pd.Timestamp("2020-03-03T00:00:00+0000")), + ("SMV4", pd.Timestamp("2020-04-02T00:00:00+0000"), pd.Timestamp("2020-05-20T00:00:00+0000")), + ] + + assessment_inputs.cfg.exclusion_periods_utc = [ + ("SMV3", pd.Timestamp("2020-04-01T00:00:00+0000"), pd.Timestamp("2020-04-10T00:00:00+0000")), + ("SMV6", pd.Timestamp("2020-03-10T00:00:00+0000"), pd.Timestamp("2020-03-12T00:00:00+0000")), + ] + + for exclusion in [*assessment_inputs.cfg.yaw_data_exclusions_utc, *assessment_inputs.cfg.exclusion_periods_utc]: + turbine_name = exclusion[0] + start, end = exclusion[1], exclusion[2] + assessment_inputs.wf_df.loc[pd.IndexSlice[turbine_name, start:end], "ActivePowerMean"] = np.nan + + plot_input_data_timeline(assessment_inputs) diff --git a/tests/test_smarteole.py b/tests/test_smarteole.py index cf1714a..721aa7f 100644 --- a/tests/test_smarteole.py +++ b/tests/test_smarteole.py @@ -1,45 +1,30 @@ """Tests running the Smarteole dataset through wind-up analysis.""" +import copy from pathlib import Path import pytest -from examples.smarteole_example import ( - SmarteoleData, - main_smarteole_analysis, - unpack_smarteole_metadata, - unpack_smarteole_scada, - unpack_smarteole_toggle_data, -) +from examples.smarteole_example import SmarteoleData, main_smarteole_analysis +from tests.conftest import SMARTEOLE_CACHE_DIR +from wind_up.interface import AssessmentInputs SMARTEOLE_DATA_DIR = Path(__file__).parents[1] / "tests/test_data/smarteole" @pytest.mark.slow @pytest.mark.filterwarnings("ignore") -def test_smarteole_analysis(tmp_path: Path) -> None: +def test_smarteole_analysis( + smarteole_assessment_inputs: tuple[AssessmentInputs, SmarteoleData], tmp_path: Path +) -> None: """Test running the Smarteole analysis.""" - - timebase_s = 600 - cache_subdir = tmp_path / f"timebase_{timebase_s}" - cache_subdir.mkdir(parents=True, exist_ok=True) - - scada_df = unpack_smarteole_scada( - timebase_s=timebase_s, scada_data_file=SMARTEOLE_DATA_DIR / "SMARTEOLE_WakeSteering_SCADA_1minData.csv" - ) - metadata_df = unpack_smarteole_metadata( - timebase_s=timebase_s, metadata_file=SMARTEOLE_DATA_DIR / "SMARTEOLE_WakeSteering_Coordinates_staticData.csv" - ) - toggle_df = unpack_smarteole_toggle_data( - timebase_s=timebase_s, toggle_file=SMARTEOLE_DATA_DIR / "SMARTEOLE_WakeSteering_ControlLog_1minData.csv" - ) - smarteole_data = SmarteoleData(scada_df=scada_df, metadata_df=metadata_df, toggle_df=toggle_df) + smarteole_data = copy.deepcopy(smarteole_assessment_inputs[1]) main_smarteole_analysis( smarteole_data=smarteole_data, reanalysis_file_path=SMARTEOLE_DATA_DIR / "ERA5T_50.00N_2.75E_100m_1hr_20200201_20200531.parquet", - analysis_timebase_s=timebase_s, + analysis_timebase_s=600, check_results=True, # asserts expected results analysis_output_dir=tmp_path, - cache_sub_dir=cache_subdir, + cache_sub_dir=SMARTEOLE_CACHE_DIR, ) diff --git a/wind_up/plots/input_data.py b/wind_up/plots/input_data.py new file mode 100644 index 0000000..440a2ec --- /dev/null +++ b/wind_up/plots/input_data.py @@ -0,0 +1,245 @@ +"""Plot timeline of input data with key milestones and data exclusions.""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING + +import matplotlib.pyplot as plt + +if TYPE_CHECKING: + import datetime as dt + from pathlib import Path + + import pandas as pd + + from wind_up.interface import AssessmentInputs + + +class DateRangeColors(str, Enum): + """Colors for date ranges.""" + + PRE = "#59a89c" + POST = "#7E4794" + ANALYSIS = "#0b81a2" + DETREND = "#9d2c00" + LONG_TERM = "#c8c8c8" + + +def _validate_data_within_exclusions( + exclusions: list[tuple[str, dt.datetime, dt.datetime]], wf_series: pd.DataFrame +) -> None: + _series = wf_series.copy() + for exclusion in exclusions: + mask = _series.index.get_level_values("TurbineName") == exclusion[0] + _data = _series.loc[mask].droplevel(level="TurbineName", axis=0).sort_index() + if _data[exclusion[1] : exclusion[2]].notna().any(): # type: ignore[misc] + _msg = f"Data is not all NaN within exclusion period {exclusion}" + raise ValueError(_msg) + + +def _plot_exclusion( + *, + y_value: int, + y_values: list[int], + turbine_name: str, + name_for_legend: str, + exclusions: list[tuple[str, dt.datetime, dt.datetime]], + trace_format: dict, + ax: plt.Axes, +) -> None: + for _count, exclusion in enumerate(exclusions): + _name_for_legend = {"label": name_for_legend} if _count == 0 else {} + left, right = exclusion[1], exclusion[2] + if exclusion[0] == turbine_name: + ax.barh(y_value, left=left, width=right - left, **trace_format, **_name_for_legend) # type: ignore[arg-type] + elif exclusion[0].lower() == "all": + for y in y_values: + ax.barh(y, left=left, width=right - left, **trace_format, **_name_for_legend) # type: ignore[arg-type] + + +def _plot_data_coverage(*, turbine_series: pd.DataFrame, ax: plt.Axes, y_value: int) -> None: + mask = turbine_series.notna() + column_data = turbine_series.copy() + column_data.loc[mask] = y_value + _label = {"label": f"{turbine_series.name} not NaN"} if y_value == 1 else {} + ax.plot(column_data.index, column_data, color="blue", linewidth=1, **_label) # type: ignore[arg-type] + + +def plot_input_data_timeline( + assessment_inputs: AssessmentInputs, + *, + figsize: tuple[int, int] = (12, 6), + height_ratios: tuple[int, int] = (2, 1), + save_to_folder: Path | None = None, + scada_data_column_for_data_coverage: str = "ActivePowerMean", +) -> plt.Figure: + """Plot timeline of input data with key milestones and data exclusions. + + This function does not do any data filtering itself, but instead only displays the data as it is provided. + + :param assessment_inputs: wind-up configuration and time series data for the assessment + :param figsize: size of the plot figure + :param height_ratios: height ratios for the two subplots + :param save_to_folder: directory in which to save the plot + :param scada_data_column_for_data_coverage: column name in the wind farm DataFrame to use for data coverage plotting + :return: figure object + """ + + _wu_cfg = assessment_inputs.cfg + _df = assessment_inputs.wf_df.copy() + pwr_col = scada_data_column_for_data_coverage + + _validate_data_within_exclusions([*_wu_cfg.yaw_data_exclusions_utc, *_wu_cfg.exclusion_periods_utc], _df[pwr_col]) + + fig, (ax_turbines, ax_wf) = plt.subplots( + ncols=1, nrows=2, sharex=True, figsize=figsize, gridspec_kw={"height_ratios": list(height_ratios)} + ) + + turbines = [t.name for t in _wu_cfg.asset.wtgs] + y_values = list(range(1, len(turbines) + 1)) + + for y_value_count, t in enumerate(turbines): + y_value = y_value_count + 1 + + # turbine data series + turbine_scada_data = (_df.query(f"TurbineName == '{t}'").droplevel(level="TurbineName", axis=0).sort_index())[ + pwr_col + ] + + # data coverage + _plot_data_coverage(turbine_series=turbine_scada_data, ax=ax_turbines, y_value=y_value) + + # yaw exclusions + trace_fmt_yaw = {"height": 0.5, "color": "black", "alpha": 0.5} + _plot_exclusion( + y_value=y_value, + y_values=y_values, + turbine_name=t, + name_for_legend="Yaw Exclusion Period", + exclusions=_wu_cfg.yaw_data_exclusions_utc, + trace_format=trace_fmt_yaw, + ax=ax_turbines, + ) + + # general exclusions + trace_fmt_general = {"height": 0.5, "color": "red", "alpha": 0.5} + _plot_exclusion( + y_value=y_value, + y_values=y_values, + turbine_name=t, + name_for_legend="Exclusion Period", + exclusions=_wu_cfg.exclusion_periods_utc, + trace_format=trace_fmt_general, + ax=ax_turbines, + ) + + # plot wind farm + # -------------- + + _key_dates = [ + _wu_cfg.analysis_first_dt_utc_start, + _wu_cfg.analysis_last_dt_utc_start, + _wu_cfg.detrend_first_dt_utc_start, + _wu_cfg.detrend_last_dt_utc_start, + _wu_cfg.lt_first_dt_utc_start, + _wu_cfg.lt_last_dt_utc_start, + ] + + # extend x-axis to show key dates + x_range_extension = (max(_key_dates) - min(_key_dates)) * 0.05 + x_min = min(_key_dates) - x_range_extension + x_max = max(_key_dates) + x_range_extension + + key_dates_styles = [ + { + "trace_fmt": {"height": 0.5, "color": DateRangeColors.ANALYSIS}, + "label": "Analysis Period", + "left": _wu_cfg.analysis_first_dt_utc_start, + "right": _wu_cfg.analysis_last_dt_utc_start, + }, + { + "trace_fmt": {"height": 0.5, "color": DateRangeColors.DETREND}, + "label": "Detrend Period", + "left": _wu_cfg.detrend_first_dt_utc_start, + "right": _wu_cfg.detrend_last_dt_utc_start, + }, + { + "trace_fmt": {"height": 0.5, "color": DateRangeColors.LONG_TERM}, + "label": "Long Term Period", + "left": _wu_cfg.lt_first_dt_utc_start, + "right": _wu_cfg.lt_last_dt_utc_start, + }, + ] + + if _wu_cfg.toggle is None: + key_dates_styles = [ + *key_dates_styles, + { + "trace_fmt": {"height": 0.5, "color": DateRangeColors.PRE}, + "label": "Pre-Upgrade Period", + "left": _wu_cfg.analysis_first_dt_utc_start, + "right": _wu_cfg.upgrade_first_dt_utc_start, + }, + { + "trace_fmt": {"height": 0.5, "color": DateRangeColors.POST}, + "label": "Post-Upgrade Period", + "left": _wu_cfg.upgrade_first_dt_utc_start, + "right": _wu_cfg.analysis_last_dt_utc_start, + }, + ] + else: + key_dates_styles.append( + { + "trace_fmt": {"height": 0.5, "color": DateRangeColors.PRE}, + "label": "Toggle Period", + "left": _wu_cfg.upgrade_first_dt_utc_start, + "right": _wu_cfg.analysis_last_dt_utc_start, + } + ) + + for y_value, trace_metadata in enumerate(key_dates_styles): + ax_wf.barh( + y_value, + left=trace_metadata["left"], + width=trace_metadata["right"] - trace_metadata["left"], # type: ignore[operator] + label=trace_metadata["label"], + **trace_metadata["trace_fmt"], + ) + + upgrade_date = _wu_cfg.upgrade_first_dt_utc_start + ax_wf.axvline( + x=upgrade_date, # type: ignore[arg-type] + label="Upgrade Date", + color="orange", + linewidth=2, + ) + ax_wf.set_xlim(x_min, x_max) # type: ignore[arg-type] + + # legends + for a in [ax_turbines, ax_wf]: + # Shrink current axis's width by 20% + box = a.get_position() + a.set_position([box.x0, box.y0, box.width * 0.8, box.height]) # type: ignore[arg-type] + a.legend(bbox_to_anchor=(1.04, 1), borderaxespad=0) + + ax_turbines.set_title("Turbine Level") + ax_turbines.set_ylabel("Turbine") + ax_turbines.set_yticks(y_values) + ax_turbines.set_yticklabels(turbines) + ax_turbines.set_ylim(0, len(turbines) + 1) + + ax_wf.set_title("Wind Farm Level") + ax_wf.set_ylim(-1, len(key_dates_styles)) + ax_wf.set_yticks(range(len(key_dates_styles))) + ax_wf.set_yticklabels([d["label"] for d in key_dates_styles]) + ax_wf.set_xlabel("TimeStamp") + + fig.suptitle("Wind-Up Assessment Timeline", fontsize=14) + + if save_to_folder is not None: + if not save_to_folder.is_dir(): + save_to_folder.mkdir(parents=True, exist_ok=True) + fig.savefig(save_to_folder / "input_data_timeline_fig.png") + + return fig