From 71f26eacc26dd78fe62eaec4d610664f4a3b041d Mon Sep 17 00:00:00 2001 From: Samuel Northover-Naylor Date: Thu, 26 Mar 2026 14:33:09 +0000 Subject: [PATCH 1/5] Limit the maximum pre-period end date to upgrade date If the `analysis_last_dt_utc_start` is >1 year post upgrade date then ensure that if the `years_offset_for_pre_period` is specified as 1 year, then the maximum end date of the pre-period is the upgrade date (with 1 day as contingency). --- tests/test_models.py | 32 ++++++++++++++++++++++++++++++++ wind_up/models.py | 4 ++++ 2 files changed, 36 insertions(+) diff --git a/tests/test_models.py b/tests/test_models.py index 2f2ce06..1d3fd54 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,6 +2,7 @@ import datetime as dt import json import logging +import re from pathlib import Path import pandas as pd @@ -214,3 +215,34 @@ def test_with_pre_last_dt_utc_start(self) -> None: assert cfg.upgrade_first_dt_utc_start == pd.Timestamp("2021-09-30 00:00:00+0000", tz="UTC") assert cfg.prepost.post_first_dt_utc_start == pd.Timestamp("2021-09-30 00:00:00+0000", tz="UTC") assert cfg.prepost.post_last_dt_utc_start == pd.Timestamp("2022-07-20 23:50:00+0000", tz="UTC") + + +def test_windupconfig_with_extended_post_period_length() -> None: + """Check that the pre-period does not extend over the upgrade date. + + Check that if the `analysis_last_dt_utc_start` is >1 year post upgrade that if the `years_offset_for_pre_period` is + 1 year, then the maximum end date of the pre-period is the upgrade date (with a days contingency). + """ + # Modify yaml file and then load it to ensure the override works as expected + yaml_path = TEST_CONFIG_DIR / "test_LSA_T13.yaml" + with yaml_path.open() as f: + yaml_str = f.read() + + # Replace the existing line containing "pre_last_dt_utc_start" + analysis_end = "2026-01-01 23:50:00+0000" + yaml_str = re.sub(r"analysis_last_dt_utc_start:.*", f"analysis_last_dt_utc_start: {analysis_end}", yaml_str) + + modified_yaml_path = TEST_CONFIG_DIR / "modified_test_LSA_T13.yaml" + with modified_yaml_path.open("w") as mf: + mf.write(yaml_str) + + cfg = WindUpConfig.from_yaml(modified_yaml_path) + + # delete the modified yaml file after loading the config + modified_yaml_path.unlink() + + assert cfg.prepost.pre_first_dt_utc_start == pd.Timestamp("2020-09-30 00:00:00+0000", tz="UTC") + assert cfg.prepost.pre_last_dt_utc_start == (cfg.upgrade_first_dt_utc_start - pd.Timedelta(days=1)) # key check + assert cfg.upgrade_first_dt_utc_start == pd.Timestamp("2021-09-30 00:00:00+0000", tz="UTC") + assert cfg.prepost.post_first_dt_utc_start == pd.Timestamp("2021-09-30 00:00:00+0000", tz="UTC") + assert cfg.prepost.post_last_dt_utc_start == pd.Timestamp(analysis_end, tz="UTC") diff --git a/wind_up/models.py b/wind_up/models.py index 23ed55a..8f45491 100644 --- a/wind_up/models.py +++ b/wind_up/models.py @@ -417,6 +417,10 @@ def from_yaml(cls, file_path: Path) -> "WindUpConfig": # noqa ANN102 pre_last_dt_utc_start = pd.to_datetime( cfg_dct["analysis_last_dt_utc_start"] - pd.DateOffset(years=cfg_dct["years_offset_for_pre_period"]) ) + if pre_last_dt_utc_start > cfg_dct["upgrade_first_dt_utc_start"]: + pre_last_dt_utc_start = pd.to_datetime(cfg_dct["upgrade_first_dt_utc_start"]) - pd.Timedelta( + days=1 + ) # One day for contingency if pre_last_dt_utc_start.tzinfo is None: pre_last_dt_utc_start = pre_last_dt_utc_start.tz_localize("UTC") pre_post_dict = { From aefdc7ee9fbcb753c120c46af60c67423714d13d Mon Sep 17 00:00:00 2001 From: Samuel Northover-Naylor Date: Thu, 26 Mar 2026 15:39:10 +0000 Subject: [PATCH 2/5] Add date validations for PrePost model --- tests/test_models.py | 59 ++++++++++++++++++++++++++++++++++++++++++++ wind_up/models.py | 32 ++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/tests/test_models.py b/tests/test_models.py index 1d3fd54..0278c5a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,6 +7,7 @@ import pandas as pd import pytest +from pydantic import ValidationError from tests.conftest import TEST_CONFIG_DIR from wind_up.models import PrePost, WindUpConfig @@ -246,3 +247,61 @@ def test_windupconfig_with_extended_post_period_length() -> None: assert cfg.upgrade_first_dt_utc_start == pd.Timestamp("2021-09-30 00:00:00+0000", tz="UTC") assert cfg.prepost.post_first_dt_utc_start == pd.Timestamp("2021-09-30 00:00:00+0000", tz="UTC") assert cfg.prepost.post_last_dt_utc_start == pd.Timestamp(analysis_end, tz="UTC") + + +class TestPrePostValidation: + @pytest.fixture + def valid_dates(self) -> dict[str, dt.datetime]: + return { + "pre_first_dt_utc_start": dt.datetime(2000, 1, 1, tzinfo=dt.timezone.utc), + "pre_last_dt_utc_start": dt.datetime(2000, 1, 15, tzinfo=dt.timezone.utc), + "post_first_dt_utc_start": dt.datetime(2000, 1, 16, tzinfo=dt.timezone.utc), + "post_last_dt_utc_start": dt.datetime(2000, 1, 29, tzinfo=dt.timezone.utc), + } + + def test_valid_prepost(self, valid_dates: dict[str, dt.datetime]) -> None: + PrePost(**valid_dates) + + def test_pre_period_start_after_end_raises(self, valid_dates: dict[str, dt.datetime]) -> None: + valid_dates["pre_first_dt_utc_start"] = dt.datetime(2000, 1, 20, tzinfo=dt.timezone.utc) + with pytest.raises( + ValidationError, match=re.escape("Start date of pre-period must be before the end date of pre-period.") + ): + PrePost(**valid_dates) + + def test_pre_period_equal_start_and_end_is_invalid(self, valid_dates: dict[str, dt.datetime]) -> None: + valid_dates["pre_first_dt_utc_start"] = valid_dates["pre_last_dt_utc_start"] + with pytest.raises( + ValidationError, match=re.escape("Start date of pre-period must be before the end date of pre-period.") + ): + PrePost(**valid_dates) + + def test_post_period_start_after_end_raises(self, valid_dates: dict[str, dt.datetime]) -> None: + valid_dates["post_first_dt_utc_start"] = dt.datetime(2000, 1, 30, tzinfo=dt.timezone.utc) + with pytest.raises( + ValidationError, match=re.escape("Start date of post-period must be before the end date of post-period.") + ): + PrePost(**valid_dates) + + def test_post_period_equal_start_and_end_is_invalid(self, valid_dates: dict[str, dt.datetime]) -> None: + valid_dates["post_first_dt_utc_start"] = valid_dates["post_last_dt_utc_start"] + with pytest.raises( + ValidationError, match=re.escape("Start date of post-period must be before the end date of post-period.") + ): + PrePost(**valid_dates) + + def test_pre_last_after_post_first_raises(self, valid_dates: dict[str, dt.datetime]) -> None: + valid_dates["pre_last_dt_utc_start"] = dt.datetime(2000, 1, 20, tzinfo=dt.timezone.utc) + with pytest.raises( + ValidationError, match=re.escape("End date of pre-period must be before the Start date of post-period.") + ): + PrePost(**valid_dates) + + def test_pre_last_equal_post_first_raises(self, valid_dates: dict[str, dt.datetime]) -> None: + same_dt = dt.datetime(2000, 1, 16, tzinfo=dt.timezone.utc) + valid_dates["pre_last_dt_utc_start"] = same_dt + valid_dates["post_first_dt_utc_start"] = same_dt + with pytest.raises( + ValidationError, match=re.escape("End date of pre-period must be before the Start date of post-period.") + ): + PrePost(**valid_dates) diff --git a/wind_up/models.py b/wind_up/models.py index 8f45491..1567b5c 100644 --- a/wind_up/models.py +++ b/wind_up/models.py @@ -159,6 +159,27 @@ class PrePost(BaseModel): description="Last time to use in post-upgrade analysis, UTC Start format", ) + @model_validator(mode="after") + def _validate_pre_period_dates(self) -> PrePost: + if self.pre_first_dt_utc_start >= self.pre_last_dt_utc_start: + msg = "Start date of pre-period must be before the end date of pre-period." + raise ValueError(msg) + return self + + @model_validator(mode="after") + def _validate_post_period_dates(self) -> PrePost: + if self.post_first_dt_utc_start >= self.post_last_dt_utc_start: + msg = "Start date of post-period must be before the end date of post-period." + raise ValueError(msg) + return self + + @model_validator(mode="after") + def _validate_pre_is_prior_to_post(self) -> PrePost: + if self.pre_last_dt_utc_start >= self.post_first_dt_utc_start: + msg = "End date of pre-period must be before the Start date of post-period." + raise ValueError(msg) + return self + class WindUpConfig(BaseModel): """WindUpConfig model. @@ -319,6 +340,17 @@ class WindUpConfig(BaseModel): ), ) + @model_validator(mode="after") + def _validate_pre_period_is_before_upgrade_date(self: WindUpConfig) -> WindUpConfig: + if ( + (self.toggle is None) + and (self.prepost is not None) + and (self.prepost.pre_last_dt_utc_start >= self.upgrade_first_dt_utc_start) + ): + msg = "pre_last_dt_utc_start must be before upgrade_first_dt_utc_start" + raise ValueError(msg) + return self + @model_validator(mode="after") def _check_years_offset_for_pre_period(self: WindUpConfig) -> WindUpConfig: if self.toggle is None and self.years_offset_for_pre_period is None: From 26a1ad73815765cd0664b75ad65335450f7df14b Mon Sep 17 00:00:00 2001 From: Samuel Northover-Naylor Date: Fri, 27 Mar 2026 10:34:42 +0000 Subject: [PATCH 3/5] PrePost model consistency with from_yaml method --- tests/test_models.py | 6 ++++-- wind_up/models.py | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 0278c5a..a75e7ba 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -10,7 +10,7 @@ from pydantic import ValidationError from tests.conftest import TEST_CONFIG_DIR -from wind_up.models import PrePost, WindUpConfig +from wind_up.models import DEFAULT_TIMEBASE_S, PrePost, WindUpConfig def test_lsa_asset_name(test_lsa_t13_config: WindUpConfig) -> None: @@ -243,7 +243,9 @@ def test_windupconfig_with_extended_post_period_length() -> None: modified_yaml_path.unlink() assert cfg.prepost.pre_first_dt_utc_start == pd.Timestamp("2020-09-30 00:00:00+0000", tz="UTC") - assert cfg.prepost.pre_last_dt_utc_start == (cfg.upgrade_first_dt_utc_start - pd.Timedelta(days=1)) # key check + assert cfg.prepost.pre_last_dt_utc_start == ( + cfg.upgrade_first_dt_utc_start - pd.Timedelta(seconds=DEFAULT_TIMEBASE_S) + ) # key check assert cfg.upgrade_first_dt_utc_start == pd.Timestamp("2021-09-30 00:00:00+0000", tz="UTC") assert cfg.prepost.post_first_dt_utc_start == pd.Timestamp("2021-09-30 00:00:00+0000", tz="UTC") assert cfg.prepost.post_last_dt_utc_start == pd.Timestamp(analysis_end, tz="UTC") diff --git a/wind_up/models.py b/wind_up/models.py index 1567b5c..2d9dd5b 100644 --- a/wind_up/models.py +++ b/wind_up/models.py @@ -19,6 +19,8 @@ logger = logging.getLogger(__name__) +DEFAULT_TIMEBASE_S = 10 * 60 + class PlotConfig(BaseModel): """Plot configuration model.""" @@ -192,7 +194,7 @@ class WindUpConfig(BaseModel): description="Name used for assessment output folder", ) timebase_s: int = Field( - default=10 * 60, + default=DEFAULT_TIMEBASE_S, description="Timebase in seconds for SCADA data, other data is converted to this timebase", ) ignore_turbine_anemometer_data: bool = Field( @@ -451,8 +453,8 @@ def from_yaml(cls, file_path: Path) -> "WindUpConfig": # noqa ANN102 ) if pre_last_dt_utc_start > cfg_dct["upgrade_first_dt_utc_start"]: pre_last_dt_utc_start = pd.to_datetime(cfg_dct["upgrade_first_dt_utc_start"]) - pd.Timedelta( - days=1 - ) # One day for contingency + seconds=cfg_dct.get("timebase_s", DEFAULT_TIMEBASE_S) + ) if pre_last_dt_utc_start.tzinfo is None: pre_last_dt_utc_start = pre_last_dt_utc_start.tz_localize("UTC") pre_post_dict = { From 6aafceb0a28edfc505ec68822265f05f11b297e5 Mon Sep 17 00:00:00 2001 From: Samuel Northover-Naylor Date: Fri, 27 Mar 2026 16:46:03 +0000 Subject: [PATCH 4/5] Walrus checks and clips the datetime threshold --- wind_up/models.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/wind_up/models.py b/wind_up/models.py index 2d9dd5b..b78d132 100644 --- a/wind_up/models.py +++ b/wind_up/models.py @@ -451,10 +451,11 @@ def from_yaml(cls, file_path: Path) -> "WindUpConfig": # noqa ANN102 pre_last_dt_utc_start = pd.to_datetime( cfg_dct["analysis_last_dt_utc_start"] - pd.DateOffset(years=cfg_dct["years_offset_for_pre_period"]) ) - if pre_last_dt_utc_start > cfg_dct["upgrade_first_dt_utc_start"]: - pre_last_dt_utc_start = pd.to_datetime(cfg_dct["upgrade_first_dt_utc_start"]) - pd.Timedelta( - seconds=cfg_dct.get("timebase_s", DEFAULT_TIMEBASE_S) - ) + if pre_last_dt_utc_start > ( + pre_max_threshold := pd.to_datetime(cfg_dct["upgrade_first_dt_utc_start"]) + - pd.Timedelta(seconds=cfg_dct.get("timebase_s", DEFAULT_TIMEBASE_S)) + ): + pre_last_dt_utc_start = pre_max_threshold if pre_last_dt_utc_start.tzinfo is None: pre_last_dt_utc_start = pre_last_dt_utc_start.tz_localize("UTC") pre_post_dict = { From 252625cfb8106319a2f933e2922d1bb9b65f627c Mon Sep 17 00:00:00 2001 From: Alex Clerc Date: Mon, 30 Mar 2026 13:22:26 +0100 Subject: [PATCH 5/5] Apply suggestion from @aclerc --- tests/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_models.py b/tests/test_models.py index a75e7ba..d23dcc4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -222,7 +222,7 @@ def test_windupconfig_with_extended_post_period_length() -> None: """Check that the pre-period does not extend over the upgrade date. Check that if the `analysis_last_dt_utc_start` is >1 year post upgrade that if the `years_offset_for_pre_period` is - 1 year, then the maximum end date of the pre-period is the upgrade date (with a days contingency). + 1 year, then the maximum end date of the pre-period is one timebase before upgrade date. """ # Modify yaml file and then load it to ensure the override works as expected yaml_path = TEST_CONFIG_DIR / "test_LSA_T13.yaml"