-
-
Notifications
You must be signed in to change notification settings - Fork 43
9 various tidy-ups and loud failing #430
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev_feb2026_speedups
Are you sure you want to change the base?
Changes from all commits
4a56094
2ace3c0
7ab3777
d781bc4
ea0ab33
374765a
8674a5e
bdd1199
8ebcfe9
6138c4c
38d82a6
dbfc10f
014ec7b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| """Configuration model for the PVNet dataset.""" | ||
|
|
||
| from collections.abc import Iterator | ||
| from collections.abc import ItemsView, Iterator, KeysView | ||
| from typing import Literal | ||
|
|
||
| from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator | ||
|
|
@@ -63,7 +63,8 @@ class FillValueMixin(Base): | |
|
|
||
| dropout_fill_value: float = Field( | ||
| default=0.0, | ||
| description="The value used to fill in dropped out data or any missing values." | ||
| description="The value used to fill in dropped out data or any missing values. This is " | ||
| "applied after normalisation, so it is in normalised units." | ||
| ) | ||
|
|
||
|
|
||
|
|
@@ -280,13 +281,12 @@ def check_all_channel_have_normalisation_constants(self) -> "NWP": | |
|
|
||
|
|
||
| class MultiNWP(RootModel): | ||
| """Configuration for multiple NWPs.""" | ||
| """Configuration for multiple NWPs. | ||
|
|
||
| root: dict[str, NWP] | ||
| The NWP sources are accessed dict-style - `config.nwp["ukv"]`, not `config.nwp.ukv`. | ||
| """ | ||
|
|
||
| @override | ||
| def __getattr__(self, item: str) -> NWP: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we don't use this and don't need it |
||
| return self.root[item] | ||
| root: dict[str, NWP] | ||
|
|
||
| @override | ||
| def __getitem__(self, item: str) -> NWP: | ||
|
|
@@ -300,11 +300,11 @@ def __len__(self) -> int: | |
| def __iter__(self) -> Iterator: | ||
| return iter(self.root) | ||
|
|
||
| def keys(self) -> Iterator[str]: | ||
| def keys(self) -> KeysView[str]: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the old type hints were wrong |
||
| """Returns dictionary-like keys.""" | ||
| return self.root.keys() | ||
|
|
||
| def items(self) -> Iterator[tuple[str, NWP]]: | ||
| def items(self) -> ItemsView[str, NWP]: | ||
| """Returns dictionary-like items.""" | ||
| return self.root.items() | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,13 +6,22 @@ | |
| class PickleCacheMixin: | ||
| """A mixin for classes that need to cache their state using pickle.""" | ||
|
|
||
| def __init__(self, *args: list, **kwargs: dict) -> None: | ||
| def __init__(self, *args: object, **kwargs: object) -> None: | ||
| """Initialize the pickle path and call the parent constructor.""" | ||
| self._pickle_path = None | ||
| super().__init__(*args, **kwargs) # cooperative multiple inheritance | ||
|
|
||
| def presave_pickle(self, pickle_path: str) -> None: | ||
| """Save the full object state to a pickle file and store the pickle path.""" | ||
| """Save the full object state to a pickle file and store the pickle path. | ||
|
|
||
| The object will be pickled by reference after calling this function, so the | ||
| file must be readable wherever it is unpickled. | ||
|
|
||
| The saved state is a snapshot - call this again after mutating the object. | ||
|
|
||
| Args: | ||
| pickle_path: Where to write the object state to. | ||
| """ | ||
| self._pickle_path = pickle_path | ||
| with open(pickle_path, "wb") as f: | ||
| pickle.dump(self.__dict__, f) | ||
|
|
@@ -22,12 +31,23 @@ def __getstate__(self) -> dict: | |
| if self._pickle_path: | ||
| return {"_pickle_path": self._pickle_path} | ||
| else: | ||
| return self.__dict__ | ||
| # Copied so that the pickled state can't be mutated through the live object | ||
| return dict(self.__dict__) | ||
|
|
||
| def __setstate__(self, state: dict) -> None: | ||
| """Restore object from pickle, reloading from presaved file if possible.""" | ||
| self.__dict__.update(state) | ||
| if self._pickle_path and os.path.exists(self._pickle_path): | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a chance here that this would fail weirdly if the pickled dataset was deleted or unavailable. So we handle this more explicitly now |
||
| with open(self._pickle_path, "rb") as f: | ||
| saved_state = pickle.load(f) # noqa: S301 | ||
| self.__dict__.update(saved_state) | ||
|
|
||
| if not self._pickle_path: | ||
| return | ||
|
|
||
| if not os.path.exists(self._pickle_path): | ||
| raise FileNotFoundError( | ||
| f"Presaved state file not found: {self._pickle_path}. This object was pickled by " | ||
| "reference to that path - see `presave_pickle`.", | ||
| ) | ||
|
|
||
| with open(self._pickle_path, "rb") as f: | ||
| saved_state = pickle.load(f) # noqa: S301 | ||
|
|
||
| self.__dict__.update(saved_state) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -98,8 +98,10 @@ def get_time_periods_mask( | |
| ) -> np.ndarray: | ||
| """Get a boolean mask showing which times fall within any of the specified time periods. | ||
|
|
||
| A `None` bound means the period is unbounded in that direction. | ||
|
|
||
| Args: | ||
| times: DatetimeIndex of times to filter | ||
| times: Array of times to filter | ||
| time_periods: List of tuples specifying the start and end times for each period | ||
| """ | ||
| if len(time_periods)==0: | ||
|
|
@@ -109,10 +111,15 @@ def get_time_periods_mask( | |
|
|
||
| for start_time, end_time in time_periods: | ||
|
|
||
| start_time = times[0] if start_time is None else np.datetime64(start_time) | ||
| end_time = times[-1] if end_time is None else np.datetime64(end_time) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new version is slightly more robust and general since it doesn't rely on One change here that we should have had is to be exclusive of the end point. A common pattern would be This stops both train and val from containing the |
||
| this_period_mask = np.full(len(times), True) | ||
|
|
||
| # Inclusive of start_time, exclusive of end_time | ||
| if start_time is not None: | ||
| this_period_mask &= times >= np.datetime64(start_time) | ||
| if end_time is not None: | ||
| this_period_mask &= times < np.datetime64(end_time) | ||
|
|
||
| mask |= (times >= start_time) & (times <= end_time) | ||
| mask |= this_period_mask | ||
|
|
||
| return mask | ||
|
|
||
|
|
@@ -291,6 +298,8 @@ def __init__( | |
| # Filter t0 times to given range | ||
| if time_periods is not None: | ||
| mask = get_time_periods_mask(valid_t0_times, time_periods) | ||
| if not mask.any(): | ||
| raise ValueError(f"`time_periods` {time_periods} excluded all valid t0 times") | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We raise this here instead of letting the dataset be empty and raise later further away from the source |
||
| valid_t0_times = valid_t0_times[mask] | ||
|
|
||
| self.valid_t0_times = valid_t0_times | ||
|
|
@@ -305,8 +314,10 @@ def __init__( | |
|
|
||
| # Filter t0 times to given range | ||
| if time_periods is not None: | ||
| mask = get_time_periods_mask(valid_t0_and_location_ids["t0"], time_periods) | ||
| valid_t0_and_location_ids = valid_t0_and_location_ids[mask] | ||
| mask = get_time_periods_mask(valid_t0_and_location_ids["t0"].values, time_periods) | ||
| if not mask.any(): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice one! |
||
| raise ValueError(f"`time_periods` {time_periods} excluded all valid t0 times") | ||
| valid_t0_and_location_ids = valid_t0_and_location_ids.iloc[mask] | ||
|
|
||
| self.valid_t0_and_location_ids = valid_t0_and_location_ids | ||
|
|
||
|
|
@@ -362,6 +373,13 @@ def find_valid_t0_times( | |
| valid_time_periods, | ||
| freq=minutes(config.sampling_grid.t0_resolution_minutes), | ||
| ) | ||
|
|
||
| if len(valid_t0_times) == 0: | ||
| raise ValueError( | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This catches a separate error where there are some time periods available but none intersect with the t0 resolution. As in we could have a time period 00:10 -> 00:20 but if we have time resolution 30mins then we get no valid t0s
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Was always silent / empty before right? Good one! |
||
| f"The inputs overlap, but no period is long enough to contain a t0 on the " | ||
| f"{config.sampling_grid.t0_resolution_minutes}-minute grid:\n{valid_time_periods}", | ||
| ) | ||
|
|
||
| return valid_t0_times | ||
|
|
||
| @staticmethod | ||
|
|
@@ -381,15 +399,19 @@ def find_valid_t0_and_location_ids( | |
| locations: The locations to find valid t0 times for | ||
| config: PVNetDataConfig file | ||
| """ | ||
| # Get valid time period for nwp and satellite | ||
| datasets_without_generation = {k: v for k, v in datasets_dict.items() if k != "generation"} | ||
| valid_time_periods = find_valid_time_periods(datasets_without_generation, config) | ||
| # Get valid time periods for inputs other than generation | ||
| non_gen_time_periods = find_valid_time_periods( | ||
| datasets_dict={k: v for k, v in datasets_dict.items() if k != "generation"}, | ||
| config=config, | ||
| ) | ||
|
|
||
| # Loop over each location in system id and obtain valid periods | ||
| # There are separate input and target generation slices | ||
| generation_windows = [ | ||
| w for w in (config.generation.input, config.generation.target) if w is not None | ||
| ] | ||
| valid_t0_and_location_ids = [] | ||
|
|
||
| # Loop over each location in system id and obtain valid periods | ||
| valid_t0_and_location_ids: list[pd.DataFrame] = [] | ||
| for location in locations: | ||
| # Drop NaN values for location | ||
| generation = ( | ||
|
|
@@ -398,8 +420,8 @@ def find_valid_t0_and_location_ids( | |
| .dropna(dim="time_utc") | ||
| ) | ||
|
|
||
| # Obtain valid time periods for this location, for each configured window | ||
| time_periods_per_window = [ | ||
| # Obtain valid time periods for this location for both input and target generation | ||
| gen_time_periods = [ | ||
| find_contiguous_t0_periods( | ||
| generation["time_utc"].values, | ||
| time_resolution=minutes(config.generation.time_resolution_minutes), | ||
|
|
@@ -408,22 +430,29 @@ def find_valid_t0_and_location_ids( | |
| ) | ||
| for window_config in generation_windows | ||
| ] | ||
| valid_time_periods_per_location = intersect_time_periods( | ||
| [valid_time_periods, *time_periods_per_window], | ||
| valid_time_periods = intersect_time_periods( | ||
| [non_gen_time_periods, *gen_time_periods], | ||
| ) | ||
|
|
||
| # Fill out contiguous time periods to get t0 times | ||
| valid_t0_times_per_location = fill_time_periods( | ||
| valid_time_periods_per_location, | ||
| valid_t0_times = fill_time_periods( | ||
| valid_time_periods, | ||
| freq=minutes(config.sampling_grid.t0_resolution_minutes), | ||
| ) | ||
|
|
||
| valid_t0_per_location = pd.DataFrame(index=valid_t0_times_per_location) | ||
| valid_t0_per_location["location_id"] = location.id | ||
| valid_t0_and_location_ids.append(valid_t0_per_location) | ||
| if len(valid_t0_times) == 0: | ||
| logger.warning(f"No valid t0 times found for location {location.id}") | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is good to warn about since the model could silently train without ever seeing this location
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could have been an error but maybe that's too strict |
||
|
|
||
| valid_t0_and_location_ids.append( | ||
| pd.DataFrame({"t0": valid_t0_times, "location_id": location.id}) | ||
| ) | ||
|
|
||
| all_valid_t0_and_location_ids = pd.concat(valid_t0_and_location_ids, ignore_index=True) | ||
|
|
||
| if len(all_valid_t0_and_location_ids) == 0: | ||
| raise ValueError("No valid t0 times found for any location") | ||
|
|
||
| valid_t0_and_location_ids = pd.concat(valid_t0_and_location_ids) | ||
| return valid_t0_and_location_ids.reset_index(names="t0") | ||
| return all_valid_t0_and_location_ids | ||
|
|
||
|
|
||
| class PVNetDataset(AbstractPVNetDataset): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -62,9 +62,6 @@ def find_valid_time_periods( | |
| max_staleness=max_staleness, | ||
| ) | ||
|
|
||
| if len(time_periods) == 0: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not reaching these was a bug, but perhaps we want to reach these. But that would require us to define what |
||
| raise ValueError(f"No valid t0 periods found for {nwp_key} NWP data") | ||
|
|
||
| contiguous_time_periods.append(time_periods) | ||
|
|
||
| if "sat" in datasets_dict: | ||
|
|
@@ -77,14 +74,8 @@ def find_valid_time_periods( | |
|
|
||
| contiguous_time_periods.append(time_periods) | ||
|
|
||
| if len(time_periods) == 0: | ||
| raise ValueError("No valid t0 periods found for satellite data") | ||
|
|
||
| if "generation" in datasets_dict: | ||
| for window_name, window_config in ( | ||
| ("input", config.generation.input), | ||
| ("target", config.generation.target), | ||
| ): | ||
| for window_config in (config.generation.input, config.generation.target): | ||
| if window_config is None: | ||
| continue | ||
|
|
||
|
|
@@ -95,11 +86,6 @@ def find_valid_time_periods( | |
| interval_end=minutes(window_config.interval_end_minutes), | ||
| ) | ||
|
|
||
| if len(time_periods) == 0: | ||
| raise ValueError( | ||
| f"No valid t0 periods found for {window_name} generation data", | ||
| ) | ||
|
|
||
| contiguous_time_periods.append(time_periods) | ||
|
|
||
| # Find joint overlapping contiguous time periods | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,14 +27,16 @@ def encode_datetimes(datetimes: NDArray[np.datetime64]) -> dict[str, NDArray[np. | |
| day_fraction = get_day_fraction(datetimes) | ||
| day_of_year = get_day_of_year(datetimes) | ||
|
|
||
| days_in_year = 365 + get_is_leap_year(datetimes).astype(int) | ||
|
|
||
| time_in_radians = (2 * np.pi) * day_fraction | ||
| date_in_radians = (2 * np.pi) * (day_of_year / 365) | ||
| date_in_radians = (2 * np.pi) * ((day_of_year - 1) / days_in_year) | ||
|
|
||
| return { | ||
| "date_sin": np.sin(date_in_radians), | ||
| "date_cos": np.cos(date_in_radians), | ||
| "time_sin": np.sin(time_in_radians), | ||
| "time_cos": np.cos(time_in_radians), | ||
| "date_sin": np.sin(date_in_radians).astype(np.float32), | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These were returning float64 which is not what the function signature advertises. We also cast to float32 for the TensorBatch passed to the model |
||
| "date_cos": np.cos(date_in_radians).astype(np.float32), | ||
| "time_sin": np.sin(time_in_radians).astype(np.float32), | ||
| "time_cos": np.cos(time_in_radians).astype(np.float32), | ||
| } | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
|
|
||
| import numpy as np | ||
| import xarray as xr | ||
| from numpy.typing import NDArray | ||
|
|
||
| from ocf_data_sampler.common.indexing import assert_values_unique_increasing | ||
| from ocf_data_sampler.common.xr_tensorstore import ZarrSource, open_zarr_paths | ||
|
|
@@ -58,6 +59,7 @@ def open_nwp(zarr_path: ZarrSource, provider: str) -> xr.DataArray: | |
|
|
||
| assert_values_unique_increasing(ds["init_time_utc"].values, "init_time_utc") | ||
| assert_values_unique_increasing(ds["step"].values, "step") | ||
| _assert_steps_uniformly_spaced(ds["step"].values, provider) | ||
|
|
||
| da = extract_single_data_array(ds) | ||
| da = da.transpose("init_time_utc", "step", "channel", x_coord, y_coord) | ||
|
|
@@ -68,6 +70,22 @@ def open_nwp(zarr_path: ZarrSource, provider: str) -> xr.DataArray: | |
| return da | ||
|
|
||
|
|
||
| def _assert_steps_uniformly_spaced(steps: NDArray[np.timedelta64], provider: str) -> None: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will cause a hard fail with uneven steps say for MO right? |
||
| """Assert that the forecast steps are evenly spaced. | ||
|
|
||
| Args: | ||
| steps: The forecast steps, already checked to be unique and increasing. | ||
| provider: The NWP provider name, used in the error message. | ||
| """ | ||
| spacings = np.unique(np.diff(steps)) | ||
|
|
||
| if len(spacings) > 1: | ||
| raise ValueError( | ||
| f"NWP steps for provider {provider!r} must be evenly spaced, but the steps are " | ||
| f"spaced by {spacings}. Mixed step spacing is not supported.", | ||
| ) | ||
|
|
||
|
|
||
| def _rename(ds: xr.Dataset, name_mapping: Mapping[str, str]) -> xr.Dataset: | ||
| """Renames coordinates in the dataset based on the provided mapping. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -166,6 +166,9 @@ def select_spatial_slice_pixels_multiple( | |
| Returns: | ||
| The selected DataArray-like slice. | ||
| """ | ||
| if len(locations) == 0: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For any window inversion errors right? |
||
| raise ValueError("`locations` is empty - there is no region to cover") | ||
|
|
||
| target_coords, x_dim, y_dim = find_coord_system(da) | ||
|
|
||
| x_values = da[x_dim].values | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -150,6 +150,14 @@ def find_contiguous_t0_periods_nwp( | |
| else: | ||
| init_end_timedelta = min(last_forecast_step - interval_end, max_staleness) | ||
|
|
||
| # Each init-time is unusable before init_start_timedelta and after init_end_timedelta. If they | ||
| # are the wrong way round then no t0 can ever use it, and every period below will be empty | ||
| if init_end_timedelta < init_start_timedelta: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Solid one, clear flagging |
||
| raise ValueError( | ||
| f"Init-times are usable from {init_start_timedelta} after init-time, but only until " | ||
| f"{init_end_timedelta}, so no t0s are available.", | ||
| ) | ||
|
|
||
| # Store contiguous periods | ||
| contiguous_periods: list[list[np.datetime64]] = [] | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was out of date