From 3deb84dbe1742c022bc7cb091ad4e91f2684593c Mon Sep 17 00:00:00 2001 From: James Fulton Date: Tue, 16 Jan 2024 14:23:27 +0000 Subject: [PATCH 01/27] extend ECMWF for shetlands --- ocf_datapipes/load/nwp/providers/ecmwf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ocf_datapipes/load/nwp/providers/ecmwf.py b/ocf_datapipes/load/nwp/providers/ecmwf.py index a4997b8e9..a2aac5793 100644 --- a/ocf_datapipes/load/nwp/providers/ecmwf.py +++ b/ocf_datapipes/load/nwp/providers/ecmwf.py @@ -1,6 +1,7 @@ """ECMWF provider loaders""" import pandas as pd import xarray as xr +import numpy as np from ocf_datapipes.load.nwp.providers.utils import open_zarr_paths @@ -17,6 +18,7 @@ def open_ifs(zarr_path) -> xr.DataArray: """ # Open the data nwp = open_zarr_paths(zarr_path) + nwp = nwp.reindex(latitude=np.concatenate([np.arange(62, 60, -0.05), nwp.latitude.values])) dataVars = list(nwp.data_vars.keys()) if len(dataVars) > 1: raise Exception("Too many TLDVs") From c2826338118e2dadbf05093a30be99aed52e36d1 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Fri, 16 Feb 2024 10:54:21 +0000 Subject: [PATCH 02/27] optional list of dropouts --- ocf_datapipes/config/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ocf_datapipes/config/model.py b/ocf_datapipes/config/model.py index 21c1170d6..a737ce36a 100644 --- a/ocf_datapipes/config/model.py +++ b/ocf_datapipes/config/model.py @@ -156,7 +156,7 @@ def dropout_fraction_valid(cls, v): class SystemDropoutMixin(Base): """Mixin class, to add independent system dropout""" - system_dropout_timedeltas_minutes: List[int] = Field( + system_dropout_timedeltas_minutes: Optional[List[int]] = Field( None, description="List of possible minutes before t0 where data availability may start. Must be " "negative or zero. Each system in a sample is delayed independently from the other by " From 70c788b037eda965bbcbd7e927499d989b245ba6 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Mon, 15 Apr 2024 16:18:49 +0000 Subject: [PATCH 03/27] add messy version of datapipe --- ocf_datapipes/training/pvnet_all_gsp.py | 697 ++++++++++++++++++++++++ 1 file changed, 697 insertions(+) create mode 100644 ocf_datapipes/training/pvnet_all_gsp.py diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py new file mode 100644 index 000000000..4523b360e --- /dev/null +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -0,0 +1,697 @@ +"""Create the training/validation datapipe for training the PVNet Model""" +import logging +from typing import Optional, Type, Tuple, List, Union +from datetime import datetime + +import xarray as xr +from torch.utils.data.datapipes.datapipe import IterDataPipe +from torch.utils.data.datapipes._decorator import functional_datapipe +from torch.utils.data.datapipes.iter import IterableWrapper +from ocf_datapipes.convert import ( + ConvertNWPToNumpyBatch, + ConvertPVToNumpyBatch, + ConvertSatelliteToNumpyBatch, + ConvertGSPToNumpyBatch, + +) + +from ocf_datapipes.batch import MergeNumpyModalities, MergeNWPNumpyModalities +from ocf_datapipes.batch.merge_numpy_examples_to_batch import stack_np_examples_into_batch +from ocf_datapipes.training.common import ( + _get_datapipes_dict, + check_nans_in_satellite_data, + concat_xr_time_utc, + fill_nans_in_arrays, + fill_nans_in_pv, + normalize_gsp, + normalize_pv, + slice_datapipes_by_time, + minutes, + open_and_return_datapipes, +) +from ocf_datapipes.utils.consts import ( + NWP_MEANS, + NWP_STDS, + RSS_MEAN, + RSS_STD, +) +import numpy as np + +from ocf_datapipes.config.model import Configuration +from ocf_datapipes.utils import Location + +from ocf_datapipes.utils.geospatial import ( + move_lon_lat_by_meters, + spatial_coord_type, +) +from ocf_datapipes.select.select_spatial_slice import ( + _get_idx_of_pixel_closest_to_poi, + _get_idx_of_pixel_closest_to_poi_geostationary, + _get_points_from_unstructured_grids, + convert_coords_to_match_xarray, + select_spatial_slice_pixels, +) + + +xr.set_options(keep_attrs=True) +logger = logging.getLogger("pvnet_all_gsp_datapipe") + + +class SampleFunction: + def __init__(self, function): + self.function = function + + def __call__(self, sample_list): + return [self.function(sample) for sample in sample_list] + +class ZipFunction: + def __init__(self, function): + self.function = function + + def __call__(self, zipped_sample_list): + sample_lists = [sample_list for sample_list in zipped_sample_list] + return [self.function(sample) for sample in zip(*sample_lists)] + + +class SampleRepeat: + def __init__(self, num_repeats): + self.num_repeats = num_repeats + + def __call__(self, x): + return [x for _ in range(self.num_repeats)] + + +class GSPLocationLookup: + """Query object for GSP location from GSP ID""" + + def __init__(self, x_osgb: xr.DataArray, y_osgb: xr.DataArray): + """Query object for GSP location from GSP ID + + Args: + x_osgb: DataArray of the OSGB x-coordinate for any given GSP ID + y_osgb: DataArray of the OSGB y-coordinate for any given GSP ID + + """ + self.x_osgb = x_osgb + self.y_osgb = y_osgb + + def __call__(self, gsp_id: int) -> Location: + """Returns the locations for the input GSP IDs. + + Args: + gsp_id: Integer ID of the GSP + """ + return Location( + x=self.x_osgb.sel(gsp_id=gsp_id).item(), + y=self.y_osgb.sel(gsp_id=gsp_id).item(), + id=gsp_id, + ) + + + +def create_t0_datapipe( + datapipes_dict: dict, + configuration: Configuration, + shuffle: bool = True, +): + """ + Takes source datapipes and returns datapipes of appropriate t0 times. + + The t0 times are sampled without replacement. + + Args: + datapipes_dict: Dictionary of datapipes of input sources for which we want to select + appropriate location and times. + configuration: Configuration object for inputs. + shuffle: Whether to use the internal shuffle function when yielding location times. Else + location times will be heavily ordered. + + Returns: + location datapipe, t0 datapipe + + """ + assert "gsp" in datapipes_dict + + contiguous_time_datapipes = [] # Used to store contiguous time periods from each data source + + datapipes_dict["gsp"], key_datapipe = datapipes_dict["gsp"].fork(2, buffer_size=5) + + for key in datapipes_dict.keys(): + if key in ["topo"]: + continue + + elif key == "nwp": + for nwp_key in datapipes_dict["nwp"].keys(): + # NWPs are nested since there can be multiple NWP sources + datapipes_dict["nwp"][nwp_key], datapipe_copy = datapipes_dict["nwp"][nwp_key].fork( + 2, buffer_size=5 + ) + + # Different config setting per NWP source + nwp_conf = configuration.input_data.nwp[nwp_key] + + if nwp_conf.dropout_timedeltas_minutes is None: + max_dropout = minutes(0) + else: + max_dropout = minutes(int(np.max(np.abs(nwp_conf.dropout_timedeltas_minutes)))) + + if nwp_conf.max_staleness_minutes is None: + max_staleness = None + else: + max_staleness = minutes(nwp_conf.max_staleness_minutes) + + # NWP is a forecast product so gets its own contiguous function + time_periods = datapipe_copy.find_contiguous_t0_time_periods_nwp( + history_duration=minutes(nwp_conf.history_minutes), + forecast_duration=minutes(nwp_conf.forecast_minutes), + max_staleness=max_staleness, + max_dropout=max_dropout, + time_dim="init_time_utc", + ) + + contiguous_time_datapipes.append(time_periods) + + else: + if key == "sat": + sample_frequency = configuration.input_data.satellite.time_resolution_minutes + history_duration = configuration.input_data.satellite.history_minutes + forecast_duration = 0 + time_dim = "time_utc" + + elif key == "hrv": + sample_frequency = configuration.input_data.hrvsatellite.time_resolution_minutes + history_duration = configuration.input_data.hrvsatellite.history_minutes + forecast_duration = 0 + time_dim = "time_utc" + + elif key == "pv": + sample_frequency = configuration.input_data.pv.time_resolution_minutes + history_duration = configuration.input_data.pv.history_minutes + forecast_duration = configuration.input_data.pv.forecast_minutes + time_dim = "time_utc" + + elif key == "wind": + sample_frequency = configuration.input_data.wind.time_resolution_minutes + history_duration = configuration.input_data.wind.history_minutes + forecast_duration = configuration.input_data.wind.forecast_minutes + time_dim = "time_utc" + + elif key == "sensor": + sample_frequency = configuration.input_data.sensor.time_resolution_minutes + history_duration = configuration.input_data.sensor.history_minutes + forecast_duration = configuration.input_data.sensor.forecast_minutes + time_dim = "time_utc" + + elif key == "gsp": + sample_frequency = configuration.input_data.gsp.time_resolution_minutes + history_duration = configuration.input_data.gsp.history_minutes + forecast_duration = configuration.input_data.gsp.forecast_minutes + time_dim = "time_utc" + + else: + raise ValueError(f"Unexpected key: {key}") + + datapipes_dict[key], datapipe_copy = datapipes_dict[key].fork(2, buffer_size=5) + + time_periods = datapipe_copy.find_contiguous_t0_time_periods( + sample_period_duration=minutes(sample_frequency), + history_duration=minutes(history_duration), + forecast_duration=minutes(forecast_duration), + time_dim=time_dim, + ) + + contiguous_time_datapipes.append(time_periods) + + # Find joint overlapping contiguous time periods + if len(contiguous_time_datapipes) > 1: + logger.debug("Getting joint time periods") + overlapping_datapipe = contiguous_time_datapipes[0].filter_to_overlapping_time_periods( + secondary_datapipes=contiguous_time_datapipes[1:], + ) + else: + logger.debug("Skipping getting joint time periods") + overlapping_datapipe = contiguous_time_datapipes[0] + + # Select time periods and set length + key_datapipe = key_datapipe.filter_time_periods(time_periods=overlapping_datapipe) + + t0_datapipe = key_datapipe.pick_t0_times()#return_all=True, shuffle=shuffle) + + + return t0_datapipe + + +def construct_time_pipeline( + config_filename: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, +) -> Tuple[IterDataPipe, IterDataPipe]: + """Construct time pipeline for the input data config file. + + Args: + config_filename: Path to config file. + start_time: Minimum time for time datapipe. + end_time: Maximum time for time datapipe. + """ + + datapipes_dict = _get_datapipes_dict( + config_filename, + ) + + # Pull out config file + config = datapipes_dict.pop("config") + + if (start_time is not None) or (end_time is not None): + datapipes_dict["gsp"] = datapipes_dict["gsp"].filter_times(start_time, end_time) + + # Get overlapping time periods + t0_datapipe = create_t0_datapipe( + datapipes_dict, + configuration=config, + shuffle=True, + ) + + return t0_datapipe + + +def xr_compute(xr_data): + return xr_data.compute() + + +@functional_datapipe("select_all_gsp_spatial_slices_pixels") +class SelectAllGSPSpatialSlicePixelsIterDataPipe(IterDataPipe): + """Select all the spatial slices""" + + def __init__( + self, + source_datapipe: IterDataPipe, + locations: List[Location], + roi_height_pixels: int, + roi_width_pixels: int, + allow_partial_slice: bool = False, + location_idx_name: Optional[str] = None, + ): + """ + Select spatial slice based off pixels from point of interest + + If `allow_partial_slice` is set to True, then slices may be made which intersect the border + of the input data. The additional x and y cordinates that would be required for this slice + are extrapolated based on the average spacing of these coordinates in the input data. + However, currently slices cannot be made where the centre of the window is outside of the + input data. + + Args: + source_datapipe: Datapipe of Xarray data + roi_height_pixels: ROI height in pixels + roi_width_pixels: ROI width in pixels + allow_partial_slice: Whether to allow a partial slice. + location_idx_name: Name for location index of unstructured grid data, + None if not relevant + """ + self.source_datapipe = source_datapipe + self.locations = locations + self.roi_height_pixels = roi_height_pixels + self.roi_width_pixels = roi_width_pixels + self.allow_partial_slice = allow_partial_slice + self.location_idx_name = location_idx_name + + def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: + for xr_data in self.source_datapipe: + xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) + + loc_slices = [] + + for location in self.locations: + + if self.location_idx_name is not None: + selected = _get_points_from_unstructured_grids( + xr_data=xr_data, + location=location, + location_idx_name=self.location_idx_name, + num_points=self.roi_width_pixels * self.roi_height_pixels, + ) + yield selected + + if xr_coords == "geostationary": + center_idx: Location = _get_idx_of_pixel_closest_to_poi_geostationary( + xr_data=xr_data, + center_osgb=location, + ) + else: + center_idx: Location = _get_idx_of_pixel_closest_to_poi( + xr_data=xr_data, + location=location, + ) + + selected = select_spatial_slice_pixels( + xr_data, + center_idx, + self.roi_width_pixels, + self.roi_height_pixels, + xr_x_dim, + xr_y_dim, + allow_partial_slice=self.allow_partial_slice, + ) + + loc_slices.append(selected) + + yield loc_slices + + + +@functional_datapipe("select_all_gsp_spatial_slice_meters") +class SelectAllGSPSpatialSliceMetersIterDataPipe(IterDataPipe): + """Select spatial slice based off meters from point of interest""" + + def __init__( + self, + source_datapipe: IterDataPipe, + locations: List[Location], + roi_height_meters: int, + roi_width_meters: int, + dim_name: Optional[str] = None, # "pv_system_id", + ): + """ + Select spatial slice based off pixels from point of interest + + Args: + source_datapipe: Datapipe of Xarray data + location_datapipe: Location datapipe + roi_height_meters: ROI height in meters + roi_width_meters: ROI width in meters + dim_name: Dimension name to select for ID, None for coordinates + + Notes: + Using spatial slicing based on distance rather than number of pixels will often yield + slices which can vary by 1 pixel in height and/or width. + + E.g. Suppose the Xarray data has x-coords = [1,2,3,4,5]. We want to slice a spatial + window with a size which equates to 2.2 along the x-axis. If we choose to slice around + the point x=3 this will slice out the x-coords [2,3,4]. If we choose to slice around the + point x=2.5 this will slice out the x-coords [2,3]. Hence the returned slice can have + size either 2 or 3 in the x-axis depending on the spatial location selected. + + Also, if selecting over a large span of latitudes, this may also causes pixel sizes of + the yielded outputs to change. For example, if the Xarray data is on a regularly spaced + longitude-latitude grid, then the structure of the grid means that the longitudes near + to the poles are spaced closer together (measured in meters) than at the equator. So + slices near the equator will have less pixels in the x-axis than slices taken near the + poles. + """ + self.source_datapipe = source_datapipe + self.locations = locations + self.roi_height_meters = roi_height_meters + self.roi_width_meters = roi_width_meters + self.dim_name = dim_name + + def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: + for xr_data in self.source_datapipe: + loc_slices = [] + + for location in self.locations: + + # Get the spatial coords of the xarray data + xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) + + half_height = self.roi_height_meters // 2 + half_width = self.roi_width_meters // 2 + + # Find the bounding box values for the location in either lat-lon or OSGB coord systems + if location.coordinate_system == "lon_lat": + right, top = move_lon_lat_by_meters( + location.x, + location.y, + half_width, + half_height, + ) + left, bottom = move_lon_lat_by_meters( + location.x, + location.y, + -half_width, + -half_height, + ) + + elif location.coordinate_system == "osgb": + left = location.x - half_width + right = location.x + half_width + bottom = location.y - half_height + top = location.y + half_height + + else: + raise ValueError( + f"Location coord system not recognized: {location.coordinate_system}" + ) + + # Change the bounding coordinates [left, right, bottom, top] to the same + # coordinate system as the xarray data + (left, right), (bottom, top) = convert_coords_to_match_xarray( + x=np.array([left, right], dtype=np.float32), + y=np.array([bottom, top], dtype=np.float32), + from_coords=location.coordinate_system, + xr_data=xr_data, + ) + + # Do it off coordinates, not ID + if self.dim_name is None: + # Select a patch from the xarray data + x_mask = (left <= xr_data[xr_x_dim]) & (xr_data[xr_x_dim] <= right) + y_mask = (bottom <= xr_data[xr_y_dim]) & (xr_data[xr_y_dim] <= top) + selected = xr_data.isel({xr_x_dim: x_mask, xr_y_dim: y_mask}) + + else: + # Select data in the region of interest and ID: + # This also works for unstructured grids + + id_mask = ( + (left <= xr_data[xr_x_dim]) + & (xr_data[xr_x_dim] <= right) + & (bottom <= xr_data[xr_y_dim]) + & (xr_data[xr_y_dim] <= top) + ) + selected = xr_data.isel({self.dim_name: id_mask}) + + loc_slices.append(selected) + yield loc_slices + + +class ConvertWrapper(IterDataPipe): + def __init__( + self, + source_datapipe: IterDataPipe, + convert_class: Type[IterDataPipe] + ): + self.source_datapipe = source_datapipe + self.convert_class = convert_class + + def __iter__(self): + for concurrent_samples in self.source_datapipe: + dp = self.convert_class(IterableWrapper(concurrent_samples)) + stacked_converted_values = stack_np_examples_into_batch([x for x in iter(dp)]) + yield stacked_converted_values + + + + +def construct_sliced_data_pipeline( + config_filename: str, + t0_datapipe: IterDataPipe, + production: bool = False, + check_satellite_no_zeros: bool = False, +) -> IterDataPipe: + """Constructs data pipeline for the input data config file. + + This yields samples from the location and time datapipes. + + Args: + config_filename: Path to config file. + t0_datapipe: Datapipe yielding times. + production: Whether constucting pipeline for production inference. + check_satellite_no_zeros: Whether to check that satellite data has no zeros. + """ + + datapipes_dict = _get_datapipes_dict( + config_filename, + production=production, + ) + + ds_gsp = next( + iter( + open_and_return_datapipes( + config_filename, + use_gsp=True, + use_nwp=False, + use_pv=False, + use_sat=False, + use_hrv=False, + use_topo=False, + )["gsp"] + ) + ) + + gsp_id_to_loc = GSPLocationLookup(ds_gsp.x_osgb, ds_gsp.y_osgb) + + locations = [gsp_id_to_loc(gsp_id) for gsp_id in range(1, 318)] + + + configuration = datapipes_dict.pop("config") + + # Unpack for convenience + conf_sat = configuration.input_data.satellite + conf_nwp = configuration.input_data.nwp + + # Slice all of the datasets by time - this is an in-place operation + slice_datapipes_by_time(datapipes_dict, t0_datapipe, configuration, production) + + # Spatially slice, normalize, and convert data to numpy arrays + numpy_modalities = [] + + if "nwp" in datapipes_dict: + nwp_numpy_modalities = dict() + + for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): + + nwp_datapipe = nwp_datapipe.map(xr_compute) + + nwp_datapipe = nwp_datapipe.normalize( + mean=NWP_MEANS[conf_nwp[nwp_key].nwp_provider], + std=NWP_STDS[conf_nwp[nwp_key].nwp_provider], + ) + + nwp_datapipe = nwp_datapipe.select_all_gsp_spatial_slices_pixels( + locations, + roi_height_pixels=conf_nwp[nwp_key].nwp_image_size_pixels_height, + roi_width_pixels=conf_nwp[nwp_key].nwp_image_size_pixels_width, + ) + + + nwp_numpy_modalities[nwp_key] = ConvertWrapper( + nwp_datapipe, + ConvertNWPToNumpyBatch, + ) + + # Combine the NWPs into NumpyBatch + nwp_numpy_modalities = MergeNWPNumpyModalities(nwp_numpy_modalities) + numpy_modalities.append(nwp_numpy_modalities) + + if "sat" in datapipes_dict: + sat_datapipe = datapipes_dict["sat"] + + sat_datapipe = sat_datapipe.map(xr_compute) + + sat_datapipe = sat_datapipe.normalize(mean=RSS_MEAN, std=RSS_STD) + + sat_datapipe = sat_datapipe.select_all_gsp_spatial_slices_pixels( + locations, + roi_height_pixels=conf_sat.satellite_image_size_pixels_height, + roi_width_pixels=conf_sat.satellite_image_size_pixels_width, + ) + + + numpy_modalities.append( + ConvertWrapper( + sat_datapipe, + ConvertSatelliteToNumpyBatch, + ) + ) + + if "pv" in datapipes_dict: + # Recombine PV arrays - see function doc for further explanation + # No spatial slice for PV since it is always the same + pv_datapipe = ( + datapipes_dict["pv"] + .zip_ocf(datapipes_dict["pv_future"]) + .map(concat_xr_time_utc) + ) + + pv_datapipe = pv_datapipe.map(normalize_pv) + pv_datapipe = pv_datapipe.map(fill_nans_in_pv) + pv_datapipe = pv_datapipe.map(SampleRepeat(317)) + + numpy_modalities.append( + ConvertWrapper( + pv_datapipe, + ConvertPVToNumpyBatch, + ) + ) + + # GSP always assumed to be in data + #location_pipe, location_pipe_copy = location_pipe.fork(2, buffer_size=5) + gsp_future_datapipe = datapipes_dict["gsp_future"] + gsp_future_datapipe = gsp_future_datapipe.select_all_gsp_spatial_slice_meters( + locations, + roi_height_meters=1, + roi_width_meters=1, + dim_name="gsp_id", + ) + + gsp_datapipe = datapipes_dict["gsp"] + gsp_datapipe = gsp_datapipe.select_all_gsp_spatial_slice_meters( + locations, + roi_height_meters=1, + roi_width_meters=1, + dim_name="gsp_id", + ) + + # Recombine GSP arrays - see function doc for further explanation + gsp_datapipe = ( + gsp_datapipe + .zip_ocf(gsp_future_datapipe) + .map(ZipFunction(concat_xr_time_utc)) + .map(SampleFunction(normalize_gsp)) + ) + + numpy_modalities.append( + ConvertWrapper( + gsp_datapipe, + ConvertGSPToNumpyBatch, + ) + ) + + logger.debug("Combine all the data sources") + combined_datapipe = MergeNumpyModalities(numpy_modalities).add_sun_position(modality_name="gsp") + + return combined_datapipe + + +def pvnet_all_gsp_datapipe( + config_filename: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, +) -> IterDataPipe: + """ + Construct pvnet pipeline for the input data config file. + + Args: + config_filename: Path to config file. + start_time: Minimum time at which a sample can be selected. + end_time: Maximum time at which a sample can be selected. + """ + logger.info("Constructing pvnet pipeline") + + # Open datasets from the config and filter to useable location-time pairs + t0_datapipe = construct_time_pipeline( + config_filename, + start_time, + end_time, + ) + + # In this function we re-open the datasets to make a clean separation before/after sharding + # This function + datapipe = construct_sliced_data_pipeline( + config_filename, + t0_datapipe, + ) + + return datapipe + + +if __name__=="__main__": + import time + + t0 = time.time() + dp = pvnet_all_gsp_datapipe( + config_filename="/home/jamesfulton/repos/PVNet/configs/datamodule/configuration/gcp_configuration.yaml" + ) + + b = next(iter(dp)) + print(time.time() - t0) \ No newline at end of file From 08350f25b443359891b2ab973c2ffdb1367a855e Mon Sep 17 00:00:00 2001 From: James Fulton Date: Mon, 15 Apr 2024 19:01:29 +0000 Subject: [PATCH 04/27] standardize pick functions --- ocf_datapipes/select/pick_locations.py | 104 +++++++++++------- .../select/pick_locations_and_t0_times.py | 53 +++++---- ocf_datapipes/select/pick_t0_times.py | 50 ++++++--- 3 files changed, 129 insertions(+), 78 deletions(-) diff --git a/ocf_datapipes/select/pick_locations.py b/ocf_datapipes/select/pick_locations.py index 591da8254..5629a2a65 100644 --- a/ocf_datapipes/select/pick_locations.py +++ b/ocf_datapipes/select/pick_locations.py @@ -17,51 +17,79 @@ class PickLocationsIterDataPipe(IterDataPipe): def __init__( self, source_datapipe: IterDataPipe, - return_all_locations: bool = False, + return_all: bool = False, + shuffle: bool = False, ): """ - Picks random locations from a dataset + Datapipe to yield locations from the input data source. Args: source_datapipe: Datapipe emitting Xarray Dataset - return_all_locations: Whether to return all locations, - if True, also returns them in order + return_all: Whether to return all t0-location pairs, + if True, also returns them in structured order + shuffle: If `return_all` is True this sets whether the pairs are + shuffled before being returned. """ super().__init__() self.source_datapipe = source_datapipe - self.return_all_locations = return_all_locations + self.return_all = return_all + self.shuffle = shuffle + + def _yield_all_iter(self, xr_dataset): + + # Get the spatial coords + xr_coord_system, xr_x_dim, xr_y_dim = spatial_coord_type(xr_dataset) + + loc_indices = np.arange(len(xr_dataset[xr_x_dim])) + + if self.shuffle: + loc_indices = np.random.permutation(loc_indices) + + # Iterate through all locations in dataset + for loc_index in loc_indices: + + # Get the location ID + loc_id = None + for id_dim_name in ["pv_system_id", "gsp_id", "station_id"]: + if id_dim_name in xr_dataset.coords.keys(): + loc_id = int(xr_dataset[id_dim_name][loc_index].values) + + location = Location( + coordinate_system=xr_coord_system, + x=xr_dataset[xr_x_dim][loc_index].values, + y=xr_dataset[xr_y_dim][loc_index].values, + id=loc_id, + ) + + yield location + + def _yield_random_iter(self, xr_dataset): + + # Get the spatial coords + xr_coord_system, xr_x_dim, xr_y_dim = spatial_coord_type(xr_dataset) + + while True: + loc_index = np.random.randint(0, len(xr_dataset[xr_x_dim])) + + # Get the location ID + loc_id = None + for id_dim_name in ["pv_system_id", "gsp_id", "station_id"]: + if id_dim_name in xr_dataset.coords.keys(): + loc_id = int(xr_dataset[id_dim_name][loc_index].values) + + location = Location( + coordinate_system=xr_coord_system, + x=xr_dataset[xr_x_dim][loc_index].values, + y=xr_dataset[xr_y_dim][loc_index].values, + id=loc_id, + ) + + yield location def __iter__(self) -> Location: - """Returns locations from the inputs datapipe""" - for xr_dataset in self.source_datapipe: - loc_type, xr_x_dim, xr_y_dim = spatial_coord_type(xr_dataset) - - if self.return_all_locations: - logger.debug("Going to return all locations") - - # Iterate through all locations in dataset - for location_idx in range(len(xr_dataset[xr_x_dim])): - location = Location( - x=xr_dataset[xr_x_dim][location_idx].values, - y=xr_dataset[xr_y_dim][location_idx].values, - coordinate_system=loc_type, - ) - if "pv_system_id" in xr_dataset.coords.keys(): - location.id = int(xr_dataset["pv_system_id"][location_idx].values) - logger.debug(f"Got all location {location}") - yield location - else: - # Pick 1 random location from the input dataset - logger.debug("Selecting random idx") - location_idx = np.random.randint(0, len(xr_dataset[xr_x_dim])) - logger.debug(f"{location_idx=}") - location = Location( - x=xr_dataset[xr_x_dim][location_idx].values, - y=xr_dataset[xr_y_dim][location_idx].values, - coordinate_system=loc_type, - ) - if "pv_system_id" in xr_dataset.coords.keys(): - location.id = int(xr_dataset["pv_system_id"][location_idx].values) - logger.debug(f"Have selected location.id {location.id}") - logger.debug(f"{location=}") - yield location + xr_dataset = next(iter(self.source_datapipe)) + + if self.return_all: + return self._yield_all_iter(xr_dataset) + else: + return self._yield_random_iter(xr_dataset) \ No newline at end of file diff --git a/ocf_datapipes/select/pick_locations_and_t0_times.py b/ocf_datapipes/select/pick_locations_and_t0_times.py index 06cec1677..1b2a619bb 100644 --- a/ocf_datapipes/select/pick_locations_and_t0_times.py +++ b/ocf_datapipes/select/pick_locations_and_t0_times.py @@ -32,7 +32,7 @@ def __init__( source_datapipe: Datapipe emitting Xarray Dataset return_all: Whether to return all t0-location pairs, if True, also returns them in structured order - shuffle: If `return_all` sets whether the pairs are + shuffle: If `return_all` is True this sets whether the pairs are shuffled before being returned. time_dim_name: time dimension name, defaulted to 'time_utc' """ @@ -43,7 +43,10 @@ def __init__( self.time_dim_name = time_dim_name def _yield_all_iter(self, xr_dataset): + + # Get the spatial coords xr_coord_system, xr_x_dim, xr_y_dim = spatial_coord_type(xr_dataset) + t_index, x_index = np.meshgrid( np.arange(len(xr_dataset[self.time_dim_name])), np.arange(len(xr_dataset[xr_x_dim])), @@ -56,48 +59,44 @@ def _yield_all_iter(self, xr_dataset): # Iterate through all locations in dataset for t_index, loc_index in index_pairs: + + # Get the location ID + loc_id = None + for id_dim_name in ["pv_system_id", "gsp_id", "station_id"]: + if id_dim_name in xr_dataset.coords.keys(): + loc_id = int(xr_dataset[id_dim_name][loc_index].values) + t0 = xr_dataset[self.time_dim_name][t_index].values location = Location( coordinate_system=xr_coord_system, x=xr_dataset[xr_x_dim][loc_index].values, y=xr_dataset[xr_y_dim][loc_index].values, + id=loc_id, ) - # for pv - if "pv_system_id" in xr_dataset.coords.keys(): - location.id = int(xr_dataset["pv_system_id"][loc_index].values) - - # for gsp - if "gsp_id" in xr_dataset.coords.keys(): - location.id = int(xr_dataset["gsp_id"][loc_index].values) - - # for sensor - if "station_id" in xr_dataset.coords.keys(): - location.id = int(xr_dataset["station_id"][loc_index].values) - yield location, t0 def _yield_random_iter(self, xr_dataset): + + # Get the spatial coords xr_coord_system, xr_x_dim, xr_y_dim = spatial_coord_type(xr_dataset) + while True: - location_idx = np.random.randint(0, len(xr_dataset[xr_x_dim])) + loc_index = np.random.randint(0, len(xr_dataset[xr_x_dim])) + + # Get the location ID + loc_id = None + for id_dim_name in ["pv_system_id", "gsp_id", "station_id"]: + if id_dim_name in xr_dataset.coords.keys(): + loc_id = int(xr_dataset[id_dim_name][loc_index].values) location = Location( coordinate_system=xr_coord_system, - x=xr_dataset[xr_x_dim][location_idx].values, - y=xr_dataset[xr_y_dim][location_idx].values, + x=xr_dataset[xr_x_dim][loc_index].values, + y=xr_dataset[xr_y_dim][loc_index].values, + id=loc_id, ) - if "pv_system_id" in xr_dataset.coords.keys(): - location.id = int(xr_dataset["pv_system_id"][location_idx].values) - - # for gsp - if "gsp_id" in xr_dataset.coords.keys(): - location.id = int(xr_dataset["gsp_id"][location_idx].values) - - # for sensor - if "station_id" in xr_dataset.coords.keys(): - location.id = int(xr_dataset["station_id"][location_idx].values) - + t0 = np.random.choice(xr_dataset[self.time_dim_name].values) yield location, t0 diff --git a/ocf_datapipes/select/pick_t0_times.py b/ocf_datapipes/select/pick_t0_times.py index 88afdccf9..173b054e4 100644 --- a/ocf_datapipes/select/pick_t0_times.py +++ b/ocf_datapipes/select/pick_t0_times.py @@ -10,31 +10,55 @@ @functional_datapipe("pick_t0_times") class PickT0TimesIterDataPipe(IterDataPipe): - """Picks random t0 times from a dataset""" + """Picks (random) t0 times from a dataset""" def __init__( self, source_datapipe: IterDataPipe, + return_all: bool = False, + shuffle: bool = False, dim_name: str = "time_utc", ): """ - Picks random t0 times from a dataset + Datapipe to yield t0 times from the input data source. Args: - source_datapipe: Datapipe emitting Xarray objects - dim_name: The time dimension name to use + source_datapipe: Datapipe emitting Xarray objects. + return_all: Whether to return all t0 values, else sample with replacement. If True, the + default behaviour to return t0 values in order - see `shuffle` parameter. + shuffle: If `return_all` is True this sets whether the pairs are + shuffled before being returned. + dim_name: The time dimension name to use. """ self.source_datapipe = source_datapipe + self.return_all = return_all + self.shuffle = shuffle self.dim_name = dim_name + + + def _yield_random_iter(self, xr_dataset): + """Sample t0 with replacement""" + while True: + t0 = np.random.choice(xr_dataset[self.dim_name].values) + yield t0 + + def _yield_all_iter(self, xr_dataset): + """Yield all the t0s in order, and maybe with a shuffle""" + all_t0s = np.copy(xr_dataset[self.dim_name].values) + if self.shuffle: + all_t0s = np.random.permutation(all_t0s) + for t0 in all_t0s: + yield t0 def __iter__(self) -> pd.Timestamp: - """Get the latest timestamp and return it""" - for xr_data in self.source_datapipe: - logger.debug(f"Selecting t0 from {len(xr_data[self.dim_name])} datetimes") + xr_dataset = next(iter(self.source_datapipe)) + + if len(xr_dataset[self.dim_name].values) == 0: + raise Exception("There are no values to get t0 from") + + if self.return_all: + return self._yield_all_iter(xr_dataset) + else: + return self._yield_random_iter(xr_dataset) + - if len(xr_data[self.dim_name].values) == 0: - raise Exception("There are no values to get t0 from") - t0 = np.random.choice(xr_data[self.dim_name].values) - logger.debug(f"t0 will be {t0}") - - yield t0 From 8e7c95f059f61584003b58806e0611fec0da81f6 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Mon, 15 Apr 2024 19:01:57 +0000 Subject: [PATCH 05/27] update tests using pick functions --- tests/select/test_pick_locations.py | 2 +- tests/select/test_select_spatial_slice.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/select/test_pick_locations.py b/tests/select/test_pick_locations.py index b52cec234..753014273 100644 --- a/tests/select/test_pick_locations.py +++ b/tests/select/test_pick_locations.py @@ -28,7 +28,7 @@ def test_pick_locations_all_locations(gsp_datapipe): sample_period_duration=timedelta(minutes=30), history_duration=timedelta(hours=1), ) - location_datapipe = PickLocations(gsp_datapipe, return_all_locations=True) + location_datapipe = PickLocations(gsp_datapipe, return_all=True) loc_iterator = iter(location_datapipe) for i in range(len(dataset["x_osgb"])): loc_data = next(loc_iterator) diff --git a/tests/select/test_select_spatial_slice.py b/tests/select/test_select_spatial_slice.py index 5e089fd38..7e7c2a7fb 100644 --- a/tests/select/test_select_spatial_slice.py +++ b/tests/select/test_select_spatial_slice.py @@ -114,7 +114,7 @@ def test_select_spatial_slice_pixel_icon_eu(passiv_datapipe, icon_eu_datapipe): def test_select_spatial_slice_pixel_icon_global(passiv_datapipe, icon_global_datapipe): - loc_datapipe = PickLocations(passiv_datapipe, return_all_locations=True) + loc_datapipe = PickLocations(passiv_datapipe, return_all=True) icon_global_datapipe = SelectSpatialSlicePixels( icon_global_datapipe, location_datapipe=loc_datapipe, @@ -146,7 +146,7 @@ def test_select_spatial_slice_meters_icon_eu(passiv_datapipe, icon_eu_datapipe): def test_select_spatial_slice_meters_icon_global(passiv_datapipe, icon_global_datapipe): - loc_datapipe = PickLocations(passiv_datapipe, return_all_locations=True) + loc_datapipe = PickLocations(passiv_datapipe, return_all=True) icon_global_datapipe = SelectSpatialSliceMeters( icon_global_datapipe, location_datapipe=loc_datapipe, From d30f2345baaa7eb25e85ab29efad3625e86c7056 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Mon, 15 Apr 2024 19:03:07 +0000 Subject: [PATCH 06/27] refdactor to remove code duplication --- ocf_datapipes/training/common.py | 53 ++++++--- ocf_datapipes/training/pvnet_all_gsp.py | 139 +++++------------------- 2 files changed, 64 insertions(+), 128 deletions(-) diff --git a/ocf_datapipes/training/common.py b/ocf_datapipes/training/common.py index 36398d479..7a6be9b4c 100644 --- a/ocf_datapipes/training/common.py +++ b/ocf_datapipes/training/common.py @@ -1092,28 +1092,18 @@ def add_selected_time_slices_from_datapipes(used_datapipes: dict): return datapipes_to_return -def create_t0_and_loc_datapipes( +def create_valid_t0_periods_datapipe( datapipes_dict: dict, configuration: Configuration, key_for_t0: str = "gsp", - shuffle: bool = True, ): - """ - Takes source datapipes and returns datapipes of appropriate sample pairs of locations and times. - - The (location, t0) pairs are sampled without replacement. - + """Create datapipe yielding t0 periods which are valid for the input data sources. + Args: datapipes_dict: Dictionary of datapipes of input sources for which we want to select appropriate location and times. configuration: Configuration object for inputs. key_for_t0: Key to use for the t0 datapipe. Must be "gsp" or "pv". - shuffle: Whether to use the internal shuffle function when yielding location times. Else - location times will be heavily ordered. - - Returns: - location datapipe, t0 datapipe - """ assert key_for_t0 in datapipes_dict assert key_for_t0 in [ @@ -1224,9 +1214,42 @@ def create_t0_and_loc_datapipes( overlapping_datapipe = contiguous_time_datapipes[0] # Select time periods and set length - key_datapipe = key_datapipe.filter_time_periods(time_periods=overlapping_datapipe) + valid_t0_periods_datapipe = key_datapipe.filter_time_periods(time_periods=overlapping_datapipe) + + return valid_t0_periods_datapipe + + + +def create_t0_and_loc_datapipes( + datapipes_dict: dict, + configuration: Configuration, + key_for_t0: str = "gsp", + shuffle: bool = True, +): + """ + Takes source datapipes and returns datapipes of appropriate sample pairs of locations and times. + + The (location, t0) pairs are sampled without replacement. + + Args: + datapipes_dict: Dictionary of datapipes of input sources for which we want to select + appropriate location and times. + configuration: Configuration object for inputs. + key_for_t0: Key to use for the t0 datapipe. Must be "gsp" or "pv". + shuffle: Whether to use the internal shuffle function when yielding location times. Else + location times will be heavily ordered. + + Returns: + location datapipe, t0 datapipe + """ - t0_loc_datapipe = key_datapipe.pick_locs_and_t0s(return_all=True, shuffle=shuffle) + valid_t0_periods_datapipe = create_valid_t0_periods_datapipe( + datapipes_dict, + configuration, + key_for_t0, + ) + + t0_loc_datapipe = valid_t0_periods_datapipe.pick_locs_and_t0s(return_all=True, shuffle=shuffle) location_pipe, t0_datapipe = t0_loc_datapipe.unzip(sequence_length=2) diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py index 4523b360e..f27cbd892 100644 --- a/ocf_datapipes/training/pvnet_all_gsp.py +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -28,6 +28,7 @@ slice_datapipes_by_time, minutes, open_and_return_datapipes, + create_valid_t0_periods_datapipe, ) from ocf_datapipes.utils.consts import ( NWP_MEANS, @@ -58,12 +59,20 @@ class SampleFunction: + """Apply a function to each sample in the concurrent batch list""" + def __init__(self, function): + """Apply a function to each sample in the concurrent batch list + + Args: + function: Function to apply to each sample + """ self.function = function def __call__(self, sample_list): return [self.function(sample) for sample in sample_list] - + + class ZipFunction: def __init__(self, function): self.function = function @@ -74,7 +83,14 @@ def __call__(self, zipped_sample_list): class SampleRepeat: + """Use a single input element to create a list of identical values""" + def __init__(self, num_repeats): + """Use a single input element to create a list of identical values + + Args: + num_repeats: Length of the returned list of duplicated values + """ self.num_repeats = num_repeats def __call__(self, x): @@ -108,7 +124,6 @@ def __call__(self, gsp_id: int) -> Location: ) - def create_t0_datapipe( datapipes_dict: dict, configuration: Configuration, @@ -121,122 +136,22 @@ def create_t0_datapipe( Args: datapipes_dict: Dictionary of datapipes of input sources for which we want to select - appropriate location and times. + appropriate t0 times. configuration: Configuration object for inputs. - shuffle: Whether to use the internal shuffle function when yielding location times. Else + shuffle: Whether to use the internal shuffle function when yielding times. Else location times will be heavily ordered. Returns: - location datapipe, t0 datapipe + t0 datapipe """ - assert "gsp" in datapipes_dict - - contiguous_time_datapipes = [] # Used to store contiguous time periods from each data source - - datapipes_dict["gsp"], key_datapipe = datapipes_dict["gsp"].fork(2, buffer_size=5) - - for key in datapipes_dict.keys(): - if key in ["topo"]: - continue - - elif key == "nwp": - for nwp_key in datapipes_dict["nwp"].keys(): - # NWPs are nested since there can be multiple NWP sources - datapipes_dict["nwp"][nwp_key], datapipe_copy = datapipes_dict["nwp"][nwp_key].fork( - 2, buffer_size=5 - ) - - # Different config setting per NWP source - nwp_conf = configuration.input_data.nwp[nwp_key] - - if nwp_conf.dropout_timedeltas_minutes is None: - max_dropout = minutes(0) - else: - max_dropout = minutes(int(np.max(np.abs(nwp_conf.dropout_timedeltas_minutes)))) - - if nwp_conf.max_staleness_minutes is None: - max_staleness = None - else: - max_staleness = minutes(nwp_conf.max_staleness_minutes) - - # NWP is a forecast product so gets its own contiguous function - time_periods = datapipe_copy.find_contiguous_t0_time_periods_nwp( - history_duration=minutes(nwp_conf.history_minutes), - forecast_duration=minutes(nwp_conf.forecast_minutes), - max_staleness=max_staleness, - max_dropout=max_dropout, - time_dim="init_time_utc", - ) - - contiguous_time_datapipes.append(time_periods) - - else: - if key == "sat": - sample_frequency = configuration.input_data.satellite.time_resolution_minutes - history_duration = configuration.input_data.satellite.history_minutes - forecast_duration = 0 - time_dim = "time_utc" - - elif key == "hrv": - sample_frequency = configuration.input_data.hrvsatellite.time_resolution_minutes - history_duration = configuration.input_data.hrvsatellite.history_minutes - forecast_duration = 0 - time_dim = "time_utc" - - elif key == "pv": - sample_frequency = configuration.input_data.pv.time_resolution_minutes - history_duration = configuration.input_data.pv.history_minutes - forecast_duration = configuration.input_data.pv.forecast_minutes - time_dim = "time_utc" - - elif key == "wind": - sample_frequency = configuration.input_data.wind.time_resolution_minutes - history_duration = configuration.input_data.wind.history_minutes - forecast_duration = configuration.input_data.wind.forecast_minutes - time_dim = "time_utc" - - elif key == "sensor": - sample_frequency = configuration.input_data.sensor.time_resolution_minutes - history_duration = configuration.input_data.sensor.history_minutes - forecast_duration = configuration.input_data.sensor.forecast_minutes - time_dim = "time_utc" - - elif key == "gsp": - sample_frequency = configuration.input_data.gsp.time_resolution_minutes - history_duration = configuration.input_data.gsp.history_minutes - forecast_duration = configuration.input_data.gsp.forecast_minutes - time_dim = "time_utc" - - else: - raise ValueError(f"Unexpected key: {key}") - - datapipes_dict[key], datapipe_copy = datapipes_dict[key].fork(2, buffer_size=5) - - time_periods = datapipe_copy.find_contiguous_t0_time_periods( - sample_period_duration=minutes(sample_frequency), - history_duration=minutes(history_duration), - forecast_duration=minutes(forecast_duration), - time_dim=time_dim, - ) - - contiguous_time_datapipes.append(time_periods) - - # Find joint overlapping contiguous time periods - if len(contiguous_time_datapipes) > 1: - logger.debug("Getting joint time periods") - overlapping_datapipe = contiguous_time_datapipes[0].filter_to_overlapping_time_periods( - secondary_datapipes=contiguous_time_datapipes[1:], - ) - else: - logger.debug("Skipping getting joint time periods") - overlapping_datapipe = contiguous_time_datapipes[0] - - # Select time periods and set length - key_datapipe = key_datapipe.filter_time_periods(time_periods=overlapping_datapipe) - - t0_datapipe = key_datapipe.pick_t0_times()#return_all=True, shuffle=shuffle) + valid_t0_periods_datapipe = create_valid_t0_periods_datapipe( + datapipes_dict, + configuration, + key_for_t0="gsp", + ) + t0_datapipe = valid_t0_periods_datapipe.pick_t0_times(return_all=True, shuffle=shuffle) return t0_datapipe @@ -490,8 +405,6 @@ def __iter__(self): yield stacked_converted_values - - def construct_sliced_data_pipeline( config_filename: str, t0_datapipe: IterDataPipe, From d8b10e0c75b5f36cb2528eefa374424b04db6871 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Fri, 31 May 2024 11:57:19 +0000 Subject: [PATCH 07/27] refactor gsp_all_pipeline --- ocf_datapipes/load/gsp/gsp.py | 34 +- ocf_datapipes/load/gsp/utils.py | 62 +- ocf_datapipes/select/select_spatial_slice.py | 717 +++++++++++-------- ocf_datapipes/training/pvnet.py | 81 ++- ocf_datapipes/training/pvnet_all_gsp_v2.py | 516 +++++++++++++ ocf_datapipes/utils/location.py | 3 +- 6 files changed, 1039 insertions(+), 374 deletions(-) create mode 100644 ocf_datapipes/training/pvnet_all_gsp_v2.py diff --git a/ocf_datapipes/load/gsp/gsp.py b/ocf_datapipes/load/gsp/gsp.py index 57800032a..251d46775 100644 --- a/ocf_datapipes/load/gsp/gsp.py +++ b/ocf_datapipes/load/gsp/gsp.py @@ -12,14 +12,6 @@ logger = logging.getLogger(__name__) -try: - from ocf_datapipes.utils.eso import get_gsp_metadata_from_eso, get_gsp_shape_from_eso - - _has_pvlive = True -except ImportError: - print("Unable to import PVLive utils, please provide filenames with OpenGSP") - _has_pvlive = False - @functional_datapipe("open_gsp") class OpenGSPIterDataPipe(IterDataPipe): @@ -44,40 +36,34 @@ def __init__( sample_period_duration: Sample period of the GSP data """ self.gsp_pv_power_zarr_path = gsp_pv_power_zarr_path - if ( - gsp_id_to_region_id_filename is None - or sheffield_solar_region_path is None - and _has_pvlive - ): - self.gsp_id_to_region_id_filename = get_gsp_metadata_from_eso() - self.sheffield_solar_region_path = get_gsp_shape_from_eso() - else: - self.gsp_id_to_region_id_filename = gsp_id_to_region_id_filename - self.sheffield_solar_region_path = sheffield_solar_region_path + + self.gsp_id_to_region_id_filename = gsp_id_to_region_id_filename + self.sheffield_solar_region_path = sheffield_solar_region_path self.threshold_mw = threshold_mw self.sample_period_duration = sample_period_duration def __iter__(self) -> xr.DataArray: """Get and return GSP data""" gsp_id_to_shape = get_gsp_id_to_shape( - self.gsp_id_to_region_id_filename, self.sheffield_solar_region_path + self.gsp_id_to_region_id_filename, + self.sheffield_solar_region_path, ) - self._gsp_id_to_shape = gsp_id_to_shape # Save, mostly for plotting to check all is fine! logger.debug(f"Getting GSP data from {self.gsp_pv_power_zarr_path}") - # Load GSP generation xr.Dataset: + # Load GSP generation xr.Dataset gsp_pv_power_mw_ds = xr.open_dataset(self.gsp_pv_power_zarr_path, engine="zarr") - # Ensure the centroids have the same GSP ID index as the GSP PV power: + # Ensure the centroids have the same GSP ID index as the GSP PV power gsp_id_to_shape = gsp_id_to_shape.loc[gsp_pv_power_mw_ds.gsp_id] + data_array = put_gsp_data_into_an_xr_dataarray( gsp_pv_power_mw=gsp_pv_power_mw_ds.generation_mw.data.astype(np.float32), time_utc=gsp_pv_power_mw_ds.datetime_gmt.data, gsp_id=gsp_pv_power_mw_ds.gsp_id.data, # TODO: Try using `gsp_id_to_shape.geometry.envelope.centroid`. See issue #76. - x_osgb=gsp_id_to_shape.geometry.centroid.x.astype(np.float32), - y_osgb=gsp_id_to_shape.geometry.centroid.y.astype(np.float32), + x_osgb=gsp_id_to_shape.x_osgb.astype(np.float32), + y_osgb=gsp_id_to_shape.y_osgb.astype(np.float32), nominal_capacity_mwp=gsp_pv_power_mw_ds.installedcapacity_mwp.data.astype(np.float32), effective_capacity_mwp=gsp_pv_power_mw_ds.capacity_mwp.data.astype(np.float32), ) diff --git a/ocf_datapipes/load/gsp/utils.py b/ocf_datapipes/load/gsp/utils.py index 3ec228935..e6b0d75b2 100644 --- a/ocf_datapipes/load/gsp/utils.py +++ b/ocf_datapipes/load/gsp/utils.py @@ -1,9 +1,20 @@ """ Utils for GSP loading""" +from typing import Optional + import geopandas as gpd import numpy as np import pandas as pd import xarray as xr +from ocf_datapipes.utils.location import Location + +try: + from ocf_datapipes.utils.eso import get_gsp_metadata_from_eso, get_gsp_shape_from_eso + _has_pvlive = True +except ImportError: + print("Unable to import PVLive utils, please provide filenames with OpenGSP") + _has_pvlive = False + def put_gsp_data_into_an_xr_dataarray( gsp_pv_power_mw: np.ndarray, @@ -48,7 +59,8 @@ def put_gsp_data_into_an_xr_dataarray( def get_gsp_id_to_shape( - gsp_id_to_region_id_filename: str, sheffield_solar_region_path: str + gsp_id_to_region_id_filename: Optional[str] = None, + sheffield_solar_region_path: Optional[str] = None, ) -> gpd.GeoDataFrame: """ Get the GSP ID to the shape @@ -60,6 +72,16 @@ def get_gsp_id_to_shape( Returns: GeoDataFrame containing the mapping from ID to shape """ + + did_provide_filepaths = None not in [gsp_id_to_region_id_filename, sheffield_solar_region_path] + assert _has_pvlive or did_provide_filepaths + + if not did_provide_filepaths: + if gsp_id_to_region_id_filename is None: + gsp_id_to_region_id_filename = get_gsp_metadata_from_eso() + if sheffield_solar_region_path is None: + sheffield_solar_region_path = get_gsp_shape_from_eso() + # Load mapping from GSP ID to Sheffield Solar GSP ID to GSP name: gsp_id_to_region_id = pd.read_csv( gsp_id_to_region_id_filename, @@ -94,4 +116,42 @@ def get_gsp_id_to_shape( # For the national forecast, GSP ID 0, we want the shape to be the # union of all the other shapes gsp_id_to_shape = pd.concat([gsp_id_to_shape, gsp_0]).sort_index() + + # Add central coordinates + gsp_id_to_shape["x_osgb"] = gsp_id_to_shape.geometry.centroid.x.astype(np.float32) + gsp_id_to_shape["y_osgb"] = gsp_id_to_shape.geometry.centroid.y.astype(np.float32) + return gsp_id_to_shape + + +class GSPLocationLookup: + """Query object for GSP location from GSP ID""" + + def __init__( + self, + gsp_id_to_region_id_filename: Optional[str] = None, + sheffield_solar_region_path: Optional[str] = None, +): + """Query object for GSP location from GSP ID + + Args: + gsp_id_to_region_id_filename: Filename of the mapping file + sheffield_solar_region_path: Path to the region shapes + + """ + self.gsp_id_to_shape = get_gsp_id_to_shape( + gsp_id_to_region_id_filename, + sheffield_solar_region_path, + ) + + def __call__(self, gsp_id: int) -> Location: + """Returns the locations for the input GSP IDs. + + Args: + gsp_id: Integer ID of the GSP + """ + return Location( + x=self.gsp_id_to_shape.loc[gsp_id].x_osgb.astype(np.float32), + y=self.gsp_id_to_shape.loc[gsp_id].y_osgb.astype(np.float32), + id=gsp_id, + ) \ No newline at end of file diff --git a/ocf_datapipes/select/select_spatial_slice.py b/ocf_datapipes/select/select_spatial_slice.py index 36e4c8787..4b42ea3f3 100644 --- a/ocf_datapipes/select/select_spatial_slice.py +++ b/ocf_datapipes/select/select_spatial_slice.py @@ -21,7 +21,249 @@ logger = logging.getLogger(__name__) -def select_spatial_slice_pixels( +# -------------------------------- utility functions -------------------------------- + + +def convert_coords_to_match_xarray(x, y, from_coords, xr_data): + """Convert x and y coords to cooridnate system matching xarray data + + Args: + x: Float or array-like + y: Float or array-like + from_coords: String describing coordinate system of x and y + xr_data: xarray data object to which coordinates should be matched + """ + + xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) + + assert from_coords in ["osgb", "lon_lat"] + assert xr_coords in ["geostationary", "osgb", "lon_lat"] + + if xr_coords == "geostationary": + if from_coords == "osgb": + x, y = osgb_to_geostationary_area_coords(x, y, xr_data) + + elif from_coords == "lon_lat": + x, y = lon_lat_to_geostationary_area_coords(x, y, xr_data) + + elif xr_coords == "lon_lat": + if from_coords == "osgb": + x, y = osgb_to_lon_lat(x, y) + + # else the from_coords=="lon_lat" and we don't need to convert + + elif xr_coords == "osgb": + if from_coords == "lon_lat": + x, y = lon_lat_to_osgb(x, y) + + # else the from_coords=="osgb" and we don't need to convert + + return x, y + + +def _get_idx_of_pixel_closest_to_poi( + xr_data: xr.DataArray, + location: Location, +) -> Location: + """ + Return x and y index location of pixel at center of region of interest. + + Args: + xr_data: Xarray dataset + location: Center + Returns: + The Location for the center pixel + """ + xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) + + if xr_coords not in ["osgb", "lon_lat"]: + raise NotImplementedError(f"Only 'osgb' and 'lon_lat' are supported - not '{xr_coords}'") + + # Convert location coords to match xarray data + x, y = convert_coords_to_match_xarray( + location.x, + location.y, + from_coords=location.coordinate_system, + xr_data=xr_data, + ) + + # Check that the requested point lies within the data + assert xr_data[xr_x_dim].min() < x < xr_data[xr_x_dim].max() + assert xr_data[xr_y_dim].min() < y < xr_data[xr_y_dim].max() + + x_index = xr_data.get_index(xr_x_dim) + y_index = xr_data.get_index(xr_y_dim) + + closest_x = x_index.get_indexer([x], method="nearest")[0] + closest_y = y_index.get_indexer([y], method="nearest")[0] + + return Location(x=closest_x, y=closest_y, coordinate_system="idx") + + +def _get_idx_of_pixel_closest_to_poi_geostationary( + xr_data: xr.DataArray, + center_osgb: Location, +) -> Location: + """ + Return x and y index location of pixel at center of region of interest. + + Args: + xr_data: Xarray dataset + center_osgb: Center in OSGB coordinates + + Returns: + Location for the center pixel in geostationary coordinates + """ + + xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) + + x, y = osgb_to_geostationary_area_coords(x=center_osgb.x, y=center_osgb.y, xr_data=xr_data) + center_geostationary = Location(x=x, y=y, coordinate_system="geostationary") + + # Check that the requested point lies within the data + assert xr_data[xr_x_dim].min() < x < xr_data[xr_x_dim].max() + assert xr_data[xr_y_dim].min() < y < xr_data[xr_y_dim].max() + + # Get the index into x and y nearest to x_center_geostationary and y_center_geostationary: + x_index_at_center = searchsorted( + xr_data[xr_x_dim].values, center_geostationary.x, assume_ascending=True + ) + + # y_geostationary is in descending order: + y_index_at_center = searchsorted( + xr_data[xr_y_dim].values, center_geostationary.y, assume_ascending=False + ) + + return Location(x=x_index_at_center, y=y_index_at_center, coordinate_system="idx") + + +def _get_points_from_unstructured_grids( + xr_data: xr.DataArray, + location: Location, + location_idx_name: str = "values", + num_points: int = 1, +): + """ + Get the closest points from an unstructured grid (i.e. Icosahedral grid) + + This is primarily used for the Icosahedral grid, which is not a regular grid, + and so is not an image + + Args: + xr_data: Xarray dataset + location: Location of center point + location_idx_name: Name of the index values dimension + (i.e. where we index into to get the lat/lon for that point) + num_points: Number of points to return (should be width * height) + + Returns: + The closest points from the grid + """ + xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) + assert xr_coords == "lon_lat" + + # Check if need to convert from different coordinate system to lat/lon + if location.coordinate_system == "osgb": + longitude, latitude = osgb_to_lon_lat(x=location.x, y=location.y) + location = Location( + x=longitude, + y=latitude, + coordinate_system="lon_lat", + ) + elif location.coordinate_system == "geostationary": + raise NotImplementedError( + "Does not currently support geostationary coordinates when using unstructured grids" + ) + + # Extract lat, lon, and locidx data + lat = xr_data.longitude.values + lon = xr_data.latitude.values + locidx = xr_data[location_idx_name].values + + # Create a KDTree + tree = KDTree(list(zip(lat, lon))) + + # Query with the [longitude, latitude] of your point + _, idx = tree.query([location.x, location.y], k=num_points) + + # Retrieve the location_idxs for these grid points + location_idxs = locidx[idx] + + data = xr_data.sel({location_idx_name: location_idxs}) + return data + + +# ---------------------------- sub-functions for slicing ---------------------------- + + +def _slice_patial_spatial_pixel_window_from_xarray( + xr_data, + left_idx, + right_idx, + top_idx, + bottom_idx, + left_pad_pixels, + right_pad_pixels, + top_pad_pixels, + bottom_pad_pixels, + xr_x_dim, + xr_y_dim, +): + """Return spatial window of given pixel size when window partially overlaps input data""" + + dx = np.median(np.diff(xr_data[xr_x_dim].values)) + dy = np.median(np.diff(xr_data[xr_y_dim].values)) + + if left_pad_pixels > 0: + assert right_pad_pixels == 0 + x_sel = np.concatenate( + [ + xr_data[xr_x_dim].values[0] - np.arange(left_pad_pixels, 0, -1) * dx, + xr_data[xr_x_dim].values[0:right_idx], + ] + ) + xr_data = xr_data.isel({xr_x_dim: slice(0, right_idx)}).reindex({xr_x_dim: x_sel}) + + elif right_pad_pixels > 0: + assert left_pad_pixels == 0 + x_sel = np.concatenate( + [ + xr_data[xr_x_dim].values[left_idx:], + xr_data[xr_x_dim].values[-1] + np.arange(1, right_pad_pixels + 1) * dx, + ] + ) + xr_data = xr_data.isel({xr_x_dim: slice(left_idx, None)}).reindex({xr_x_dim: x_sel}) + + else: + xr_data = xr_data.isel({xr_x_dim: slice(left_idx, right_idx)}) + + if top_pad_pixels > 0: + assert bottom_pad_pixels == 0 + y_sel = np.concatenate( + [ + xr_data[xr_y_dim].values[0] - np.arange(top_pad_pixels, 0, -1) * dy, + xr_data[xr_y_dim].values[0:bottom_idx], + ] + ) + xr_data = xr_data.isel({xr_y_dim: slice(0, bottom_idx)}).reindex({xr_y_dim: y_sel}) + + elif bottom_pad_pixels > 0: + assert top_pad_pixels == 0 + y_sel = np.concatenate( + [ + xr_data[xr_y_dim].values[top_idx:], + xr_data[xr_y_dim].values[-1] + np.arange(1, bottom_pad_pixels + 1) * dy, + ] + ) + xr_data = xr_data.isel({xr_y_dim: slice(top_idx, None)}).reindex({xr_x_dim: y_sel}) + + else: + xr_data = xr_data.isel({xr_y_dim: slice(top_idx, bottom_idx)}) + + return xr_data + + +def slice_spatial_pixel_window_from_xarray( xr_data, center_idx, width_pixels, height_pixels, xr_x_dim, xr_y_dim, allow_partial_slice ): """Select a spatial slice from an xarray object @@ -64,7 +306,7 @@ def select_spatial_slice_pixels( (bottom_idx - (data_height_pixels - 1)) if bottom_pad_required else 0 ) - xr_data = select_partial_spatial_slice_pixels( + xr_data = _slice_patial_spatial_pixel_window_from_xarray( xr_data, left_idx, right_idx, @@ -105,71 +347,166 @@ def select_spatial_slice_pixels( return xr_data -def select_partial_spatial_slice_pixels( - xr_data, - left_idx, - right_idx, - top_idx, - bottom_idx, - left_pad_pixels, - right_pad_pixels, - top_pad_pixels, - bottom_pad_pixels, - xr_x_dim, - xr_y_dim, +# ---------------------------- main functions for slicing --------------------------- + + +def select_spatial_slice_pixels( + xr_data: Union[xr.Dataset, xr.DataArray], + location: Location, + roi_width_pixels: int, + roi_height_pixels: int, + allow_partial_slice: bool = False, + location_idx_name: Optional[str] = None, ): - """Return spatial window of given pixel size when window partially overlaps input data""" + """ + Select spatial slice based off pixels from location point of interest - dx = np.median(np.diff(xr_data[xr_x_dim].values)) - dy = np.median(np.diff(xr_data[xr_y_dim].values)) + If `allow_partial_slice` is set to True, then slices may be made which intersect the border + of the input data. The additional x and y cordinates that would be required for this slice + are extrapolated based on the average spacing of these coordinates in the input data. + However, currently slices cannot be made where the centre of the window is outside of the + input data. - if left_pad_pixels > 0: - assert right_pad_pixels == 0 - x_sel = np.concatenate( - [ - xr_data[xr_x_dim].values[0] - np.arange(left_pad_pixels, 0, -1) * dx, - xr_data[xr_x_dim].values[0:right_idx], - ] + Args: + xr_data: Xarray DataArray or Dataset to slice from + location: Location of interest + roi_height_pixels: ROI height in pixels + roi_width_pixels: ROI width in pixels + allow_partial_slice: Whether to allow a partial slice. + location_idx_name: Name for location index of unstructured grid data, + None if not relevant + """ + + xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) + if location_idx_name is not None: + selected = _get_points_from_unstructured_grids( + xr_data=xr_data, + location=location, + location_idx_name=location_idx_name, + num_points=roi_width_pixels * roi_height_pixels, ) - xr_data = xr_data.isel({xr_x_dim: slice(0, right_idx)}).reindex({xr_x_dim: x_sel}) + else: + if xr_coords == "geostationary": + center_idx: Location = _get_idx_of_pixel_closest_to_poi_geostationary( + xr_data=xr_data, + center_osgb=location, + ) + else: + center_idx: Location = _get_idx_of_pixel_closest_to_poi( + xr_data=xr_data, + location=location, + ) - elif right_pad_pixels > 0: - assert left_pad_pixels == 0 - x_sel = np.concatenate( - [ - xr_data[xr_x_dim].values[left_idx:], - xr_data[xr_x_dim].values[-1] + np.arange(1, right_pad_pixels + 1) * dx, - ] + selected = slice_spatial_pixel_window_from_xarray( + xr_data, + center_idx, + roi_width_pixels, + roi_height_pixels, + xr_x_dim, + xr_y_dim, + allow_partial_slice=allow_partial_slice, ) - xr_data = xr_data.isel({xr_x_dim: slice(left_idx, None)}).reindex({xr_x_dim: x_sel}) - else: - xr_data = xr_data.isel({xr_x_dim: slice(left_idx, right_idx)}) + return selected - if top_pad_pixels > 0: - assert bottom_pad_pixels == 0 - y_sel = np.concatenate( - [ - xr_data[xr_y_dim].values[0] - np.arange(top_pad_pixels, 0, -1) * dy, - xr_data[xr_y_dim].values[0:bottom_idx], - ] + +def select_spatial_slice_meters( + xr_data: Union[xr.Dataset, xr.DataArray], + location: Location, + roi_width_meters: int, + roi_height_meters: int, + dim_name: Optional[str] = None, +): + """ + Select spatial slice based off pixels from point of interest + + Args: + xr_data: Xarray DataArray or Dataset to slice from + location: Location of interest + roi_height_meters: ROI height in meters + roi_width_meters: ROI width in meters + dim_name: Dimension name to select for ID, None for coordinates + + Notes: + Using spatial slicing based on distance rather than number of pixels will often yield + slices which can vary by 1 pixel in height and/or width. + + E.g. Suppose the Xarray data has x-coords = [1,2,3,4,5]. We want to slice a spatial + window with a size which equates to 2.2 along the x-axis. If we choose to slice around + the point x=3 this will slice out the x-coords [2,3,4]. If we choose to slice around the + point x=2.5 this will slice out the x-coords [2,3]. Hence the returned slice can have + size either 2 or 3 in the x-axis depending on the spatial location selected. + + Also, if selecting over a large span of latitudes, this may also causes pixel sizes of + the yielded outputs to change. For example, if the Xarray data is on a regularly spaced + longitude-latitude grid, then the structure of the grid means that the longitudes near + to the poles are spaced closer together (measured in meters) than at the equator. So + slices near the equator will have less pixels in the x-axis than slices taken near the + poles. + """ + # Get the spatial coords of the xarray data + xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) + + half_width = roi_width_meters // 2 + half_height = roi_height_meters // 2 + + # Find the bounding box values for the location in either lat-lon or OSGB coord systems + if location.coordinate_system == "lon_lat": + right, top = move_lon_lat_by_meters( + location.x, + location.y, + half_width, + half_height, + ) + left, bottom = move_lon_lat_by_meters( + location.x, + location.y, + -half_width, + -half_height, ) - xr_data = xr_data.isel({xr_y_dim: slice(0, bottom_idx)}).reindex({xr_y_dim: y_sel}) - elif bottom_pad_pixels > 0: - assert top_pad_pixels == 0 - y_sel = np.concatenate( - [ - xr_data[xr_y_dim].values[top_idx:], - xr_data[xr_y_dim].values[-1] + np.arange(1, bottom_pad_pixels + 1) * dy, - ] + elif location.coordinate_system == "osgb": + left = location.x - half_width + right = location.x + half_width + bottom = location.y - half_height + top = location.y + half_height + + else: + raise ValueError( + f"Location coord system not recognized: {location.coordinate_system}" + ) + + # Change the bounding coordinates [left, right, bottom, top] to the same + # coordinate system as the xarray data + (left, right), (bottom, top) = convert_coords_to_match_xarray( + x=np.array([left, right], dtype=np.float32), + y=np.array([bottom, top], dtype=np.float32), + from_coords=location.coordinate_system, + xr_data=xr_data, ) - xr_data = xr_data.isel({xr_y_dim: slice(top_idx, None)}).reindex({xr_x_dim: y_sel}) + + # Do it off coordinates, not ID + if dim_name is None: + # Select a patch from the xarray data + x_mask = (left <= xr_data[xr_x_dim]) & (xr_data[xr_x_dim] <= right) + y_mask = (bottom <= xr_data[xr_y_dim]) & (xr_data[xr_y_dim] <= top) + selected = xr_data.isel({xr_x_dim: x_mask, xr_y_dim: y_mask}) else: - xr_data = xr_data.isel({xr_y_dim: slice(top_idx, bottom_idx)}) + # Select data in the region of interest and ID: + # This also works for unstructured grids + + id_mask = ( + (left <= xr_data[xr_x_dim]) + & (xr_data[xr_x_dim] <= right) + & (bottom <= xr_data[xr_y_dim]) + & (xr_data[xr_y_dim] <= top) + ) + selected = xr_data.isel({dim_name: id_mask}) + return selected - return xr_data + +# ------------------------------ datapipes for slicing ------------------------------ @functional_datapipe("select_spatial_slice_pixels") @@ -180,8 +517,8 @@ def __init__( self, source_datapipe: IterDataPipe, location_datapipe: IterDataPipe, - roi_height_pixels: int, roi_width_pixels: int, + roi_height_pixels: int, allow_partial_slice: bool = False, location_idx_name: Optional[str] = None, ): @@ -213,39 +550,18 @@ def __init__( def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: for xr_data, location in self.source_datapipe.zip_ocf(self.location_datapipe): logger.debug("Selecting spatial slice with pixels") - xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) - if self.location_idx_name is not None: - selected = _get_points_from_unstructured_grids( - xr_data=xr_data, - location=location, - location_idx_name=self.location_idx_name, - num_points=self.roi_width_pixels * self.roi_height_pixels, - ) - yield selected - - if xr_coords == "geostationary": - center_idx: Location = _get_idx_of_pixel_closest_to_poi_geostationary( - xr_data=xr_data, - center_osgb=location, - ) - else: - center_idx: Location = _get_idx_of_pixel_closest_to_poi( - xr_data=xr_data, - location=location, - ) selected = select_spatial_slice_pixels( - xr_data, - center_idx, - self.roi_width_pixels, - self.roi_height_pixels, - xr_x_dim, - xr_y_dim, + xr_data=xr_data, + location=location, + roi_width_pixels=self.roi_width_pixels, + roi_height_pixels=self.roi_height_pixels, allow_partial_slice=self.allow_partial_slice, + location_idx_name=self.location_idx_name, ) yield selected - + @functional_datapipe("select_spatial_slice_meters") class SelectSpatialSliceMetersIterDataPipe(IterDataPipe): @@ -255,9 +571,9 @@ def __init__( self, source_datapipe: IterDataPipe, location_datapipe: IterDataPipe, - roi_height_meters: int, roi_width_meters: int, - dim_name: Optional[str] = None, # "pv_system_id", + roi_height_meters: int, + dim_name: Optional[str] = None, ): """ Select spatial slice based off pixels from point of interest @@ -265,8 +581,8 @@ def __init__( Args: source_datapipe: Datapipe of Xarray data location_datapipe: Location datapipe - roi_height_meters: ROI height in meters roi_width_meters: ROI width in meters + roi_height_meters: ROI height in meters dim_name: Dimension name to select for ID, None for coordinates Notes: @@ -297,233 +613,12 @@ def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: # Compute the index for left and right: logger.debug("Getting Spatial Slice Meters") - # Get the spatial coords of the xarray data - xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) - - half_height = self.roi_height_meters // 2 - half_width = self.roi_width_meters // 2 - - # Find the bounding box values for the location in either lat-lon or OSGB coord systems - if location.coordinate_system == "lon_lat": - right, top = move_lon_lat_by_meters( - location.x, - location.y, - half_width, - half_height, - ) - left, bottom = move_lon_lat_by_meters( - location.x, - location.y, - -half_width, - -half_height, - ) - - elif location.coordinate_system == "osgb": - left = location.x - half_width - right = location.x + half_width - bottom = location.y - half_height - top = location.y + half_height - - else: - raise ValueError( - f"Location coord system not recognized: {location.coordinate_system}" - ) - - # Change the bounding coordinates [left, right, bottom, top] to the same - # coordinate system as the xarray data - (left, right), (bottom, top) = convert_coords_to_match_xarray( - x=np.array([left, right], dtype=np.float32), - y=np.array([bottom, top], dtype=np.float32), - from_coords=location.coordinate_system, - xr_data=xr_data, + selected = select_spatial_slice_meters( + xr_data=xr_data, + location=location, + roi_width_meters=self.roi_width_meters, + roi_height_meters=self.roi_height_meters, + dim_name=self.dim_name, ) - # Do it off coordinates, not ID - if self.dim_name is None: - # Select a patch from the xarray data - x_mask = (left <= xr_data[xr_x_dim]) & (xr_data[xr_x_dim] <= right) - y_mask = (bottom <= xr_data[xr_y_dim]) & (xr_data[xr_y_dim] <= top) - selected = xr_data.isel({xr_x_dim: x_mask, xr_y_dim: y_mask}) - - else: - # Select data in the region of interest and ID: - # This also works for unstructured grids - - id_mask = ( - (left <= xr_data[xr_x_dim]) - & (xr_data[xr_x_dim] <= right) - & (bottom <= xr_data[xr_y_dim]) - & (xr_data[xr_y_dim] <= top) - ) - selected = xr_data.isel({self.dim_name: id_mask}) - yield selected - - -def convert_coords_to_match_xarray(x, y, from_coords, xr_data): - """Convert x and y coords to cooridnate system matching xarray data - - Args: - x: Float or array-like - y: Float or array-like - from_coords: String describing coordinate system of x and y - xr_data: xarray data object to which coordinates should be matched - """ - - xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) - - assert from_coords in ["osgb", "lon_lat"] - assert xr_coords in ["geostationary", "osgb", "lon_lat"] - - if xr_coords == "geostationary": - if from_coords == "osgb": - x, y = osgb_to_geostationary_area_coords(x, y, xr_data) - - elif from_coords == "lon_lat": - x, y = lon_lat_to_geostationary_area_coords(x, y, xr_data) - - elif xr_coords == "lon_lat": - if from_coords == "osgb": - x, y = osgb_to_lon_lat(x, y) - - # else the from_coords=="lon_lat" and we don't need to convert - - elif xr_coords == "osgb": - if from_coords == "lon_lat": - x, y = lon_lat_to_osgb(x, y) - - # else the from_coords=="osgb" and we don't need to convert - - return x, y - - -def _get_idx_of_pixel_closest_to_poi( - xr_data: xr.DataArray, - location: Location, -) -> Location: - """ - Return x and y index location of pixel at center of region of interest. - - Args: - xr_data: Xarray dataset - location: Center - Returns: - The Location for the center pixel - """ - xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) - - if xr_coords not in ["osgb", "lon_lat"]: - raise NotImplementedError(f"Only 'osgb' and 'lon_lat' are supported - not '{xr_coords}'") - - # Convert location coords to match xarray data - x, y = convert_coords_to_match_xarray( - location.x, - location.y, - from_coords=location.coordinate_system, - xr_data=xr_data, - ) - - # Check that the requested point lies within the data - assert xr_data[xr_x_dim].min() < x < xr_data[xr_x_dim].max() - assert xr_data[xr_y_dim].min() < y < xr_data[xr_y_dim].max() - - x_index = xr_data.get_index(xr_x_dim) - y_index = xr_data.get_index(xr_y_dim) - - closest_x = x_index.get_indexer([x], method="nearest")[0] - closest_y = y_index.get_indexer([y], method="nearest")[0] - - return Location(x=closest_x, y=closest_y, coordinate_system="idx") - - -def _get_idx_of_pixel_closest_to_poi_geostationary( - xr_data: xr.DataArray, - center_osgb: Location, -) -> Location: - """ - Return x and y index location of pixel at center of region of interest. - - Args: - xr_data: Xarray dataset - center_osgb: Center in OSGB coordinates - - Returns: - Location for the center pixel in geostationary coordinates - """ - - xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) - - x, y = osgb_to_geostationary_area_coords(x=center_osgb.x, y=center_osgb.y, xr_data=xr_data) - center_geostationary = Location(x=x, y=y, coordinate_system="geostationary") - - # Check that the requested point lies within the data - assert xr_data[xr_x_dim].min() < x < xr_data[xr_x_dim].max() - assert xr_data[xr_y_dim].min() < y < xr_data[xr_y_dim].max() - - # Get the index into x and y nearest to x_center_geostationary and y_center_geostationary: - x_index_at_center = searchsorted( - xr_data[xr_x_dim].values, center_geostationary.x, assume_ascending=True - ) - - # y_geostationary is in descending order: - y_index_at_center = searchsorted( - xr_data[xr_y_dim].values, center_geostationary.y, assume_ascending=False - ) - - return Location(x=x_index_at_center, y=y_index_at_center, coordinate_system="idx") - - -def _get_points_from_unstructured_grids( - xr_data: xr.DataArray, - location: Location, - location_idx_name: str = "values", - num_points: int = 1, -): - """ - Get the closest points from an unstructured grid (i.e. Icosahedral grid) - - This is primarily used for the Icosahedral grid, which is not a regular grid, - and so is not an image - - Args: - xr_data: Xarray dataset - location: Location of center point - location_idx_name: Name of the index values dimension - (i.e. where we index into to get the lat/lon for that point) - num_points: Number of points to return (should be width * height) - - Returns: - The closest points from the grid - """ - xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) - assert xr_coords == "lon_lat" - - # Check if need to convert from different coordinate system to lat/lon - if location.coordinate_system == "osgb": - longitude, latitude = osgb_to_lon_lat(x=location.x, y=location.y) - location = Location( - x=longitude, - y=latitude, - coordinate_system="lon_lat", - ) - elif location.coordinate_system == "geostationary": - raise NotImplementedError( - "Does not currently support geostationary coordinates when using unstructured grids" - ) - - # Extract lat, lon, and locidx data - lat = xr_data.longitude.values - lon = xr_data.latitude.values - locidx = xr_data[location_idx_name].values - - # Create a KDTree - tree = KDTree(list(zip(lat, lon))) - - # Query with the [longitude, latitude] of your point - _, idx = tree.query([location.x, location.y], k=num_points) - - # Retrieve the location_idxs for these grid points - location_idxs = locidx[idx] - - data = xr_data.sel({location_idx_name: location_idxs}) - return data diff --git a/ocf_datapipes/training/pvnet.py b/ocf_datapipes/training/pvnet.py index 914ab2db4..6b64b9e0b 100644 --- a/ocf_datapipes/training/pvnet.py +++ b/ocf_datapipes/training/pvnet.py @@ -75,42 +75,12 @@ def slice_datapipes_by_space( return -def construct_sliced_data_pipeline( - config_filename: str, - location_pipe: IterDataPipe, - t0_datapipe: IterDataPipe, - production: bool = False, - check_satellite_no_zeros: bool = False, -) -> IterDataPipe: - """Constructs data pipeline for the input data config file. - - This yields samples from the location and time datapipes. - - Args: - config_filename: Path to config file. - location_pipe: Datapipe yielding locations. - t0_datapipe: Datapipe yielding times. - production: Whether constucting pipeline for production inference. - check_satellite_no_zeros: Whether to check that satellite data has no zeros. - """ - - datapipes_dict = _get_datapipes_dict( - config_filename, - production=production, - ) - - configuration = datapipes_dict.pop("config") - +def process_and_combine_datapipes(datapipes_dict, configuration): + """Normalize and convert data to numpy arrays""" + # Unpack for convenience conf_nwp = configuration.input_data.nwp - - # Slice all of the datasets by spce - this is an in-place operation - slice_datapipes_by_space(datapipes_dict, location_pipe, configuration) - - # Slice all of the datasets by time - this is an in-place operation - slice_datapipes_by_time(datapipes_dict, t0_datapipe, configuration, production) - - # Spatially slice, normalize, and convert data to numpy arrays + numpy_modalities = [] # Normalise the inputs and convert to numpy format @@ -158,13 +128,50 @@ def construct_sliced_data_pipeline( logger.debug("Combine all the data sources") combined_datapipe = MergeNumpyModalities(numpy_modalities).add_sun_position(modality_name="gsp") - logger.info("Filtering out samples with no data") + combined_datapipe = combined_datapipe.map(fill_nans_in_arrays) + + return combined_datapipe + + +def construct_sliced_data_pipeline( + config_filename: str, + location_pipe: IterDataPipe, + t0_datapipe: IterDataPipe, + production: bool = False, + check_satellite_no_zeros: bool = False, +) -> IterDataPipe: + """Constructs data pipeline for the input data config file. + + This yields samples from the location and time datapipes. + + Args: + config_filename: Path to config file. + location_pipe: Datapipe yielding locations. + t0_datapipe: Datapipe yielding times. + production: Whether constucting pipeline for production inference. + check_satellite_no_zeros: Whether to check that satellite data has no zeros. + """ + + datapipes_dict = _get_datapipes_dict( + config_filename, + production=production, + ) + + configuration = datapipes_dict.pop("config") + + # Slice all of the datasets by space - this is an in-place operation + slice_datapipes_by_space(datapipes_dict, location_pipe, configuration) + + # Slice all of the datasets by time - this is an in-place operation + slice_datapipes_by_time(datapipes_dict, t0_datapipe, configuration, production) + + # Normalise, and combine the data sources into NumpyBatches + combined_datapipe = process_and_combine_datapipes(datapipes_dict, configuration) + if check_satellite_no_zeros: # in production we don't want any nans in the satellite data combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) - combined_datapipe = combined_datapipe.map(fill_nans_in_arrays) - return combined_datapipe diff --git a/ocf_datapipes/training/pvnet_all_gsp_v2.py b/ocf_datapipes/training/pvnet_all_gsp_v2.py new file mode 100644 index 000000000..5198d1fca --- /dev/null +++ b/ocf_datapipes/training/pvnet_all_gsp_v2.py @@ -0,0 +1,516 @@ +"""Create the training/validation datapipe for training the PVNet Model""" +import logging +from typing import Optional, Type, Tuple, List, Union +from datetime import datetime + +import numpy as np +import xarray as xr +from torch.utils.data.datapipes.datapipe import IterDataPipe +from torch.utils.data.datapipes._decorator import functional_datapipe +from torch.utils.data.datapipes.iter import IterableWrapper +from ocf_datapipes.convert import ( + ConvertNWPToNumpyBatch, + ConvertPVToNumpyBatch, + ConvertSatelliteToNumpyBatch, + ConvertGSPToNumpyBatch, + +) + +from ocf_datapipes.batch import MergeNumpyModalities, MergeNWPNumpyModalities +from ocf_datapipes.batch.merge_numpy_examples_to_batch import stack_np_examples_into_batch +from ocf_datapipes.select.select_spatial_slice import ( + select_spatial_slice_pixels, + select_spatial_slice_meters, +) +from ocf_datapipes.training.common import ( + _get_datapipes_dict, + check_nans_in_satellite_data, + concat_xr_time_utc, + fill_nans_in_arrays, + fill_nans_in_pv, + normalize_gsp, + normalize_pv, + slice_datapipes_by_time, + open_and_return_datapipes, + create_valid_t0_periods_datapipe, +) +from ocf_datapipes.utils.consts import ( + NWP_MEANS, + NWP_STDS, + RSS_MEAN, + RSS_STD, +) + +from ocf_datapipes.config.model import Configuration +from ocf_datapipes.utils.location import Location +from ocf_datapipes.load.gsp.utils import GSPLocationLookup + +xr.set_options(keep_attrs=True) +logger = logging.getLogger("pvnet_all_gsp_datapipe") + + +# ---------------------------------- Utility datapipes --------------------------------- + + +def xr_compute(xr_data): + return xr_data.compute() + + +class SampleRepeat: + """Use a single input element to create a list of identical values""" + + def __init__(self, num_repeats): + """Use a single input element to create a list of identical values + + Args: + num_repeats: Length of the returned list of duplicated values + """ + self.num_repeats = num_repeats + + def __call__(self, x): + return [x for _ in range(self.num_repeats)] + + +class ConvertWrapper(IterDataPipe): + def __init__( + self, + source_datapipe: IterDataPipe, + convert_class: Type[IterDataPipe] + ): + self.source_datapipe = source_datapipe + self.convert_class = convert_class + + def __iter__(self): + for concurrent_samples in self.source_datapipe: + dp = self.convert_class(IterableWrapper(concurrent_samples)) + yield [x for x in dp] + +# ------------------------------ Multi-location datapipes ------------------------------ +# These are datapipes rewritten to run on all GSPs + +@functional_datapipe("select_all_gsp_spatial_slices_pixels") +class SelectAllGSPSpatialSlicePixelsIterDataPipe(IterDataPipe): + """Select all the spatial slices""" + + def __init__( + self, + source_datapipe: IterDataPipe, + locations: List[Location], + roi_height_pixels: int, + roi_width_pixels: int, + allow_partial_slice: bool = False, + location_idx_name: Optional[str] = None, + ): + """ + Select spatial slices for all GSPs + + If `allow_partial_slice` is set to True, then slices may be made which intersect the border + of the input data. The additional x and y cordinates that would be required for this slice + are extrapolated based on the average spacing of these coordinates in the input data. + However, currently slices cannot be made where the centre of the window is outside of the + input data. + + Args: + source_datapipe: Datapipe of Xarray data + roi_height_pixels: ROI height in pixels + roi_width_pixels: ROI width in pixels + allow_partial_slice: Whether to allow a partial slice. + location_idx_name: Name for location index of unstructured grid data, + None if not relevant + """ + self.source_datapipe = source_datapipe + self.locations = locations + self.roi_height_pixels = roi_height_pixels + self.roi_width_pixels = roi_width_pixels + self.allow_partial_slice = allow_partial_slice + self.location_idx_name = location_idx_name + + def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: + for xr_data in self.source_datapipe: + + loc_slices = [] + + for location in self.locations: + + selected = select_spatial_slice_pixels( + xr_data, + location, + self.roi_width_pixels, + self.roi_height_pixels, + self.allow_partial_slice, + self.location_idx_name, + ) + + loc_slices.append(selected) + + yield loc_slices + + +@functional_datapipe("select_all_gsp_spatial_slice_meters") +class SelectAllGSPSpatialSliceMetersIterDataPipe(IterDataPipe): + """Select spatial slice based off meters from point of interest""" + + def __init__( + self, + source_datapipe: IterDataPipe, + locations: List[Location], + roi_height_meters: int, + roi_width_meters: int, + dim_name: Optional[str] = None, + ): + """ + Select spatial slice based off pixels from point of interest + + Args: + source_datapipe: Datapipe of Xarray data + location_datapipe: Location datapipe + roi_width_meters: ROI width in meters + roi_height_meters: ROI height in meters + dim_name: Dimension name to select for ID, None for coordinates + + Notes: + Using spatial slicing based on distance rather than number of pixels will often yield + slices which can vary by 1 pixel in height and/or width. + + E.g. Suppose the Xarray data has x-coords = [1,2,3,4,5]. We want to slice a spatial + window with a size which equates to 2.2 along the x-axis. If we choose to slice around + the point x=3 this will slice out the x-coords [2,3,4]. If we choose to slice around the + point x=2.5 this will slice out the x-coords [2,3]. Hence the returned slice can have + size either 2 or 3 in the x-axis depending on the spatial location selected. + + Also, if selecting over a large span of latitudes, this may also causes pixel sizes of + the yielded outputs to change. For example, if the Xarray data is on a regularly spaced + longitude-latitude grid, then the structure of the grid means that the longitudes near + to the poles are spaced closer together (measured in meters) than at the equator. So + slices near the equator will have less pixels in the x-axis than slices taken near the + poles. + """ + self.source_datapipe = source_datapipe + self.locations = locations + self.roi_width_meters = roi_width_meters + self.roi_height_meters = roi_height_meters + self.dim_name = dim_name + + def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: + for xr_data in self.source_datapipe: + loc_slices = [] + + for location in self.locations: + + selected = select_spatial_slice_meters( + xr_data=xr_data, + location=location, + roi_width_meters=self.roi_width_meters, + roi_height_meters=self.roi_height_meters, + dim_name=self.dim_name, + ) + + loc_slices.append(selected) + + yield loc_slices + + +# ------------------------------- Time pipeline functions ------------------------------ + + +def construct_time_pipeline( + config_filename: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, +) -> Tuple[IterDataPipe, IterDataPipe]: + """Construct time pipeline for the input data config file. + + Args: + config_filename: Path to config file. + start_time: Minimum time for time datapipe. + end_time: Maximum time for time datapipe. + """ + + datapipes_dict = _get_datapipes_dict(config_filename) + + # Get config + config = datapipes_dict.pop("config") + + if (start_time is not None) or (end_time is not None): + datapipes_dict["gsp"] = datapipes_dict["gsp"].filter_times(start_time, end_time) + + # Get overlapping time periods + t0_datapipe = create_t0_datapipe( + datapipes_dict, + configuration=config, + shuffle=True, + ) + + return t0_datapipe + + +def create_t0_datapipe( + datapipes_dict: dict, + configuration: Configuration, + shuffle: bool = True, +): + """ + Takes source datapipes and returns datapipes of appropriate t0 times. + + The t0 times are sampled without replacement. + + Args: + datapipes_dict: Dictionary of datapipes of input sources for which we want to select + appropriate t0 times. + configuration: Configuration object for inputs. + shuffle: Whether to use the internal shuffle function when yielding times. Else + location times will be heavily ordered. + + Returns: + t0 datapipe + + """ + valid_t0_periods_datapipe = create_valid_t0_periods_datapipe( + datapipes_dict, + configuration, + key_for_t0="gsp", + ) + + t0_datapipe = valid_t0_periods_datapipe.pick_t0_times(return_all=True, shuffle=shuffle) + + return t0_datapipe + + +# ------------------------------- Space pipeline functions ----------------------------- + + +def slice_datapipes_by_space_all_gsps( + datapipes_dict: dict, + locations: list[Location], + configuration: Configuration, +) -> None: + + conf_nwp = configuration.input_data.nwp + conf_sat = configuration.input_data.satellite + + if "nwp" in datapipes_dict: + + for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): + + datapipes_dict["nwp"][nwp_key] = nwp_datapipe.select_all_gsp_spatial_slices_pixels( + locations, + roi_width_pixels=conf_nwp[nwp_key].nwp_image_size_pixels_width, + roi_height_pixels=conf_nwp[nwp_key].nwp_image_size_pixels_height, + ) + + if "sat" in datapipes_dict: + + datapipes_dict["sat"] = datapipes_dict["sat"].select_all_gsp_spatial_slices_pixels( + locations, + roi_width_pixels=conf_sat.satellite_image_size_pixels_width, + roi_height_pixels=conf_sat.satellite_image_size_pixels_height, + ) + + if "pv" in datapipes_dict: + # No spatial slice for PV since it is always the same, just repeat for GSPs + pv_datapipe = pv_datapipe.map(SampleRepeat(len(locations))) + + + # GSP always assumed to be in data + datapipes_dict["gsp"] = datapipes_dict["gsp"].select_all_gsp_spatial_slice_meters( + locations, + roi_width_meters=1, + roi_height_meters=1, + dim_name="gsp_id", + ) + + +# -------------------------------- Processing functions -------------------------------- + + +def pre_spatial_slice_process(datapipes_dict, configuration): + + conf_nwp = configuration.input_data.nwp + + if "nwp" in datapipes_dict: + + for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): + + datapipes_dict["nwp"][nwp_key] = ( + nwp_datapipe + .map(xr_compute) + .normalize( + mean=NWP_MEANS[conf_nwp[nwp_key].nwp_provider], + std=NWP_STDS[conf_nwp[nwp_key].nwp_provider], + ) + ) + + if "sat" in datapipes_dict: + + datapipes_dict["sat"] = ( + datapipes_dict["sat"] + .map(xr_compute) + .normalize(mean=RSS_MEAN, std=RSS_STD) + ) + + if "pv" in datapipes_dict: + # Recombine PV arrays - see function doc for further explanation + datapipes_dict["pv"] = ( + datapipes_dict["pv"] + .zip_ocf(datapipes_dict["pv_future"]) + .map(concat_xr_time_utc) + .map(normalize_pv) + .map(fill_nans_in_pv) + ) + + # GSP always assumed to be in data + # Recombine GSP arrays - see function doc for further explanation + datapipes_dict["gsp"] = ( + datapipes_dict["gsp"] + .zip_ocf(datapipes_dict["gsp_future"]) + .map(concat_xr_time_utc) + .map(normalize_gsp) + ) + + +def post_spatial_slice_process(datapipes_dict): + # Spatially slice, normalize, and convert data to numpy arrays + numpy_modalities = [] + + if "nwp" in datapipes_dict: + nwp_numpy_modalities = dict() + + for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): + + nwp_numpy_modalities[nwp_key] = ( + ConvertWrapper( + nwp_datapipe, + ConvertNWPToNumpyBatch, + ) + .map(stack_np_examples_into_batch) + ) + + # Combine the NWPs into NumpyBatch + nwp_numpy_modalities = MergeNWPNumpyModalities(nwp_numpy_modalities) + numpy_modalities.append(nwp_numpy_modalities) + + if "sat" in datapipes_dict: + + numpy_modalities.append( + ConvertWrapper(datapipes_dict["sat"], ConvertSatelliteToNumpyBatch) + .map(stack_np_examples_into_batch) + ) + + if "pv" in datapipes_dict: + + numpy_modalities.append( + ConvertWrapper(datapipes_dict["pv"], ConvertPVToNumpyBatch) + .map(stack_np_examples_into_batch) + ) + + # GSP always assumed to be in data + numpy_modalities.append( + ConvertWrapper(datapipes_dict["gsp"], ConvertGSPToNumpyBatch) + .map(stack_np_examples_into_batch) + ) + + # Combine all the data sources + combined_datapipe = ( + MergeNumpyModalities(numpy_modalities) + .add_sun_position(modality_name="gsp") + .map(fill_nans_in_arrays) + ) + + return combined_datapipe + + +# --------------------------- High level pipeline functions ---------------------------- + + +def construct_sliced_data_pipeline( + config_filename: str, + t0_datapipe: IterDataPipe, + production: bool = False, + check_satellite_no_zeros: bool = False, +) -> IterDataPipe: + """Constructs data pipeline for the input data config file. + + This yields samples from the location and time datapipes. + + Args: + config_filename: Path to config file. + t0_datapipe: Datapipe yielding times. + production: Whether constucting pipeline for production inference. + check_satellite_no_zeros: Whether to check that satellite data has no zeros. + """ + + datapipes_dict = _get_datapipes_dict( + config_filename, + production=production, + ) + + # Get the location objects for all 317 regional GSPs + gsp_id_to_loc = GSPLocationLookup() + locations = [gsp_id_to_loc(gsp_id) for gsp_id in range(1, 318)] + + # Pop config + configuration = datapipes_dict.pop("config") + + # Slice all of the datasets by time - this is an in-place operation + slice_datapipes_by_time(datapipes_dict, t0_datapipe, configuration, production) + + # Run compute and normalise all the data + pre_spatial_slice_process(datapipes_dict, configuration) + + # Slice all of the datasets by space - this is an in-place operation + slice_datapipes_by_space_all_gsps(datapipes_dict, locations, configuration) + + # Convert to NumpyBatch + combined_datapipe = post_spatial_slice_process(datapipes_dict) + + if check_satellite_no_zeros: + # in production we don't want any nans in the satellite data + combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) + + return combined_datapipe + + +def pvnet_all_gsp_datapipe( + config_filename: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, +) -> IterDataPipe: + """ + Construct pvnet pipeline for the input data config file. + + Args: + config_filename: Path to config file. + start_time: Minimum time at which a sample can be selected. + end_time: Maximum time at which a sample can be selected. + """ + + # Open datasets from the config and filter to useable times + t0_datapipe = construct_time_pipeline( + config_filename, + start_time, + end_time, + ) + + # Shard after we have the times. These are already shuffled so no need to shuffle again + t0_datapipe = t0_datapipe.sharding_filter() + + # In this function we re-open the datasets to make a clean separation before/after sharding + # This function + datapipe = construct_sliced_data_pipeline( + config_filename, + t0_datapipe, + ) + + return datapipe + + +if __name__=="__main__": + import time + + t0 = time.time() + dp = pvnet_all_gsp_datapipe( + config_filename="/home/jamesfulton/repos/PVNet/configs/datamodule/configuration/gcp_configuration.yaml" + ) + + b = next(iter(dp)) + print(time.time() - t0) diff --git a/ocf_datapipes/utils/location.py b/ocf_datapipes/utils/location.py index 653501e5f..440bfdfc5 100644 --- a/ocf_datapipes/utils/location.py +++ b/ocf_datapipes/utils/location.py @@ -2,6 +2,7 @@ from typing import Optional import numpy as np +import xarray as xr from pydantic import BaseModel, Field, validator @@ -59,4 +60,4 @@ def validate_y(cls, v, values): min_y, max_y = 0, np.inf if v < min_y or v > max_y: raise ValueError(f"y = {v} must be within {[min_y, max_y]} for {co} coordinate system") - return v + return v \ No newline at end of file From 3fa9e401c5863cba9a6a31343ad9477bb5870acb Mon Sep 17 00:00:00 2001 From: James Fulton Date: Fri, 31 May 2024 12:06:50 +0000 Subject: [PATCH 08/27] sub in new pipeline --- ocf_datapipes/training/pvnet_all_gsp.py | 602 +++++++++------------ ocf_datapipes/training/pvnet_all_gsp_v2.py | 516 ------------------ 2 files changed, 254 insertions(+), 864 deletions(-) delete mode 100644 ocf_datapipes/training/pvnet_all_gsp_v2.py diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py index f27cbd892..5198d1fca 100644 --- a/ocf_datapipes/training/pvnet_all_gsp.py +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -3,6 +3,7 @@ from typing import Optional, Type, Tuple, List, Union from datetime import datetime +import numpy as np import xarray as xr from torch.utils.data.datapipes.datapipe import IterDataPipe from torch.utils.data.datapipes._decorator import functional_datapipe @@ -17,6 +18,10 @@ from ocf_datapipes.batch import MergeNumpyModalities, MergeNWPNumpyModalities from ocf_datapipes.batch.merge_numpy_examples_to_batch import stack_np_examples_into_batch +from ocf_datapipes.select.select_spatial_slice import ( + select_spatial_slice_pixels, + select_spatial_slice_meters, +) from ocf_datapipes.training.common import ( _get_datapipes_dict, check_nans_in_satellite_data, @@ -26,7 +31,6 @@ normalize_gsp, normalize_pv, slice_datapipes_by_time, - minutes, open_and_return_datapipes, create_valid_t0_periods_datapipe, ) @@ -36,51 +40,21 @@ RSS_MEAN, RSS_STD, ) -import numpy as np from ocf_datapipes.config.model import Configuration -from ocf_datapipes.utils import Location - -from ocf_datapipes.utils.geospatial import ( - move_lon_lat_by_meters, - spatial_coord_type, -) -from ocf_datapipes.select.select_spatial_slice import ( - _get_idx_of_pixel_closest_to_poi, - _get_idx_of_pixel_closest_to_poi_geostationary, - _get_points_from_unstructured_grids, - convert_coords_to_match_xarray, - select_spatial_slice_pixels, -) - +from ocf_datapipes.utils.location import Location +from ocf_datapipes.load.gsp.utils import GSPLocationLookup xr.set_options(keep_attrs=True) logger = logging.getLogger("pvnet_all_gsp_datapipe") -class SampleFunction: - """Apply a function to each sample in the concurrent batch list""" - - def __init__(self, function): - """Apply a function to each sample in the concurrent batch list - - Args: - function: Function to apply to each sample - """ - self.function = function +# ---------------------------------- Utility datapipes --------------------------------- - def __call__(self, sample_list): - return [self.function(sample) for sample in sample_list] +def xr_compute(xr_data): + return xr_data.compute() -class ZipFunction: - def __init__(self, function): - self.function = function - - def __call__(self, zipped_sample_list): - sample_lists = [sample_list for sample_list in zipped_sample_list] - return [self.function(sample) for sample in zip(*sample_lists)] - class SampleRepeat: """Use a single input element to create a list of identical values""" @@ -95,103 +69,24 @@ def __init__(self, num_repeats): def __call__(self, x): return [x for _ in range(self.num_repeats)] + +class ConvertWrapper(IterDataPipe): + def __init__( + self, + source_datapipe: IterDataPipe, + convert_class: Type[IterDataPipe] + ): + self.source_datapipe = source_datapipe + self.convert_class = convert_class -class GSPLocationLookup: - """Query object for GSP location from GSP ID""" - - def __init__(self, x_osgb: xr.DataArray, y_osgb: xr.DataArray): - """Query object for GSP location from GSP ID - - Args: - x_osgb: DataArray of the OSGB x-coordinate for any given GSP ID - y_osgb: DataArray of the OSGB y-coordinate for any given GSP ID - - """ - self.x_osgb = x_osgb - self.y_osgb = y_osgb - - def __call__(self, gsp_id: int) -> Location: - """Returns the locations for the input GSP IDs. - - Args: - gsp_id: Integer ID of the GSP - """ - return Location( - x=self.x_osgb.sel(gsp_id=gsp_id).item(), - y=self.y_osgb.sel(gsp_id=gsp_id).item(), - id=gsp_id, - ) - - -def create_t0_datapipe( - datapipes_dict: dict, - configuration: Configuration, - shuffle: bool = True, -): - """ - Takes source datapipes and returns datapipes of appropriate t0 times. - - The t0 times are sampled without replacement. - - Args: - datapipes_dict: Dictionary of datapipes of input sources for which we want to select - appropriate t0 times. - configuration: Configuration object for inputs. - shuffle: Whether to use the internal shuffle function when yielding times. Else - location times will be heavily ordered. - - Returns: - t0 datapipe - - """ - valid_t0_periods_datapipe = create_valid_t0_periods_datapipe( - datapipes_dict, - configuration, - key_for_t0="gsp", - ) - - t0_datapipe = valid_t0_periods_datapipe.pick_t0_times(return_all=True, shuffle=shuffle) - - return t0_datapipe - - -def construct_time_pipeline( - config_filename: str, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, -) -> Tuple[IterDataPipe, IterDataPipe]: - """Construct time pipeline for the input data config file. - - Args: - config_filename: Path to config file. - start_time: Minimum time for time datapipe. - end_time: Maximum time for time datapipe. - """ - - datapipes_dict = _get_datapipes_dict( - config_filename, - ) - - # Pull out config file - config = datapipes_dict.pop("config") - - if (start_time is not None) or (end_time is not None): - datapipes_dict["gsp"] = datapipes_dict["gsp"].filter_times(start_time, end_time) - - # Get overlapping time periods - t0_datapipe = create_t0_datapipe( - datapipes_dict, - configuration=config, - shuffle=True, - ) - - return t0_datapipe - - -def xr_compute(xr_data): - return xr_data.compute() + def __iter__(self): + for concurrent_samples in self.source_datapipe: + dp = self.convert_class(IterableWrapper(concurrent_samples)) + yield [x for x in dp] +# ------------------------------ Multi-location datapipes ------------------------------ +# These are datapipes rewritten to run on all GSPs @functional_datapipe("select_all_gsp_spatial_slices_pixels") class SelectAllGSPSpatialSlicePixelsIterDataPipe(IterDataPipe): @@ -207,7 +102,7 @@ def __init__( location_idx_name: Optional[str] = None, ): """ - Select spatial slice based off pixels from point of interest + Select spatial slices for all GSPs If `allow_partial_slice` is set to True, then slices may be made which intersect the border of the input data. The additional x and y cordinates that would be required for this slice @@ -232,48 +127,25 @@ def __init__( def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: for xr_data in self.source_datapipe: - xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) loc_slices = [] for location in self.locations: - - if self.location_idx_name is not None: - selected = _get_points_from_unstructured_grids( - xr_data=xr_data, - location=location, - location_idx_name=self.location_idx_name, - num_points=self.roi_width_pixels * self.roi_height_pixels, - ) - yield selected - - if xr_coords == "geostationary": - center_idx: Location = _get_idx_of_pixel_closest_to_poi_geostationary( - xr_data=xr_data, - center_osgb=location, - ) - else: - center_idx: Location = _get_idx_of_pixel_closest_to_poi( - xr_data=xr_data, - location=location, - ) selected = select_spatial_slice_pixels( xr_data, - center_idx, - self.roi_width_pixels, - self.roi_height_pixels, - xr_x_dim, - xr_y_dim, - allow_partial_slice=self.allow_partial_slice, + location, + self.roi_width_pixels, + self.roi_height_pixels, + self.allow_partial_slice, + self.location_idx_name, ) loc_slices.append(selected) yield loc_slices - - - + + @functional_datapipe("select_all_gsp_spatial_slice_meters") class SelectAllGSPSpatialSliceMetersIterDataPipe(IterDataPipe): """Select spatial slice based off meters from point of interest""" @@ -284,7 +156,7 @@ def __init__( locations: List[Location], roi_height_meters: int, roi_width_meters: int, - dim_name: Optional[str] = None, # "pv_system_id", + dim_name: Optional[str] = None, ): """ Select spatial slice based off pixels from point of interest @@ -292,8 +164,8 @@ def __init__( Args: source_datapipe: Datapipe of Xarray data location_datapipe: Location datapipe - roi_height_meters: ROI height in meters roi_width_meters: ROI width in meters + roi_height_meters: ROI height in meters dim_name: Dimension name to select for ID, None for coordinates Notes: @@ -315,8 +187,8 @@ def __init__( """ self.source_datapipe = source_datapipe self.locations = locations - self.roi_height_meters = roi_height_meters self.roi_width_meters = roi_width_meters + self.roi_height_meters = roi_height_meters self.dim_name = dim_name def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: @@ -325,244 +197,276 @@ def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: for location in self.locations: - # Get the spatial coords of the xarray data - xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) - - half_height = self.roi_height_meters // 2 - half_width = self.roi_width_meters // 2 - - # Find the bounding box values for the location in either lat-lon or OSGB coord systems - if location.coordinate_system == "lon_lat": - right, top = move_lon_lat_by_meters( - location.x, - location.y, - half_width, - half_height, - ) - left, bottom = move_lon_lat_by_meters( - location.x, - location.y, - -half_width, - -half_height, - ) - - elif location.coordinate_system == "osgb": - left = location.x - half_width - right = location.x + half_width - bottom = location.y - half_height - top = location.y + half_height - - else: - raise ValueError( - f"Location coord system not recognized: {location.coordinate_system}" - ) - - # Change the bounding coordinates [left, right, bottom, top] to the same - # coordinate system as the xarray data - (left, right), (bottom, top) = convert_coords_to_match_xarray( - x=np.array([left, right], dtype=np.float32), - y=np.array([bottom, top], dtype=np.float32), - from_coords=location.coordinate_system, - xr_data=xr_data, + selected = select_spatial_slice_meters( + xr_data=xr_data, + location=location, + roi_width_meters=self.roi_width_meters, + roi_height_meters=self.roi_height_meters, + dim_name=self.dim_name, ) - # Do it off coordinates, not ID - if self.dim_name is None: - # Select a patch from the xarray data - x_mask = (left <= xr_data[xr_x_dim]) & (xr_data[xr_x_dim] <= right) - y_mask = (bottom <= xr_data[xr_y_dim]) & (xr_data[xr_y_dim] <= top) - selected = xr_data.isel({xr_x_dim: x_mask, xr_y_dim: y_mask}) - - else: - # Select data in the region of interest and ID: - # This also works for unstructured grids - - id_mask = ( - (left <= xr_data[xr_x_dim]) - & (xr_data[xr_x_dim] <= right) - & (bottom <= xr_data[xr_y_dim]) - & (xr_data[xr_y_dim] <= top) - ) - selected = xr_data.isel({self.dim_name: id_mask}) - loc_slices.append(selected) + yield loc_slices -class ConvertWrapper(IterDataPipe): - def __init__( - self, - source_datapipe: IterDataPipe, - convert_class: Type[IterDataPipe] - ): - self.source_datapipe = source_datapipe - self.convert_class = convert_class - - def __iter__(self): - for concurrent_samples in self.source_datapipe: - dp = self.convert_class(IterableWrapper(concurrent_samples)) - stacked_converted_values = stack_np_examples_into_batch([x for x in iter(dp)]) - yield stacked_converted_values +# ------------------------------- Time pipeline functions ------------------------------ -def construct_sliced_data_pipeline( +def construct_time_pipeline( config_filename: str, - t0_datapipe: IterDataPipe, - production: bool = False, - check_satellite_no_zeros: bool = False, -) -> IterDataPipe: - """Constructs data pipeline for the input data config file. - - This yields samples from the location and time datapipes. + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, +) -> Tuple[IterDataPipe, IterDataPipe]: + """Construct time pipeline for the input data config file. Args: config_filename: Path to config file. - t0_datapipe: Datapipe yielding times. - production: Whether constucting pipeline for production inference. - check_satellite_no_zeros: Whether to check that satellite data has no zeros. + start_time: Minimum time for time datapipe. + end_time: Maximum time for time datapipe. """ - datapipes_dict = _get_datapipes_dict( - config_filename, - production=production, - ) - - ds_gsp = next( - iter( - open_and_return_datapipes( - config_filename, - use_gsp=True, - use_nwp=False, - use_pv=False, - use_sat=False, - use_hrv=False, - use_topo=False, - )["gsp"] - ) + datapipes_dict = _get_datapipes_dict(config_filename) + + # Get config + config = datapipes_dict.pop("config") + + if (start_time is not None) or (end_time is not None): + datapipes_dict["gsp"] = datapipes_dict["gsp"].filter_times(start_time, end_time) + + # Get overlapping time periods + t0_datapipe = create_t0_datapipe( + datapipes_dict, + configuration=config, + shuffle=True, ) - - gsp_id_to_loc = GSPLocationLookup(ds_gsp.x_osgb, ds_gsp.y_osgb) - - locations = [gsp_id_to_loc(gsp_id) for gsp_id in range(1, 318)] + return t0_datapipe - configuration = datapipes_dict.pop("config") - # Unpack for convenience - conf_sat = configuration.input_data.satellite - conf_nwp = configuration.input_data.nwp +def create_t0_datapipe( + datapipes_dict: dict, + configuration: Configuration, + shuffle: bool = True, +): + """ + Takes source datapipes and returns datapipes of appropriate t0 times. - # Slice all of the datasets by time - this is an in-place operation - slice_datapipes_by_time(datapipes_dict, t0_datapipe, configuration, production) + The t0 times are sampled without replacement. - # Spatially slice, normalize, and convert data to numpy arrays - numpy_modalities = [] + Args: + datapipes_dict: Dictionary of datapipes of input sources for which we want to select + appropriate t0 times. + configuration: Configuration object for inputs. + shuffle: Whether to use the internal shuffle function when yielding times. Else + location times will be heavily ordered. - if "nwp" in datapipes_dict: - nwp_numpy_modalities = dict() + Returns: + t0 datapipe - for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): + """ + valid_t0_periods_datapipe = create_valid_t0_periods_datapipe( + datapipes_dict, + configuration, + key_for_t0="gsp", + ) - nwp_datapipe = nwp_datapipe.map(xr_compute) + t0_datapipe = valid_t0_periods_datapipe.pick_t0_times(return_all=True, shuffle=shuffle) - nwp_datapipe = nwp_datapipe.normalize( - mean=NWP_MEANS[conf_nwp[nwp_key].nwp_provider], - std=NWP_STDS[conf_nwp[nwp_key].nwp_provider], - ) + return t0_datapipe + + +# ------------------------------- Space pipeline functions ----------------------------- + + +def slice_datapipes_by_space_all_gsps( + datapipes_dict: dict, + locations: list[Location], + configuration: Configuration, +) -> None: + + conf_nwp = configuration.input_data.nwp + conf_sat = configuration.input_data.satellite + + if "nwp" in datapipes_dict: + + for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): - nwp_datapipe = nwp_datapipe.select_all_gsp_spatial_slices_pixels( + datapipes_dict["nwp"][nwp_key] = nwp_datapipe.select_all_gsp_spatial_slices_pixels( locations, - roi_height_pixels=conf_nwp[nwp_key].nwp_image_size_pixels_height, roi_width_pixels=conf_nwp[nwp_key].nwp_image_size_pixels_width, + roi_height_pixels=conf_nwp[nwp_key].nwp_image_size_pixels_height, ) - - - nwp_numpy_modalities[nwp_key] = ConvertWrapper( - nwp_datapipe, - ConvertNWPToNumpyBatch, - ) - - # Combine the NWPs into NumpyBatch - nwp_numpy_modalities = MergeNWPNumpyModalities(nwp_numpy_modalities) - numpy_modalities.append(nwp_numpy_modalities) if "sat" in datapipes_dict: - sat_datapipe = datapipes_dict["sat"] - - sat_datapipe = sat_datapipe.map(xr_compute) - sat_datapipe = sat_datapipe.normalize(mean=RSS_MEAN, std=RSS_STD) - - sat_datapipe = sat_datapipe.select_all_gsp_spatial_slices_pixels( + datapipes_dict["sat"] = datapipes_dict["sat"].select_all_gsp_spatial_slices_pixels( locations, - roi_height_pixels=conf_sat.satellite_image_size_pixels_height, roi_width_pixels=conf_sat.satellite_image_size_pixels_width, + roi_height_pixels=conf_sat.satellite_image_size_pixels_height, ) - - numpy_modalities.append( - ConvertWrapper( - sat_datapipe, - ConvertSatelliteToNumpyBatch, + if "pv" in datapipes_dict: + # No spatial slice for PV since it is always the same, just repeat for GSPs + pv_datapipe = pv_datapipe.map(SampleRepeat(len(locations))) + + + # GSP always assumed to be in data + datapipes_dict["gsp"] = datapipes_dict["gsp"].select_all_gsp_spatial_slice_meters( + locations, + roi_width_meters=1, + roi_height_meters=1, + dim_name="gsp_id", + ) + + +# -------------------------------- Processing functions -------------------------------- + + +def pre_spatial_slice_process(datapipes_dict, configuration): + + conf_nwp = configuration.input_data.nwp + + if "nwp" in datapipes_dict: + + for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): + + datapipes_dict["nwp"][nwp_key] = ( + nwp_datapipe + .map(xr_compute) + .normalize( + mean=NWP_MEANS[conf_nwp[nwp_key].nwp_provider], + std=NWP_STDS[conf_nwp[nwp_key].nwp_provider], + ) ) + + if "sat" in datapipes_dict: + + datapipes_dict["sat"] = ( + datapipes_dict["sat"] + .map(xr_compute) + .normalize(mean=RSS_MEAN, std=RSS_STD) ) if "pv" in datapipes_dict: # Recombine PV arrays - see function doc for further explanation - # No spatial slice for PV since it is always the same - pv_datapipe = ( + datapipes_dict["pv"] = ( datapipes_dict["pv"] .zip_ocf(datapipes_dict["pv_future"]) .map(concat_xr_time_utc) + .map(normalize_pv) + .map(fill_nans_in_pv) ) - pv_datapipe = pv_datapipe.map(normalize_pv) - pv_datapipe = pv_datapipe.map(fill_nans_in_pv) - pv_datapipe = pv_datapipe.map(SampleRepeat(317)) + # GSP always assumed to be in data + # Recombine GSP arrays - see function doc for further explanation + datapipes_dict["gsp"] = ( + datapipes_dict["gsp"] + .zip_ocf(datapipes_dict["gsp_future"]) + .map(concat_xr_time_utc) + .map(normalize_gsp) + ) + - numpy_modalities.append( - ConvertWrapper( - pv_datapipe, - ConvertPVToNumpyBatch, +def post_spatial_slice_process(datapipes_dict): + # Spatially slice, normalize, and convert data to numpy arrays + numpy_modalities = [] + + if "nwp" in datapipes_dict: + nwp_numpy_modalities = dict() + + for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): + + nwp_numpy_modalities[nwp_key] = ( + ConvertWrapper( + nwp_datapipe, + ConvertNWPToNumpyBatch, + ) + .map(stack_np_examples_into_batch) ) + + # Combine the NWPs into NumpyBatch + nwp_numpy_modalities = MergeNWPNumpyModalities(nwp_numpy_modalities) + numpy_modalities.append(nwp_numpy_modalities) + + if "sat" in datapipes_dict: + + numpy_modalities.append( + ConvertWrapper(datapipes_dict["sat"], ConvertSatelliteToNumpyBatch) + .map(stack_np_examples_into_batch) ) - # GSP always assumed to be in data - #location_pipe, location_pipe_copy = location_pipe.fork(2, buffer_size=5) - gsp_future_datapipe = datapipes_dict["gsp_future"] - gsp_future_datapipe = gsp_future_datapipe.select_all_gsp_spatial_slice_meters( - locations, - roi_height_meters=1, - roi_width_meters=1, - dim_name="gsp_id", - ) + if "pv" in datapipes_dict: + + numpy_modalities.append( + ConvertWrapper(datapipes_dict["pv"], ConvertPVToNumpyBatch) + .map(stack_np_examples_into_batch) + ) - gsp_datapipe = datapipes_dict["gsp"] - gsp_datapipe = gsp_datapipe.select_all_gsp_spatial_slice_meters( - locations, - roi_height_meters=1, - roi_width_meters=1, - dim_name="gsp_id", + # GSP always assumed to be in data + numpy_modalities.append( + ConvertWrapper(datapipes_dict["gsp"], ConvertGSPToNumpyBatch) + .map(stack_np_examples_into_batch) ) - # Recombine GSP arrays - see function doc for further explanation - gsp_datapipe = ( - gsp_datapipe - .zip_ocf(gsp_future_datapipe) - .map(ZipFunction(concat_xr_time_utc)) - .map(SampleFunction(normalize_gsp)) + # Combine all the data sources + combined_datapipe = ( + MergeNumpyModalities(numpy_modalities) + .add_sun_position(modality_name="gsp") + .map(fill_nans_in_arrays) ) - numpy_modalities.append( - ConvertWrapper( - gsp_datapipe, - ConvertGSPToNumpyBatch, - ) + return combined_datapipe + + +# --------------------------- High level pipeline functions ---------------------------- + + +def construct_sliced_data_pipeline( + config_filename: str, + t0_datapipe: IterDataPipe, + production: bool = False, + check_satellite_no_zeros: bool = False, +) -> IterDataPipe: + """Constructs data pipeline for the input data config file. + + This yields samples from the location and time datapipes. + + Args: + config_filename: Path to config file. + t0_datapipe: Datapipe yielding times. + production: Whether constucting pipeline for production inference. + check_satellite_no_zeros: Whether to check that satellite data has no zeros. + """ + + datapipes_dict = _get_datapipes_dict( + config_filename, + production=production, ) + + # Get the location objects for all 317 regional GSPs + gsp_id_to_loc = GSPLocationLookup() + locations = [gsp_id_to_loc(gsp_id) for gsp_id in range(1, 318)] - logger.debug("Combine all the data sources") - combined_datapipe = MergeNumpyModalities(numpy_modalities).add_sun_position(modality_name="gsp") + # Pop config + configuration = datapipes_dict.pop("config") + # Slice all of the datasets by time - this is an in-place operation + slice_datapipes_by_time(datapipes_dict, t0_datapipe, configuration, production) + + # Run compute and normalise all the data + pre_spatial_slice_process(datapipes_dict, configuration) + + # Slice all of the datasets by space - this is an in-place operation + slice_datapipes_by_space_all_gsps(datapipes_dict, locations, configuration) + + # Convert to NumpyBatch + combined_datapipe = post_spatial_slice_process(datapipes_dict) + + if check_satellite_no_zeros: + # in production we don't want any nans in the satellite data + combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) + return combined_datapipe @@ -579,14 +483,16 @@ def pvnet_all_gsp_datapipe( start_time: Minimum time at which a sample can be selected. end_time: Maximum time at which a sample can be selected. """ - logger.info("Constructing pvnet pipeline") - # Open datasets from the config and filter to useable location-time pairs + # Open datasets from the config and filter to useable times t0_datapipe = construct_time_pipeline( config_filename, start_time, end_time, ) + + # Shard after we have the times. These are already shuffled so no need to shuffle again + t0_datapipe = t0_datapipe.sharding_filter() # In this function we re-open the datasets to make a clean separation before/after sharding # This function @@ -607,4 +513,4 @@ def pvnet_all_gsp_datapipe( ) b = next(iter(dp)) - print(time.time() - t0) \ No newline at end of file + print(time.time() - t0) diff --git a/ocf_datapipes/training/pvnet_all_gsp_v2.py b/ocf_datapipes/training/pvnet_all_gsp_v2.py deleted file mode 100644 index 5198d1fca..000000000 --- a/ocf_datapipes/training/pvnet_all_gsp_v2.py +++ /dev/null @@ -1,516 +0,0 @@ -"""Create the training/validation datapipe for training the PVNet Model""" -import logging -from typing import Optional, Type, Tuple, List, Union -from datetime import datetime - -import numpy as np -import xarray as xr -from torch.utils.data.datapipes.datapipe import IterDataPipe -from torch.utils.data.datapipes._decorator import functional_datapipe -from torch.utils.data.datapipes.iter import IterableWrapper -from ocf_datapipes.convert import ( - ConvertNWPToNumpyBatch, - ConvertPVToNumpyBatch, - ConvertSatelliteToNumpyBatch, - ConvertGSPToNumpyBatch, - -) - -from ocf_datapipes.batch import MergeNumpyModalities, MergeNWPNumpyModalities -from ocf_datapipes.batch.merge_numpy_examples_to_batch import stack_np_examples_into_batch -from ocf_datapipes.select.select_spatial_slice import ( - select_spatial_slice_pixels, - select_spatial_slice_meters, -) -from ocf_datapipes.training.common import ( - _get_datapipes_dict, - check_nans_in_satellite_data, - concat_xr_time_utc, - fill_nans_in_arrays, - fill_nans_in_pv, - normalize_gsp, - normalize_pv, - slice_datapipes_by_time, - open_and_return_datapipes, - create_valid_t0_periods_datapipe, -) -from ocf_datapipes.utils.consts import ( - NWP_MEANS, - NWP_STDS, - RSS_MEAN, - RSS_STD, -) - -from ocf_datapipes.config.model import Configuration -from ocf_datapipes.utils.location import Location -from ocf_datapipes.load.gsp.utils import GSPLocationLookup - -xr.set_options(keep_attrs=True) -logger = logging.getLogger("pvnet_all_gsp_datapipe") - - -# ---------------------------------- Utility datapipes --------------------------------- - - -def xr_compute(xr_data): - return xr_data.compute() - - -class SampleRepeat: - """Use a single input element to create a list of identical values""" - - def __init__(self, num_repeats): - """Use a single input element to create a list of identical values - - Args: - num_repeats: Length of the returned list of duplicated values - """ - self.num_repeats = num_repeats - - def __call__(self, x): - return [x for _ in range(self.num_repeats)] - - -class ConvertWrapper(IterDataPipe): - def __init__( - self, - source_datapipe: IterDataPipe, - convert_class: Type[IterDataPipe] - ): - self.source_datapipe = source_datapipe - self.convert_class = convert_class - - def __iter__(self): - for concurrent_samples in self.source_datapipe: - dp = self.convert_class(IterableWrapper(concurrent_samples)) - yield [x for x in dp] - -# ------------------------------ Multi-location datapipes ------------------------------ -# These are datapipes rewritten to run on all GSPs - -@functional_datapipe("select_all_gsp_spatial_slices_pixels") -class SelectAllGSPSpatialSlicePixelsIterDataPipe(IterDataPipe): - """Select all the spatial slices""" - - def __init__( - self, - source_datapipe: IterDataPipe, - locations: List[Location], - roi_height_pixels: int, - roi_width_pixels: int, - allow_partial_slice: bool = False, - location_idx_name: Optional[str] = None, - ): - """ - Select spatial slices for all GSPs - - If `allow_partial_slice` is set to True, then slices may be made which intersect the border - of the input data. The additional x and y cordinates that would be required for this slice - are extrapolated based on the average spacing of these coordinates in the input data. - However, currently slices cannot be made where the centre of the window is outside of the - input data. - - Args: - source_datapipe: Datapipe of Xarray data - roi_height_pixels: ROI height in pixels - roi_width_pixels: ROI width in pixels - allow_partial_slice: Whether to allow a partial slice. - location_idx_name: Name for location index of unstructured grid data, - None if not relevant - """ - self.source_datapipe = source_datapipe - self.locations = locations - self.roi_height_pixels = roi_height_pixels - self.roi_width_pixels = roi_width_pixels - self.allow_partial_slice = allow_partial_slice - self.location_idx_name = location_idx_name - - def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: - for xr_data in self.source_datapipe: - - loc_slices = [] - - for location in self.locations: - - selected = select_spatial_slice_pixels( - xr_data, - location, - self.roi_width_pixels, - self.roi_height_pixels, - self.allow_partial_slice, - self.location_idx_name, - ) - - loc_slices.append(selected) - - yield loc_slices - - -@functional_datapipe("select_all_gsp_spatial_slice_meters") -class SelectAllGSPSpatialSliceMetersIterDataPipe(IterDataPipe): - """Select spatial slice based off meters from point of interest""" - - def __init__( - self, - source_datapipe: IterDataPipe, - locations: List[Location], - roi_height_meters: int, - roi_width_meters: int, - dim_name: Optional[str] = None, - ): - """ - Select spatial slice based off pixels from point of interest - - Args: - source_datapipe: Datapipe of Xarray data - location_datapipe: Location datapipe - roi_width_meters: ROI width in meters - roi_height_meters: ROI height in meters - dim_name: Dimension name to select for ID, None for coordinates - - Notes: - Using spatial slicing based on distance rather than number of pixels will often yield - slices which can vary by 1 pixel in height and/or width. - - E.g. Suppose the Xarray data has x-coords = [1,2,3,4,5]. We want to slice a spatial - window with a size which equates to 2.2 along the x-axis. If we choose to slice around - the point x=3 this will slice out the x-coords [2,3,4]. If we choose to slice around the - point x=2.5 this will slice out the x-coords [2,3]. Hence the returned slice can have - size either 2 or 3 in the x-axis depending on the spatial location selected. - - Also, if selecting over a large span of latitudes, this may also causes pixel sizes of - the yielded outputs to change. For example, if the Xarray data is on a regularly spaced - longitude-latitude grid, then the structure of the grid means that the longitudes near - to the poles are spaced closer together (measured in meters) than at the equator. So - slices near the equator will have less pixels in the x-axis than slices taken near the - poles. - """ - self.source_datapipe = source_datapipe - self.locations = locations - self.roi_width_meters = roi_width_meters - self.roi_height_meters = roi_height_meters - self.dim_name = dim_name - - def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: - for xr_data in self.source_datapipe: - loc_slices = [] - - for location in self.locations: - - selected = select_spatial_slice_meters( - xr_data=xr_data, - location=location, - roi_width_meters=self.roi_width_meters, - roi_height_meters=self.roi_height_meters, - dim_name=self.dim_name, - ) - - loc_slices.append(selected) - - yield loc_slices - - -# ------------------------------- Time pipeline functions ------------------------------ - - -def construct_time_pipeline( - config_filename: str, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, -) -> Tuple[IterDataPipe, IterDataPipe]: - """Construct time pipeline for the input data config file. - - Args: - config_filename: Path to config file. - start_time: Minimum time for time datapipe. - end_time: Maximum time for time datapipe. - """ - - datapipes_dict = _get_datapipes_dict(config_filename) - - # Get config - config = datapipes_dict.pop("config") - - if (start_time is not None) or (end_time is not None): - datapipes_dict["gsp"] = datapipes_dict["gsp"].filter_times(start_time, end_time) - - # Get overlapping time periods - t0_datapipe = create_t0_datapipe( - datapipes_dict, - configuration=config, - shuffle=True, - ) - - return t0_datapipe - - -def create_t0_datapipe( - datapipes_dict: dict, - configuration: Configuration, - shuffle: bool = True, -): - """ - Takes source datapipes and returns datapipes of appropriate t0 times. - - The t0 times are sampled without replacement. - - Args: - datapipes_dict: Dictionary of datapipes of input sources for which we want to select - appropriate t0 times. - configuration: Configuration object for inputs. - shuffle: Whether to use the internal shuffle function when yielding times. Else - location times will be heavily ordered. - - Returns: - t0 datapipe - - """ - valid_t0_periods_datapipe = create_valid_t0_periods_datapipe( - datapipes_dict, - configuration, - key_for_t0="gsp", - ) - - t0_datapipe = valid_t0_periods_datapipe.pick_t0_times(return_all=True, shuffle=shuffle) - - return t0_datapipe - - -# ------------------------------- Space pipeline functions ----------------------------- - - -def slice_datapipes_by_space_all_gsps( - datapipes_dict: dict, - locations: list[Location], - configuration: Configuration, -) -> None: - - conf_nwp = configuration.input_data.nwp - conf_sat = configuration.input_data.satellite - - if "nwp" in datapipes_dict: - - for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): - - datapipes_dict["nwp"][nwp_key] = nwp_datapipe.select_all_gsp_spatial_slices_pixels( - locations, - roi_width_pixels=conf_nwp[nwp_key].nwp_image_size_pixels_width, - roi_height_pixels=conf_nwp[nwp_key].nwp_image_size_pixels_height, - ) - - if "sat" in datapipes_dict: - - datapipes_dict["sat"] = datapipes_dict["sat"].select_all_gsp_spatial_slices_pixels( - locations, - roi_width_pixels=conf_sat.satellite_image_size_pixels_width, - roi_height_pixels=conf_sat.satellite_image_size_pixels_height, - ) - - if "pv" in datapipes_dict: - # No spatial slice for PV since it is always the same, just repeat for GSPs - pv_datapipe = pv_datapipe.map(SampleRepeat(len(locations))) - - - # GSP always assumed to be in data - datapipes_dict["gsp"] = datapipes_dict["gsp"].select_all_gsp_spatial_slice_meters( - locations, - roi_width_meters=1, - roi_height_meters=1, - dim_name="gsp_id", - ) - - -# -------------------------------- Processing functions -------------------------------- - - -def pre_spatial_slice_process(datapipes_dict, configuration): - - conf_nwp = configuration.input_data.nwp - - if "nwp" in datapipes_dict: - - for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): - - datapipes_dict["nwp"][nwp_key] = ( - nwp_datapipe - .map(xr_compute) - .normalize( - mean=NWP_MEANS[conf_nwp[nwp_key].nwp_provider], - std=NWP_STDS[conf_nwp[nwp_key].nwp_provider], - ) - ) - - if "sat" in datapipes_dict: - - datapipes_dict["sat"] = ( - datapipes_dict["sat"] - .map(xr_compute) - .normalize(mean=RSS_MEAN, std=RSS_STD) - ) - - if "pv" in datapipes_dict: - # Recombine PV arrays - see function doc for further explanation - datapipes_dict["pv"] = ( - datapipes_dict["pv"] - .zip_ocf(datapipes_dict["pv_future"]) - .map(concat_xr_time_utc) - .map(normalize_pv) - .map(fill_nans_in_pv) - ) - - # GSP always assumed to be in data - # Recombine GSP arrays - see function doc for further explanation - datapipes_dict["gsp"] = ( - datapipes_dict["gsp"] - .zip_ocf(datapipes_dict["gsp_future"]) - .map(concat_xr_time_utc) - .map(normalize_gsp) - ) - - -def post_spatial_slice_process(datapipes_dict): - # Spatially slice, normalize, and convert data to numpy arrays - numpy_modalities = [] - - if "nwp" in datapipes_dict: - nwp_numpy_modalities = dict() - - for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): - - nwp_numpy_modalities[nwp_key] = ( - ConvertWrapper( - nwp_datapipe, - ConvertNWPToNumpyBatch, - ) - .map(stack_np_examples_into_batch) - ) - - # Combine the NWPs into NumpyBatch - nwp_numpy_modalities = MergeNWPNumpyModalities(nwp_numpy_modalities) - numpy_modalities.append(nwp_numpy_modalities) - - if "sat" in datapipes_dict: - - numpy_modalities.append( - ConvertWrapper(datapipes_dict["sat"], ConvertSatelliteToNumpyBatch) - .map(stack_np_examples_into_batch) - ) - - if "pv" in datapipes_dict: - - numpy_modalities.append( - ConvertWrapper(datapipes_dict["pv"], ConvertPVToNumpyBatch) - .map(stack_np_examples_into_batch) - ) - - # GSP always assumed to be in data - numpy_modalities.append( - ConvertWrapper(datapipes_dict["gsp"], ConvertGSPToNumpyBatch) - .map(stack_np_examples_into_batch) - ) - - # Combine all the data sources - combined_datapipe = ( - MergeNumpyModalities(numpy_modalities) - .add_sun_position(modality_name="gsp") - .map(fill_nans_in_arrays) - ) - - return combined_datapipe - - -# --------------------------- High level pipeline functions ---------------------------- - - -def construct_sliced_data_pipeline( - config_filename: str, - t0_datapipe: IterDataPipe, - production: bool = False, - check_satellite_no_zeros: bool = False, -) -> IterDataPipe: - """Constructs data pipeline for the input data config file. - - This yields samples from the location and time datapipes. - - Args: - config_filename: Path to config file. - t0_datapipe: Datapipe yielding times. - production: Whether constucting pipeline for production inference. - check_satellite_no_zeros: Whether to check that satellite data has no zeros. - """ - - datapipes_dict = _get_datapipes_dict( - config_filename, - production=production, - ) - - # Get the location objects for all 317 regional GSPs - gsp_id_to_loc = GSPLocationLookup() - locations = [gsp_id_to_loc(gsp_id) for gsp_id in range(1, 318)] - - # Pop config - configuration = datapipes_dict.pop("config") - - # Slice all of the datasets by time - this is an in-place operation - slice_datapipes_by_time(datapipes_dict, t0_datapipe, configuration, production) - - # Run compute and normalise all the data - pre_spatial_slice_process(datapipes_dict, configuration) - - # Slice all of the datasets by space - this is an in-place operation - slice_datapipes_by_space_all_gsps(datapipes_dict, locations, configuration) - - # Convert to NumpyBatch - combined_datapipe = post_spatial_slice_process(datapipes_dict) - - if check_satellite_no_zeros: - # in production we don't want any nans in the satellite data - combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) - - return combined_datapipe - - -def pvnet_all_gsp_datapipe( - config_filename: str, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, -) -> IterDataPipe: - """ - Construct pvnet pipeline for the input data config file. - - Args: - config_filename: Path to config file. - start_time: Minimum time at which a sample can be selected. - end_time: Maximum time at which a sample can be selected. - """ - - # Open datasets from the config and filter to useable times - t0_datapipe = construct_time_pipeline( - config_filename, - start_time, - end_time, - ) - - # Shard after we have the times. These are already shuffled so no need to shuffle again - t0_datapipe = t0_datapipe.sharding_filter() - - # In this function we re-open the datasets to make a clean separation before/after sharding - # This function - datapipe = construct_sliced_data_pipeline( - config_filename, - t0_datapipe, - ) - - return datapipe - - -if __name__=="__main__": - import time - - t0 = time.time() - dp = pvnet_all_gsp_datapipe( - config_filename="/home/jamesfulton/repos/PVNet/configs/datamodule/configuration/gcp_configuration.yaml" - ) - - b = next(iter(dp)) - print(time.time() - t0) From 801b7a8c0146c2e608e77729d5b0960e1ef5a815 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 31 May 2024 12:12:22 +0000 Subject: [PATCH 09/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ocf_datapipes/load/gsp/gsp.py | 4 +- ocf_datapipes/load/gsp/utils.py | 27 +-- ocf_datapipes/select/pick_locations.py | 17 +- .../select/pick_locations_and_t0_times.py | 15 +- ocf_datapipes/select/pick_t0_times.py | 11 +- ocf_datapipes/select/select_spatial_slice.py | 30 ++-- ocf_datapipes/training/common.py | 7 +- ocf_datapipes/training/pvnet.py | 10 +- ocf_datapipes/training/pvnet_all_gsp.py | 157 ++++++++---------- ocf_datapipes/utils/location.py | 3 +- 10 files changed, 121 insertions(+), 160 deletions(-) diff --git a/ocf_datapipes/load/gsp/gsp.py b/ocf_datapipes/load/gsp/gsp.py index 251d46775..318a86753 100644 --- a/ocf_datapipes/load/gsp/gsp.py +++ b/ocf_datapipes/load/gsp/gsp.py @@ -45,7 +45,7 @@ def __init__( def __iter__(self) -> xr.DataArray: """Get and return GSP data""" gsp_id_to_shape = get_gsp_id_to_shape( - self.gsp_id_to_region_id_filename, + self.gsp_id_to_region_id_filename, self.sheffield_solar_region_path, ) @@ -56,7 +56,7 @@ def __iter__(self) -> xr.DataArray: # Ensure the centroids have the same GSP ID index as the GSP PV power gsp_id_to_shape = gsp_id_to_shape.loc[gsp_pv_power_mw_ds.gsp_id] - + data_array = put_gsp_data_into_an_xr_dataarray( gsp_pv_power_mw=gsp_pv_power_mw_ds.generation_mw.data.astype(np.float32), time_utc=gsp_pv_power_mw_ds.datetime_gmt.data, diff --git a/ocf_datapipes/load/gsp/utils.py b/ocf_datapipes/load/gsp/utils.py index e6b0d75b2..faf74243d 100644 --- a/ocf_datapipes/load/gsp/utils.py +++ b/ocf_datapipes/load/gsp/utils.py @@ -10,6 +10,7 @@ try: from ocf_datapipes.utils.eso import get_gsp_metadata_from_eso, get_gsp_shape_from_eso + _has_pvlive = True except ImportError: print("Unable to import PVLive utils, please provide filenames with OpenGSP") @@ -59,7 +60,7 @@ def put_gsp_data_into_an_xr_dataarray( def get_gsp_id_to_shape( - gsp_id_to_region_id_filename: Optional[str] = None, + gsp_id_to_region_id_filename: Optional[str] = None, sheffield_solar_region_path: Optional[str] = None, ) -> gpd.GeoDataFrame: """ @@ -72,16 +73,16 @@ def get_gsp_id_to_shape( Returns: GeoDataFrame containing the mapping from ID to shape """ - + did_provide_filepaths = None not in [gsp_id_to_region_id_filename, sheffield_solar_region_path] assert _has_pvlive or did_provide_filepaths - + if not did_provide_filepaths: if gsp_id_to_region_id_filename is None: gsp_id_to_region_id_filename = get_gsp_metadata_from_eso() if sheffield_solar_region_path is None: - sheffield_solar_region_path = get_gsp_shape_from_eso() - + sheffield_solar_region_path = get_gsp_shape_from_eso() + # Load mapping from GSP ID to Sheffield Solar GSP ID to GSP name: gsp_id_to_region_id = pd.read_csv( gsp_id_to_region_id_filename, @@ -116,11 +117,11 @@ def get_gsp_id_to_shape( # For the national forecast, GSP ID 0, we want the shape to be the # union of all the other shapes gsp_id_to_shape = pd.concat([gsp_id_to_shape, gsp_0]).sort_index() - + # Add central coordinates gsp_id_to_shape["x_osgb"] = gsp_id_to_shape.geometry.centroid.x.astype(np.float32) gsp_id_to_shape["y_osgb"] = gsp_id_to_shape.geometry.centroid.y.astype(np.float32) - + return gsp_id_to_shape @@ -128,10 +129,10 @@ class GSPLocationLookup: """Query object for GSP location from GSP ID""" def __init__( - self, - gsp_id_to_region_id_filename: Optional[str] = None, - sheffield_solar_region_path: Optional[str] = None, -): + self, + gsp_id_to_region_id_filename: Optional[str] = None, + sheffield_solar_region_path: Optional[str] = None, + ): """Query object for GSP location from GSP ID Args: @@ -140,7 +141,7 @@ def __init__( """ self.gsp_id_to_shape = get_gsp_id_to_shape( - gsp_id_to_region_id_filename, + gsp_id_to_region_id_filename, sheffield_solar_region_path, ) @@ -154,4 +155,4 @@ def __call__(self, gsp_id: int) -> Location: x=self.gsp_id_to_shape.loc[gsp_id].x_osgb.astype(np.float32), y=self.gsp_id_to_shape.loc[gsp_id].y_osgb.astype(np.float32), id=gsp_id, - ) \ No newline at end of file + ) diff --git a/ocf_datapipes/select/pick_locations.py b/ocf_datapipes/select/pick_locations.py index 5629a2a65..8e82c4678 100644 --- a/ocf_datapipes/select/pick_locations.py +++ b/ocf_datapipes/select/pick_locations.py @@ -36,10 +36,9 @@ def __init__( self.shuffle = shuffle def _yield_all_iter(self, xr_dataset): - - # Get the spatial coords + # Get the spatial coords xr_coord_system, xr_x_dim, xr_y_dim = spatial_coord_type(xr_dataset) - + loc_indices = np.arange(len(xr_dataset[xr_x_dim])) if self.shuffle: @@ -47,13 +46,12 @@ def _yield_all_iter(self, xr_dataset): # Iterate through all locations in dataset for loc_index in loc_indices: - # Get the location ID loc_id = None for id_dim_name in ["pv_system_id", "gsp_id", "station_id"]: if id_dim_name in xr_dataset.coords.keys(): loc_id = int(xr_dataset[id_dim_name][loc_index].values) - + location = Location( coordinate_system=xr_coord_system, x=xr_dataset[xr_x_dim][loc_index].values, @@ -64,13 +62,12 @@ def _yield_all_iter(self, xr_dataset): yield location def _yield_random_iter(self, xr_dataset): - - # Get the spatial coords + # Get the spatial coords xr_coord_system, xr_x_dim, xr_y_dim = spatial_coord_type(xr_dataset) while True: loc_index = np.random.randint(0, len(xr_dataset[xr_x_dim])) - + # Get the location ID loc_id = None for id_dim_name in ["pv_system_id", "gsp_id", "station_id"]: @@ -83,7 +80,7 @@ def _yield_random_iter(self, xr_dataset): y=xr_dataset[xr_y_dim][loc_index].values, id=loc_id, ) - + yield location def __iter__(self) -> Location: @@ -92,4 +89,4 @@ def __iter__(self) -> Location: if self.return_all: return self._yield_all_iter(xr_dataset) else: - return self._yield_random_iter(xr_dataset) \ No newline at end of file + return self._yield_random_iter(xr_dataset) diff --git a/ocf_datapipes/select/pick_locations_and_t0_times.py b/ocf_datapipes/select/pick_locations_and_t0_times.py index 1b2a619bb..5e72af414 100644 --- a/ocf_datapipes/select/pick_locations_and_t0_times.py +++ b/ocf_datapipes/select/pick_locations_and_t0_times.py @@ -43,10 +43,9 @@ def __init__( self.time_dim_name = time_dim_name def _yield_all_iter(self, xr_dataset): - - # Get the spatial coords + # Get the spatial coords xr_coord_system, xr_x_dim, xr_y_dim = spatial_coord_type(xr_dataset) - + t_index, x_index = np.meshgrid( np.arange(len(xr_dataset[self.time_dim_name])), np.arange(len(xr_dataset[xr_x_dim])), @@ -59,13 +58,12 @@ def _yield_all_iter(self, xr_dataset): # Iterate through all locations in dataset for t_index, loc_index in index_pairs: - # Get the location ID loc_id = None for id_dim_name in ["pv_system_id", "gsp_id", "station_id"]: if id_dim_name in xr_dataset.coords.keys(): loc_id = int(xr_dataset[id_dim_name][loc_index].values) - + t0 = xr_dataset[self.time_dim_name][t_index].values location = Location( coordinate_system=xr_coord_system, @@ -77,13 +75,12 @@ def _yield_all_iter(self, xr_dataset): yield location, t0 def _yield_random_iter(self, xr_dataset): - - # Get the spatial coords + # Get the spatial coords xr_coord_system, xr_x_dim, xr_y_dim = spatial_coord_type(xr_dataset) while True: loc_index = np.random.randint(0, len(xr_dataset[xr_x_dim])) - + # Get the location ID loc_id = None for id_dim_name in ["pv_system_id", "gsp_id", "station_id"]: @@ -96,7 +93,7 @@ def _yield_random_iter(self, xr_dataset): y=xr_dataset[xr_y_dim][loc_index].values, id=loc_id, ) - + t0 = np.random.choice(xr_dataset[self.time_dim_name].values) yield location, t0 diff --git a/ocf_datapipes/select/pick_t0_times.py b/ocf_datapipes/select/pick_t0_times.py index 173b054e4..1f677cd8d 100644 --- a/ocf_datapipes/select/pick_t0_times.py +++ b/ocf_datapipes/select/pick_t0_times.py @@ -34,14 +34,13 @@ def __init__( self.return_all = return_all self.shuffle = shuffle self.dim_name = dim_name - - + def _yield_random_iter(self, xr_dataset): """Sample t0 with replacement""" while True: t0 = np.random.choice(xr_dataset[self.dim_name].values) yield t0 - + def _yield_all_iter(self, xr_dataset): """Yield all the t0s in order, and maybe with a shuffle""" all_t0s = np.copy(xr_dataset[self.dim_name].values) @@ -52,13 +51,11 @@ def _yield_all_iter(self, xr_dataset): def __iter__(self) -> pd.Timestamp: xr_dataset = next(iter(self.source_datapipe)) - + if len(xr_dataset[self.dim_name].values) == 0: raise Exception("There are no values to get t0 from") - + if self.return_all: return self._yield_all_iter(xr_dataset) else: return self._yield_random_iter(xr_dataset) - - diff --git a/ocf_datapipes/select/select_spatial_slice.py b/ocf_datapipes/select/select_spatial_slice.py index 4b42ea3f3..11a5dad95 100644 --- a/ocf_datapipes/select/select_spatial_slice.py +++ b/ocf_datapipes/select/select_spatial_slice.py @@ -351,10 +351,10 @@ def slice_spatial_pixel_window_from_xarray( def select_spatial_slice_pixels( - xr_data: Union[xr.Dataset, xr.DataArray], + xr_data: Union[xr.Dataset, xr.DataArray], location: Location, - roi_width_pixels: int, - roi_height_pixels: int, + roi_width_pixels: int, + roi_height_pixels: int, allow_partial_slice: bool = False, location_idx_name: Optional[str] = None, ): @@ -376,7 +376,7 @@ def select_spatial_slice_pixels( location_idx_name: Name for location index of unstructured grid data, None if not relevant """ - + xr_coords, xr_x_dim, xr_y_dim = spatial_coord_type(xr_data) if location_idx_name is not None: selected = _get_points_from_unstructured_grids( @@ -411,11 +411,11 @@ def select_spatial_slice_pixels( def select_spatial_slice_meters( - xr_data: Union[xr.Dataset, xr.DataArray], + xr_data: Union[xr.Dataset, xr.DataArray], location: Location, roi_width_meters: int, roi_height_meters: int, - dim_name: Optional[str] = None, + dim_name: Optional[str] = None, ): """ Select spatial slice based off pixels from point of interest @@ -449,7 +449,7 @@ def select_spatial_slice_meters( half_width = roi_width_meters // 2 half_height = roi_height_meters // 2 - + # Find the bounding box values for the location in either lat-lon or OSGB coord systems if location.coordinate_system == "lon_lat": right, top = move_lon_lat_by_meters( @@ -472,9 +472,7 @@ def select_spatial_slice_meters( top = location.y + half_height else: - raise ValueError( - f"Location coord system not recognized: {location.coordinate_system}" - ) + raise ValueError(f"Location coord system not recognized: {location.coordinate_system}") # Change the bounding coordinates [left, right, bottom, top] to the same # coordinate system as the xarray data @@ -483,7 +481,7 @@ def select_spatial_slice_meters( y=np.array([bottom, top], dtype=np.float32), from_coords=location.coordinate_system, xr_data=xr_data, - ) + ) # Do it off coordinates, not ID if dim_name is None: @@ -554,14 +552,14 @@ def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: selected = select_spatial_slice_pixels( xr_data=xr_data, location=location, - roi_width_pixels=self.roi_width_pixels, - roi_height_pixels=self.roi_height_pixels, + roi_width_pixels=self.roi_width_pixels, + roi_height_pixels=self.roi_height_pixels, allow_partial_slice=self.allow_partial_slice, location_idx_name=self.location_idx_name, ) yield selected - + @functional_datapipe("select_spatial_slice_meters") class SelectSpatialSliceMetersIterDataPipe(IterDataPipe): @@ -614,11 +612,11 @@ def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: logger.debug("Getting Spatial Slice Meters") selected = select_spatial_slice_meters( - xr_data=xr_data, + xr_data=xr_data, location=location, roi_width_meters=self.roi_width_meters, roi_height_meters=self.roi_height_meters, - dim_name=self.dim_name, + dim_name=self.dim_name, ) yield selected diff --git a/ocf_datapipes/training/common.py b/ocf_datapipes/training/common.py index 7941a5f1e..bfe196e52 100644 --- a/ocf_datapipes/training/common.py +++ b/ocf_datapipes/training/common.py @@ -1099,7 +1099,7 @@ def create_valid_t0_periods_datapipe( key_for_t0: str = "gsp", ): """Create datapipe yielding t0 periods which are valid for the input data sources. - + Args: datapipes_dict: Dictionary of datapipes of input sources for which we want to select appropriate location and times. @@ -1224,9 +1224,8 @@ def create_valid_t0_periods_datapipe( # Select time periods and set length valid_t0_periods_datapipe = key_datapipe.filter_time_periods(time_periods=overlapping_datapipe) - - return valid_t0_periods_datapipe + return valid_t0_periods_datapipe def create_t0_and_loc_datapipes( @@ -1257,7 +1256,7 @@ def create_t0_and_loc_datapipes( configuration, key_for_t0, ) - + t0_loc_datapipe = valid_t0_periods_datapipe.pick_locs_and_t0s(return_all=True, shuffle=shuffle) location_pipe, t0_datapipe = t0_loc_datapipe.unzip(sequence_length=2) diff --git a/ocf_datapipes/training/pvnet.py b/ocf_datapipes/training/pvnet.py index 6b64b9e0b..b3f77acf2 100644 --- a/ocf_datapipes/training/pvnet.py +++ b/ocf_datapipes/training/pvnet.py @@ -77,10 +77,10 @@ def slice_datapipes_by_space( def process_and_combine_datapipes(datapipes_dict, configuration): """Normalize and convert data to numpy arrays""" - + # Unpack for convenience conf_nwp = configuration.input_data.nwp - + numpy_modalities = [] # Normalise the inputs and convert to numpy format @@ -129,7 +129,7 @@ def process_and_combine_datapipes(datapipes_dict, configuration): combined_datapipe = MergeNumpyModalities(numpy_modalities).add_sun_position(modality_name="gsp") combined_datapipe = combined_datapipe.map(fill_nans_in_arrays) - + return combined_datapipe @@ -164,10 +164,10 @@ def construct_sliced_data_pipeline( # Slice all of the datasets by time - this is an in-place operation slice_datapipes_by_time(datapipes_dict, t0_datapipe, configuration, production) - + # Normalise, and combine the data sources into NumpyBatches combined_datapipe = process_and_combine_datapipes(datapipes_dict, configuration) - + if check_satellite_no_zeros: # in production we don't want any nans in the satellite data combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py index 5198d1fca..bd3b47623 100644 --- a/ocf_datapipes/training/pvnet_all_gsp.py +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -1,38 +1,37 @@ """Create the training/validation datapipe for training the PVNet Model""" import logging -from typing import Optional, Type, Tuple, List, Union from datetime import datetime +from typing import List, Optional, Tuple, Type, Union -import numpy as np import xarray as xr -from torch.utils.data.datapipes.datapipe import IterDataPipe from torch.utils.data.datapipes._decorator import functional_datapipe +from torch.utils.data.datapipes.datapipe import IterDataPipe from torch.utils.data.datapipes.iter import IterableWrapper + +from ocf_datapipes.batch import MergeNumpyModalities, MergeNWPNumpyModalities +from ocf_datapipes.batch.merge_numpy_examples_to_batch import stack_np_examples_into_batch +from ocf_datapipes.config.model import Configuration from ocf_datapipes.convert import ( + ConvertGSPToNumpyBatch, ConvertNWPToNumpyBatch, ConvertPVToNumpyBatch, ConvertSatelliteToNumpyBatch, - ConvertGSPToNumpyBatch, - ) - -from ocf_datapipes.batch import MergeNumpyModalities, MergeNWPNumpyModalities -from ocf_datapipes.batch.merge_numpy_examples_to_batch import stack_np_examples_into_batch +from ocf_datapipes.load.gsp.utils import GSPLocationLookup from ocf_datapipes.select.select_spatial_slice import ( - select_spatial_slice_pixels, select_spatial_slice_meters, + select_spatial_slice_pixels, ) from ocf_datapipes.training.common import ( _get_datapipes_dict, check_nans_in_satellite_data, concat_xr_time_utc, + create_valid_t0_periods_datapipe, fill_nans_in_arrays, fill_nans_in_pv, normalize_gsp, normalize_pv, slice_datapipes_by_time, - open_and_return_datapipes, - create_valid_t0_periods_datapipe, ) from ocf_datapipes.utils.consts import ( NWP_MEANS, @@ -40,10 +39,7 @@ RSS_MEAN, RSS_STD, ) - -from ocf_datapipes.config.model import Configuration from ocf_datapipes.utils.location import Location -from ocf_datapipes.load.gsp.utils import GSPLocationLookup xr.set_options(keep_attrs=True) logger = logging.getLogger("pvnet_all_gsp_datapipe") @@ -58,36 +54,34 @@ def xr_compute(xr_data): class SampleRepeat: """Use a single input element to create a list of identical values""" - + def __init__(self, num_repeats): """Use a single input element to create a list of identical values - + Args: num_repeats: Length of the returned list of duplicated values """ self.num_repeats = num_repeats - + def __call__(self, x): return [x for _ in range(self.num_repeats)] - + class ConvertWrapper(IterDataPipe): - def __init__( - self, - source_datapipe: IterDataPipe, - convert_class: Type[IterDataPipe] - ): + def __init__(self, source_datapipe: IterDataPipe, convert_class: Type[IterDataPipe]): self.source_datapipe = source_datapipe self.convert_class = convert_class def __iter__(self): for concurrent_samples in self.source_datapipe: - dp = self.convert_class(IterableWrapper(concurrent_samples)) + dp = self.convert_class(IterableWrapper(concurrent_samples)) yield [x for x in dp] + # ------------------------------ Multi-location datapipes ------------------------------ # These are datapipes rewritten to run on all GSPs + @functional_datapipe("select_all_gsp_spatial_slices_pixels") class SelectAllGSPSpatialSlicePixelsIterDataPipe(IterDataPipe): """Select all the spatial slices""" @@ -127,20 +121,18 @@ def __init__( def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: for xr_data in self.source_datapipe: - loc_slices = [] - - for location in self.locations: + for location in self.locations: selected = select_spatial_slice_pixels( xr_data, location, - self.roi_width_pixels, - self.roi_height_pixels, + self.roi_width_pixels, + self.roi_height_pixels, self.allow_partial_slice, self.location_idx_name, ) - + loc_slices.append(selected) yield loc_slices @@ -194,19 +186,18 @@ def __init__( def __iter__(self) -> Union[xr.DataArray, xr.Dataset]: for xr_data in self.source_datapipe: loc_slices = [] - - for location in self.locations: + for location in self.locations: selected = select_spatial_slice_meters( - xr_data=xr_data, + xr_data=xr_data, location=location, roi_width_meters=self.roi_width_meters, roi_height_meters=self.roi_height_meters, - dim_name=self.dim_name, + dim_name=self.dim_name, ) loc_slices.append(selected) - + yield loc_slices @@ -284,14 +275,11 @@ def slice_datapipes_by_space_all_gsps( locations: list[Location], configuration: Configuration, ) -> None: - conf_nwp = configuration.input_data.nwp conf_sat = configuration.input_data.satellite - + if "nwp" in datapipes_dict: - for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): - datapipes_dict["nwp"][nwp_key] = nwp_datapipe.select_all_gsp_spatial_slices_pixels( locations, roi_width_pixels=conf_nwp[nwp_key].nwp_image_size_pixels_width, @@ -299,7 +287,6 @@ def slice_datapipes_by_space_all_gsps( ) if "sat" in datapipes_dict: - datapipes_dict["sat"] = datapipes_dict["sat"].select_all_gsp_spatial_slices_pixels( locations, roi_width_pixels=conf_sat.satellite_image_size_pixels_width, @@ -309,7 +296,6 @@ def slice_datapipes_by_space_all_gsps( if "pv" in datapipes_dict: # No spatial slice for PV since it is always the same, just repeat for GSPs pv_datapipe = pv_datapipe.map(SampleRepeat(len(locations))) - # GSP always assumed to be in data datapipes_dict["gsp"] = datapipes_dict["gsp"].select_all_gsp_spatial_slice_meters( @@ -322,40 +308,30 @@ def slice_datapipes_by_space_all_gsps( # -------------------------------- Processing functions -------------------------------- - + def pre_spatial_slice_process(datapipes_dict, configuration): - conf_nwp = configuration.input_data.nwp - - if "nwp" in datapipes_dict: + if "nwp" in datapipes_dict: for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): - - datapipes_dict["nwp"][nwp_key] = ( - nwp_datapipe - .map(xr_compute) - .normalize( - mean=NWP_MEANS[conf_nwp[nwp_key].nwp_provider], - std=NWP_STDS[conf_nwp[nwp_key].nwp_provider], - ) + datapipes_dict["nwp"][nwp_key] = nwp_datapipe.map(xr_compute).normalize( + mean=NWP_MEANS[conf_nwp[nwp_key].nwp_provider], + std=NWP_STDS[conf_nwp[nwp_key].nwp_provider], ) - - if "sat" in datapipes_dict: + if "sat" in datapipes_dict: datapipes_dict["sat"] = ( - datapipes_dict["sat"] - .map(xr_compute) - .normalize(mean=RSS_MEAN, std=RSS_STD) + datapipes_dict["sat"].map(xr_compute).normalize(mean=RSS_MEAN, std=RSS_STD) ) if "pv" in datapipes_dict: # Recombine PV arrays - see function doc for further explanation datapipes_dict["pv"] = ( datapipes_dict["pv"] - .zip_ocf(datapipes_dict["pv_future"]) - .map(concat_xr_time_utc) - .map(normalize_pv) - .map(fill_nans_in_pv) + .zip_ocf(datapipes_dict["pv_future"]) + .map(concat_xr_time_utc) + .map(normalize_pv) + .map(fill_nans_in_pv) ) # GSP always assumed to be in data @@ -367,7 +343,7 @@ def pre_spatial_slice_process(datapipes_dict, configuration): .map(normalize_gsp) ) - + def post_spatial_slice_process(datapipes_dict): # Spatially slice, normalize, and convert data to numpy arrays numpy_modalities = [] @@ -376,37 +352,34 @@ def post_spatial_slice_process(datapipes_dict): nwp_numpy_modalities = dict() for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): - - nwp_numpy_modalities[nwp_key] = ( - ConvertWrapper( - nwp_datapipe, - ConvertNWPToNumpyBatch, - ) - .map(stack_np_examples_into_batch) - ) + nwp_numpy_modalities[nwp_key] = ConvertWrapper( + nwp_datapipe, + ConvertNWPToNumpyBatch, + ).map(stack_np_examples_into_batch) # Combine the NWPs into NumpyBatch nwp_numpy_modalities = MergeNWPNumpyModalities(nwp_numpy_modalities) numpy_modalities.append(nwp_numpy_modalities) if "sat" in datapipes_dict: - numpy_modalities.append( - ConvertWrapper(datapipes_dict["sat"], ConvertSatelliteToNumpyBatch) - .map(stack_np_examples_into_batch) + ConvertWrapper(datapipes_dict["sat"], ConvertSatelliteToNumpyBatch).map( + stack_np_examples_into_batch + ) ) if "pv" in datapipes_dict: - numpy_modalities.append( - ConvertWrapper(datapipes_dict["pv"], ConvertPVToNumpyBatch) - .map(stack_np_examples_into_batch) + ConvertWrapper(datapipes_dict["pv"], ConvertPVToNumpyBatch).map( + stack_np_examples_into_batch + ) ) - # GSP always assumed to be in data + # GSP always assumed to be in data numpy_modalities.append( - ConvertWrapper(datapipes_dict["gsp"], ConvertGSPToNumpyBatch) - .map(stack_np_examples_into_batch) + ConvertWrapper(datapipes_dict["gsp"], ConvertGSPToNumpyBatch).map( + stack_np_examples_into_batch + ) ) # Combine all the data sources @@ -415,7 +388,7 @@ def post_spatial_slice_process(datapipes_dict): .add_sun_position(modality_name="gsp") .map(fill_nans_in_arrays) ) - + return combined_datapipe @@ -443,7 +416,7 @@ def construct_sliced_data_pipeline( config_filename, production=production, ) - + # Get the location objects for all 317 regional GSPs gsp_id_to_loc = GSPLocationLookup() locations = [gsp_id_to_loc(gsp_id) for gsp_id in range(1, 318)] @@ -453,20 +426,20 @@ def construct_sliced_data_pipeline( # Slice all of the datasets by time - this is an in-place operation slice_datapipes_by_time(datapipes_dict, t0_datapipe, configuration, production) - + # Run compute and normalise all the data pre_spatial_slice_process(datapipes_dict, configuration) - + # Slice all of the datasets by space - this is an in-place operation slice_datapipes_by_space_all_gsps(datapipes_dict, locations, configuration) - + # Convert to NumpyBatch combined_datapipe = post_spatial_slice_process(datapipes_dict) - + if check_satellite_no_zeros: # in production we don't want any nans in the satellite data combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) - + return combined_datapipe @@ -490,7 +463,7 @@ def pvnet_all_gsp_datapipe( start_time, end_time, ) - + # Shard after we have the times. These are already shuffled so no need to shuffle again t0_datapipe = t0_datapipe.sharding_filter() @@ -504,13 +477,13 @@ def pvnet_all_gsp_datapipe( return datapipe -if __name__=="__main__": +if __name__ == "__main__": import time - + t0 = time.time() dp = pvnet_all_gsp_datapipe( config_filename="/home/jamesfulton/repos/PVNet/configs/datamodule/configuration/gcp_configuration.yaml" ) - - b = next(iter(dp)) + + b = next(iter(dp)) print(time.time() - t0) diff --git a/ocf_datapipes/utils/location.py b/ocf_datapipes/utils/location.py index 440bfdfc5..653501e5f 100644 --- a/ocf_datapipes/utils/location.py +++ b/ocf_datapipes/utils/location.py @@ -2,7 +2,6 @@ from typing import Optional import numpy as np -import xarray as xr from pydantic import BaseModel, Field, validator @@ -60,4 +59,4 @@ def validate_y(cls, v, values): min_y, max_y = 0, np.inf if v < min_y or v > max_y: raise ValueError(f"y = {v} must be within {[min_y, max_y]} for {co} coordinate system") - return v \ No newline at end of file + return v From 45569737fe1ba9e1920ddb32553a2de70d7b4dc4 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Fri, 31 May 2024 13:28:05 +0000 Subject: [PATCH 10/27] fix tests --- ocf_datapipes/training/common.py | 11 ++++++----- ocf_datapipes/training/pvnet.py | 20 ++++++++++++-------- tests/production/test_pvnet_production.py | 8 ++++---- tests/select/test_select_spatial_slice.py | 10 +++++----- 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/ocf_datapipes/training/common.py b/ocf_datapipes/training/common.py index bfe196e52..0f270c7ff 100644 --- a/ocf_datapipes/training/common.py +++ b/ocf_datapipes/training/common.py @@ -936,21 +936,22 @@ def check_nans_in_satellite_data(batch: NumpyBatch) -> NumpyBatch: Check if there are any Nans values in the satellite data. """ if np.any(np.isnan(batch[BatchKey.satellite_actual])): + logger.error("Found nans values in satellite data") - logger.error(batch[BatchKey.satellite_actual].shape) # loop over time and channels for dim in [0, 1]: for t in range(batch[BatchKey.satellite_actual].shape[dim]): if dim == 0: - sate_data_one_step = batch[BatchKey.satellite_actual][t] + sat_data_one_step = batch[BatchKey.satellite_actual][t] else: - sate_data_one_step = batch[BatchKey.satellite_actual][:, t] - nans = np.isnan(sate_data_one_step) + sat_data_one_step = batch[BatchKey.satellite_actual][:, t] + + nans = np.isnan(sat_data_one_step) if np.any(nans): - percent_nans = np.sum(nans) / np.prod(sate_data_one_step.shape) * 100 + percent_nans = np.mean(nans) * 100 logger.error( f"Found nans values in satellite data at index {t} ({dim=}). " diff --git a/ocf_datapipes/training/pvnet.py b/ocf_datapipes/training/pvnet.py index b3f77acf2..d6a6aff46 100644 --- a/ocf_datapipes/training/pvnet.py +++ b/ocf_datapipes/training/pvnet.py @@ -75,7 +75,7 @@ def slice_datapipes_by_space( return -def process_and_combine_datapipes(datapipes_dict, configuration): +def process_and_combine_datapipes(datapipes_dict, configuration, check_satellite_no_nans=False): """Normalize and convert data to numpy arrays""" # Unpack for convenience @@ -128,6 +128,10 @@ def process_and_combine_datapipes(datapipes_dict, configuration): logger.debug("Combine all the data sources") combined_datapipe = MergeNumpyModalities(numpy_modalities).add_sun_position(modality_name="gsp") + if check_satellite_no_nans: + # in production we don't want any nans in the satellite data + combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) + combined_datapipe = combined_datapipe.map(fill_nans_in_arrays) return combined_datapipe @@ -138,7 +142,7 @@ def construct_sliced_data_pipeline( location_pipe: IterDataPipe, t0_datapipe: IterDataPipe, production: bool = False, - check_satellite_no_zeros: bool = False, + check_satellite_no_nans: bool = False, ) -> IterDataPipe: """Constructs data pipeline for the input data config file. @@ -149,7 +153,7 @@ def construct_sliced_data_pipeline( location_pipe: Datapipe yielding locations. t0_datapipe: Datapipe yielding times. production: Whether constucting pipeline for production inference. - check_satellite_no_zeros: Whether to check that satellite data has no zeros. + check_satellite_no_nans: Whether to check that satellite data has no nans. """ datapipes_dict = _get_datapipes_dict( @@ -166,11 +170,11 @@ def construct_sliced_data_pipeline( slice_datapipes_by_time(datapipes_dict, t0_datapipe, configuration, production) # Normalise, and combine the data sources into NumpyBatches - combined_datapipe = process_and_combine_datapipes(datapipes_dict, configuration) - - if check_satellite_no_zeros: - # in production we don't want any nans in the satellite data - combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) + combined_datapipe = process_and_combine_datapipes( + datapipes_dict, + configuration, + check_satellite_no_nans + ) return combined_datapipe diff --git a/tests/production/test_pvnet_production.py b/tests/production/test_pvnet_production.py index 572d19766..8f2fae68c 100644 --- a/tests/production/test_pvnet_production.py +++ b/tests/production/test_pvnet_production.py @@ -40,7 +40,7 @@ def test_construct_sliced_data_pipeline(configuration_filename, gsp_yields): configuration_filename, location_pipe=loc_pipe, t0_datapipe=t0_pipe, - check_satellite_no_zeros=True, + check_satellite_no_nans=True, production=True, ) @@ -48,7 +48,7 @@ def test_construct_sliced_data_pipeline(configuration_filename, gsp_yields): @freeze_time("2020-04-01 02:30:00") -def test_construct_sliced_data_pipeline_satellite_with_zeros(configuration_filename, gsp_yields): +def test_construct_sliced_data_pipeline_satellite_with_nans(configuration_filename, gsp_yields): # This is randomly chosen, but real, GSP location loc_pipe = IterableWrapper([Location(x=246699.328125, y=849771.9375, id=18)]) @@ -59,8 +59,8 @@ def test_construct_sliced_data_pipeline_satellite_with_zeros(configuration_filen configuration_filename, location_pipe=loc_pipe, t0_datapipe=t0_pipe, - check_satellite_no_zeros=True, + check_satellite_no_nans=True, production=True, ) with pytest.raises(ValueError): - _ = next(iter(dp)) + batch = next(iter(dp)) diff --git a/tests/select/test_select_spatial_slice.py b/tests/select/test_select_spatial_slice.py index 7e7c2a7fb..cdb3a89f2 100644 --- a/tests/select/test_select_spatial_slice.py +++ b/tests/select/test_select_spatial_slice.py @@ -8,10 +8,10 @@ SelectSpatialSlicePixels, ) -from ocf_datapipes.select.select_spatial_slice import select_spatial_slice_pixels +from ocf_datapipes.select.select_spatial_slice import slice_spatial_pixel_window_from_xarray -def test_select_spatial_slice_pixels_function(): +def test_slice_spatial_pixel_window_from_xarray_function(): # Create dummy data x = np.arange(100) y = np.arange(100)[::-1] @@ -29,7 +29,7 @@ def test_select_spatial_slice_pixels_function(): center_idx = Location(x=10, y=10, coordinate_system="idx") # Select window which lies within data - xr_selected = select_spatial_slice_pixels( + xr_selected = slice_spatial_pixel_window_from_xarray( xr_data, center_idx, width_pixels=10, @@ -44,7 +44,7 @@ def test_select_spatial_slice_pixels_function(): assert not xr_selected.data.isnull().any() # Select window where the edge of the window lies at the edge of the data - xr_selected = select_spatial_slice_pixels( + xr_selected = slice_spatial_pixel_window_from_xarray( xr_data, center_idx, width_pixels=20, @@ -59,7 +59,7 @@ def test_select_spatial_slice_pixels_function(): assert not xr_selected.data.isnull().any() # Select window which is partially outside the boundary of the data - xr_selected = select_spatial_slice_pixels( + xr_selected = slice_spatial_pixel_window_from_xarray( xr_data, center_idx, width_pixels=30, From 3029889287c8ab89007a278bd1a297c135d98233 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 31 May 2024 13:29:39 +0000 Subject: [PATCH 11/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ocf_datapipes/training/common.py | 3 +-- ocf_datapipes/training/pvnet.py | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/ocf_datapipes/training/common.py b/ocf_datapipes/training/common.py index 0f270c7ff..01007c116 100644 --- a/ocf_datapipes/training/common.py +++ b/ocf_datapipes/training/common.py @@ -936,7 +936,6 @@ def check_nans_in_satellite_data(batch: NumpyBatch) -> NumpyBatch: Check if there are any Nans values in the satellite data. """ if np.any(np.isnan(batch[BatchKey.satellite_actual])): - logger.error("Found nans values in satellite data") logger.error(batch[BatchKey.satellite_actual].shape) @@ -947,7 +946,7 @@ def check_nans_in_satellite_data(batch: NumpyBatch) -> NumpyBatch: sat_data_one_step = batch[BatchKey.satellite_actual][t] else: sat_data_one_step = batch[BatchKey.satellite_actual][:, t] - + nans = np.isnan(sat_data_one_step) if np.any(nans): diff --git a/ocf_datapipes/training/pvnet.py b/ocf_datapipes/training/pvnet.py index d6a6aff46..7b82ac85b 100644 --- a/ocf_datapipes/training/pvnet.py +++ b/ocf_datapipes/training/pvnet.py @@ -131,7 +131,7 @@ def process_and_combine_datapipes(datapipes_dict, configuration, check_satellite if check_satellite_no_nans: # in production we don't want any nans in the satellite data combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) - + combined_datapipe = combined_datapipe.map(fill_nans_in_arrays) return combined_datapipe @@ -171,9 +171,7 @@ def construct_sliced_data_pipeline( # Normalise, and combine the data sources into NumpyBatches combined_datapipe = process_and_combine_datapipes( - datapipes_dict, - configuration, - check_satellite_no_nans + datapipes_dict, configuration, check_satellite_no_nans ) return combined_datapipe From 9a0dde13648dc5f5cee4c224e6dad171ba6772a8 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Fri, 31 May 2024 13:58:11 +0000 Subject: [PATCH 12/27] linting --- ocf_datapipes/training/pvnet_all_gsp.py | 43 ++++++++++++++++++------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py index bd3b47623..27e07cab7 100644 --- a/ocf_datapipes/training/pvnet_all_gsp.py +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -49,6 +49,7 @@ def xr_compute(xr_data): + """Compute the xarray object""" return xr_data.compute() @@ -64,11 +65,19 @@ def __init__(self, num_repeats): self.num_repeats = num_repeats def __call__(self, x): + """Repeat the input a number of times as a list""" return [x for _ in range(self.num_repeats)] class ConvertWrapper(IterDataPipe): + """A class to adapt our Convert[X]ToNumpyBatch datapipes to work on a list of samples""" def __init__(self, source_datapipe: IterDataPipe, convert_class: Type[IterDataPipe]): + """A class to adapt our Convert[X]ToNumpyBatch datapipes to work on a list of samples + + Args: + source_datapipe: The source datapipe yielding lists of samples + convert_class: The class to apply to the output of source_datapipe + """ self.source_datapipe = source_datapipe self.convert_class = convert_class @@ -106,6 +115,7 @@ def __init__( Args: source_datapipe: Datapipe of Xarray data + locations: List of all locations to create samples for roi_height_pixels: ROI height in pixels roi_width_pixels: ROI width in pixels allow_partial_slice: Whether to allow a partial slice. @@ -155,7 +165,7 @@ def __init__( Args: source_datapipe: Datapipe of Xarray data - location_datapipe: Location datapipe + locations: List of all locations to create samples for roi_width_meters: ROI width in meters roi_height_meters: ROI height in meters dim_name: Dimension name to select for ID, None for coordinates @@ -295,7 +305,7 @@ def slice_datapipes_by_space_all_gsps( if "pv" in datapipes_dict: # No spatial slice for PV since it is always the same, just repeat for GSPs - pv_datapipe = pv_datapipe.map(SampleRepeat(len(locations))) + datapipes_dict["pv"] = datapipes_dict["pv"].map(SampleRepeat(len(locations))) # GSP always assumed to be in data datapipes_dict["gsp"] = datapipes_dict["gsp"].select_all_gsp_spatial_slice_meters( @@ -310,6 +320,10 @@ def slice_datapipes_by_space_all_gsps( def pre_spatial_slice_process(datapipes_dict, configuration): + """Apply pre-processing steps to the dictionary of datapipes in place + + These steps are normalisation and recombining past and future GSP/PV data + """ conf_nwp = configuration.input_data.nwp if "nwp" in datapipes_dict: @@ -333,6 +347,8 @@ def pre_spatial_slice_process(datapipes_dict, configuration): .map(normalize_pv) .map(fill_nans_in_pv) ) + + del datapipes_dict["pv_future"] # GSP always assumed to be in data # Recombine GSP arrays - see function doc for further explanation @@ -342,10 +358,12 @@ def pre_spatial_slice_process(datapipes_dict, configuration): .map(concat_xr_time_utc) .map(normalize_gsp) ) + + del datapipes_dict["gsp_future"] - -def post_spatial_slice_process(datapipes_dict): - # Spatially slice, normalize, and convert data to numpy arrays +def post_spatial_slice_process(datapipes_dict, check_satellite_no_nans=False): + """Convert the dictionary of datapipes to NumpyBatches, combine, and fill nans""" + numpy_modalities = [] if "nwp" in datapipes_dict: @@ -386,8 +404,13 @@ def post_spatial_slice_process(datapipes_dict): combined_datapipe = ( MergeNumpyModalities(numpy_modalities) .add_sun_position(modality_name="gsp") - .map(fill_nans_in_arrays) ) + + if check_satellite_no_nans: + # in production we don't want any nans in the satellite data + combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) + + combined_datapipe = combined_datapipe.map(fill_nans_in_arrays) return combined_datapipe @@ -399,7 +422,7 @@ def construct_sliced_data_pipeline( config_filename: str, t0_datapipe: IterDataPipe, production: bool = False, - check_satellite_no_zeros: bool = False, + check_satellite_no_nans: bool = False, ) -> IterDataPipe: """Constructs data pipeline for the input data config file. @@ -409,7 +432,7 @@ def construct_sliced_data_pipeline( config_filename: Path to config file. t0_datapipe: Datapipe yielding times. production: Whether constucting pipeline for production inference. - check_satellite_no_zeros: Whether to check that satellite data has no zeros. + check_satellite_no_nans: Whether to check that satellite data has no nans. """ datapipes_dict = _get_datapipes_dict( @@ -436,10 +459,6 @@ def construct_sliced_data_pipeline( # Convert to NumpyBatch combined_datapipe = post_spatial_slice_process(datapipes_dict) - if check_satellite_no_zeros: - # in production we don't want any nans in the satellite data - combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) - return combined_datapipe From ab5b6498f4aa8aa94edf8919af11e2d62677e964 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 31 May 2024 13:58:47 +0000 Subject: [PATCH 13/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ocf_datapipes/training/pvnet_all_gsp.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py index 27e07cab7..270b58268 100644 --- a/ocf_datapipes/training/pvnet_all_gsp.py +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -71,9 +71,10 @@ def __call__(self, x): class ConvertWrapper(IterDataPipe): """A class to adapt our Convert[X]ToNumpyBatch datapipes to work on a list of samples""" + def __init__(self, source_datapipe: IterDataPipe, convert_class: Type[IterDataPipe]): """A class to adapt our Convert[X]ToNumpyBatch datapipes to work on a list of samples - + Args: source_datapipe: The source datapipe yielding lists of samples convert_class: The class to apply to the output of source_datapipe @@ -321,7 +322,7 @@ def slice_datapipes_by_space_all_gsps( def pre_spatial_slice_process(datapipes_dict, configuration): """Apply pre-processing steps to the dictionary of datapipes in place - + These steps are normalisation and recombining past and future GSP/PV data """ conf_nwp = configuration.input_data.nwp @@ -347,7 +348,7 @@ def pre_spatial_slice_process(datapipes_dict, configuration): .map(normalize_pv) .map(fill_nans_in_pv) ) - + del datapipes_dict["pv_future"] # GSP always assumed to be in data @@ -358,12 +359,13 @@ def pre_spatial_slice_process(datapipes_dict, configuration): .map(concat_xr_time_utc) .map(normalize_gsp) ) - + del datapipes_dict["gsp_future"] + def post_spatial_slice_process(datapipes_dict, check_satellite_no_nans=False): """Convert the dictionary of datapipes to NumpyBatches, combine, and fill nans""" - + numpy_modalities = [] if "nwp" in datapipes_dict: @@ -401,15 +403,12 @@ def post_spatial_slice_process(datapipes_dict, check_satellite_no_nans=False): ) # Combine all the data sources - combined_datapipe = ( - MergeNumpyModalities(numpy_modalities) - .add_sun_position(modality_name="gsp") - ) - + combined_datapipe = MergeNumpyModalities(numpy_modalities).add_sun_position(modality_name="gsp") + if check_satellite_no_nans: # in production we don't want any nans in the satellite data combined_datapipe = combined_datapipe.map(check_nans_in_satellite_data) - + combined_datapipe = combined_datapipe.map(fill_nans_in_arrays) return combined_datapipe From eb12fc73a2b1b2b43ca7ca2ff2a64579c8d2496f Mon Sep 17 00:00:00 2001 From: James Fulton Date: Fri, 31 May 2024 14:01:40 +0000 Subject: [PATCH 14/27] linting --- ocf_datapipes/training/pvnet_all_gsp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py index 270b58268..ad9174bbe 100644 --- a/ocf_datapipes/training/pvnet_all_gsp.py +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -286,6 +286,7 @@ def slice_datapipes_by_space_all_gsps( locations: list[Location], configuration: Configuration, ) -> None: + """Slice the dictionary of datapipes by space in-place""" conf_nwp = configuration.input_data.nwp conf_sat = configuration.input_data.satellite From 0180c5ca6b5f070b9fe59cf5d3f8684288341d23 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Mon, 3 Jun 2024 13:34:16 +0000 Subject: [PATCH 15/27] update tests --- tests/conftest.py | 5 ++ tests/data/configs/pvnet_test_config.yaml | 44 ++++++++++++++++++ tests/data/gsp/test.zarr/.zattrs | 2 +- tests/data/gsp/test.zarr/.zgroup | 2 +- tests/data/gsp/test.zarr/.zmetadata | 36 +++++++------- tests/data/gsp/test.zarr/capacity_mwp/.zarray | 10 ++-- tests/data/gsp/test.zarr/capacity_mwp/.zattrs | 2 +- tests/data/gsp/test.zarr/capacity_mwp/0.0 | Bin 6396 -> 4627 bytes tests/data/gsp/test.zarr/capacity_mwp/0.1 | Bin 6235 -> 0 bytes tests/data/gsp/test.zarr/capacity_mwp/1.0 | Bin 0 -> 2665 bytes tests/data/gsp/test.zarr/capacity_mwp/2.0 | Bin 0 -> 2665 bytes tests/data/gsp/test.zarr/capacity_mwp/3.0 | Bin 0 -> 2697 bytes tests/data/gsp/test.zarr/capacity_mwp/4.0 | Bin 0 -> 4252 bytes tests/data/gsp/test.zarr/capacity_mwp/5.0 | Bin 0 -> 2665 bytes tests/data/gsp/test.zarr/capacity_mwp/6.0 | Bin 0 -> 2665 bytes tests/data/gsp/test.zarr/capacity_mwp/7.0 | Bin 0 -> 2697 bytes tests/data/gsp/test.zarr/capacity_mwp/8.0 | Bin 0 -> 2665 bytes tests/data/gsp/test.zarr/capacity_mwp/9.0 | Bin 0 -> 4599 bytes tests/data/gsp/test.zarr/datetime_gmt/.zarray | 6 +-- tests/data/gsp/test.zarr/datetime_gmt/.zattrs | 4 +- tests/data/gsp/test.zarr/datetime_gmt/0 | Bin 1399 -> 200 bytes .../data/gsp/test.zarr/generation_mw/.zarray | 12 ++--- .../data/gsp/test.zarr/generation_mw/.zattrs | 2 +- tests/data/gsp/test.zarr/generation_mw/0.0 | Bin 4206 -> 236 bytes tests/data/gsp/test.zarr/generation_mw/0.1 | Bin 847 -> 0 bytes tests/data/gsp/test.zarr/generation_mw/1.0 | Bin 0 -> 19379 bytes tests/data/gsp/test.zarr/generation_mw/2.0 | Bin 0 -> 23916 bytes tests/data/gsp/test.zarr/generation_mw/3.0 | Bin 0 -> 21783 bytes tests/data/gsp/test.zarr/generation_mw/4.0 | Bin 0 -> 236 bytes tests/data/gsp/test.zarr/generation_mw/5.0 | Bin 0 -> 330 bytes tests/data/gsp/test.zarr/generation_mw/6.0 | Bin 0 -> 23973 bytes tests/data/gsp/test.zarr/generation_mw/7.0 | Bin 0 -> 23944 bytes tests/data/gsp/test.zarr/generation_mw/8.0 | Bin 0 -> 16983 bytes tests/data/gsp/test.zarr/generation_mw/9.0 | Bin 0 -> 244 bytes tests/data/gsp/test.zarr/gsp_id/.zarray | 4 +- tests/data/gsp/test.zarr/gsp_id/.zattrs | 2 +- tests/data/gsp/test.zarr/gsp_id/0 | Bin 175 -> 407 bytes .../test.zarr/installedcapacity_mwp/.zarray | 10 ++-- .../test.zarr/installedcapacity_mwp/.zattrs | 2 +- .../gsp/test.zarr/installedcapacity_mwp/0.0 | Bin 6383 -> 4590 bytes .../gsp/test.zarr/installedcapacity_mwp/0.1 | Bin 6224 -> 0 bytes .../gsp/test.zarr/installedcapacity_mwp/1.0 | Bin 0 -> 2675 bytes .../gsp/test.zarr/installedcapacity_mwp/2.0 | Bin 0 -> 2675 bytes .../gsp/test.zarr/installedcapacity_mwp/3.0 | Bin 0 -> 2675 bytes .../gsp/test.zarr/installedcapacity_mwp/4.0 | Bin 0 -> 2715 bytes .../gsp/test.zarr/installedcapacity_mwp/5.0 | Bin 0 -> 2675 bytes .../gsp/test.zarr/installedcapacity_mwp/6.0 | Bin 0 -> 2675 bytes .../gsp/test.zarr/installedcapacity_mwp/7.0 | Bin 0 -> 2675 bytes .../gsp/test.zarr/installedcapacity_mwp/8.0 | Bin 0 -> 2675 bytes .../gsp/test.zarr/installedcapacity_mwp/9.0 | Bin 0 -> 4530 bytes tests/training/test_common.py | 15 ++++++ tests/training/test_pvnet.py | 24 ++-------- tests/training/test_pvnet_all_gsp.py | 42 +++++++++++++++++ 53 files changed, 158 insertions(+), 66 deletions(-) create mode 100644 tests/data/configs/pvnet_test_config.yaml delete mode 100644 tests/data/gsp/test.zarr/capacity_mwp/0.1 create mode 100644 tests/data/gsp/test.zarr/capacity_mwp/1.0 create mode 100644 tests/data/gsp/test.zarr/capacity_mwp/2.0 create mode 100644 tests/data/gsp/test.zarr/capacity_mwp/3.0 create mode 100644 tests/data/gsp/test.zarr/capacity_mwp/4.0 create mode 100644 tests/data/gsp/test.zarr/capacity_mwp/5.0 create mode 100644 tests/data/gsp/test.zarr/capacity_mwp/6.0 create mode 100644 tests/data/gsp/test.zarr/capacity_mwp/7.0 create mode 100644 tests/data/gsp/test.zarr/capacity_mwp/8.0 create mode 100644 tests/data/gsp/test.zarr/capacity_mwp/9.0 delete mode 100644 tests/data/gsp/test.zarr/generation_mw/0.1 create mode 100644 tests/data/gsp/test.zarr/generation_mw/1.0 create mode 100644 tests/data/gsp/test.zarr/generation_mw/2.0 create mode 100644 tests/data/gsp/test.zarr/generation_mw/3.0 create mode 100644 tests/data/gsp/test.zarr/generation_mw/4.0 create mode 100644 tests/data/gsp/test.zarr/generation_mw/5.0 create mode 100644 tests/data/gsp/test.zarr/generation_mw/6.0 create mode 100644 tests/data/gsp/test.zarr/generation_mw/7.0 create mode 100644 tests/data/gsp/test.zarr/generation_mw/8.0 create mode 100644 tests/data/gsp/test.zarr/generation_mw/9.0 delete mode 100644 tests/data/gsp/test.zarr/installedcapacity_mwp/0.1 create mode 100644 tests/data/gsp/test.zarr/installedcapacity_mwp/1.0 create mode 100644 tests/data/gsp/test.zarr/installedcapacity_mwp/2.0 create mode 100644 tests/data/gsp/test.zarr/installedcapacity_mwp/3.0 create mode 100644 tests/data/gsp/test.zarr/installedcapacity_mwp/4.0 create mode 100644 tests/data/gsp/test.zarr/installedcapacity_mwp/5.0 create mode 100644 tests/data/gsp/test.zarr/installedcapacity_mwp/6.0 create mode 100644 tests/data/gsp/test.zarr/installedcapacity_mwp/7.0 create mode 100644 tests/data/gsp/test.zarr/installedcapacity_mwp/8.0 create mode 100644 tests/data/gsp/test.zarr/installedcapacity_mwp/9.0 create mode 100644 tests/training/test_pvnet_all_gsp.py diff --git a/tests/conftest.py b/tests/conftest.py index 34cc5d4b7..94d03972f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -589,6 +589,11 @@ def nwp_ukv_data_filename(nwp_ukv_data): yield filename +@pytest.fixture() +def pvnet_config_filename(): + return f"{_top_test_directory}/data/configs/pvnet_test_config.yaml" + + @pytest.fixture() def configuration_filename(): return f"{_top_test_directory}/data/configs/test.yaml" diff --git a/tests/data/configs/pvnet_test_config.yaml b/tests/data/configs/pvnet_test_config.yaml new file mode 100644 index 000000000..d3a5c8ff6 --- /dev/null +++ b/tests/data/configs/pvnet_test_config.yaml @@ -0,0 +1,44 @@ +general: + description: Test config for PVNet + name: pvnet_test + +input_data: + default_history_minutes: 120 + default_forecast_minutes: 480 + + gsp: + gsp_zarr_path: tests/data/gsp/test.zarr + history_minutes: 120 + forecast_minutes: 480 + time_resolution_minutes: 30 + dropout_timedeltas_minutes: null + dropout_fraction: 0 + + nwp: + + ukv: + nwp_provider: ukv + nwp_zarr_path: tests/data/nwp_data/test.zarr + history_minutes: 60 + forecast_minutes: 120 + time_resolution_minutes: 60 + nwp_channels: + - t # 2-metre temperature + nwp_image_size_pixels_height: 2 + nwp_image_size_pixels_width: 2 + dropout_timedeltas_minutes: [-180] + dropout_fraction: 1.0 + max_staleness_minutes: null + + satellite: + satellite_zarr_path: tests/data/sat_data.zarr + history_minutes: 90 + forecast_minutes: 0 + live_delay_minutes: 0 + time_resolution_minutes: 5 + satellite_channels: + - IR_016 + satellite_image_size_pixels_height: 2 + satellite_image_size_pixels_width: 2 + dropout_timedeltas_minutes: null + dropout_fraction: 0 \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/.zattrs b/tests/data/gsp/test.zarr/.zattrs index 0967ef424..9e26dfeeb 100644 --- a/tests/data/gsp/test.zarr/.zattrs +++ b/tests/data/gsp/test.zarr/.zattrs @@ -1 +1 @@ -{} +{} \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/.zgroup b/tests/data/gsp/test.zarr/.zgroup index 3f3fad2d1..3b7daf227 100644 --- a/tests/data/gsp/test.zarr/.zgroup +++ b/tests/data/gsp/test.zarr/.zgroup @@ -1,3 +1,3 @@ { "zarr_format": 2 -} +} \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/.zmetadata b/tests/data/gsp/test.zarr/.zmetadata index 0aade4476..2e0f2b7be 100644 --- a/tests/data/gsp/test.zarr/.zmetadata +++ b/tests/data/gsp/test.zarr/.zmetadata @@ -6,8 +6,8 @@ }, "capacity_mwp/.zarray": { "chunks": [ - 9430, - 20 + 10, + 318 ], "compressor": { "blocksize": 0, @@ -21,8 +21,8 @@ "filters": null, "order": "C", "shape": [ - 49, - 22 + 96, + 318 ], "zarr_format": 2 }, @@ -34,7 +34,7 @@ }, "datetime_gmt/.zarray": { "chunks": [ - 37717 + 96 ], "compressor": { "blocksize": 0, @@ -48,7 +48,7 @@ "filters": null, "order": "C", "shape": [ - 49 + 96 ], "zarr_format": 2 }, @@ -57,17 +57,17 @@ "datetime_gmt" ], "calendar": "proleptic_gregorian", - "units": "minutes since 2014-01-01" + "units": "minutes since 2020-04-01 00:00:00" }, "generation_mw/.zarray": { "chunks": [ - 9430, - 20 + 10, + 318 ], "compressor": { "blocksize": 0, "clevel": 5, - "cname": "zstd", + "cname": "lz4", "id": "blosc", "shuffle": 1 }, @@ -76,8 +76,8 @@ "filters": null, "order": "C", "shape": [ - 49, - 22 + 96, + 318 ], "zarr_format": 2 }, @@ -103,7 +103,7 @@ "filters": null, "order": "C", "shape": [ - 22 + 318 ], "zarr_format": 2 }, @@ -114,8 +114,8 @@ }, "installedcapacity_mwp/.zarray": { "chunks": [ - 9430, - 20 + 10, + 318 ], "compressor": { "blocksize": 0, @@ -129,8 +129,8 @@ "filters": null, "order": "C", "shape": [ - 49, - 22 + 96, + 318 ], "zarr_format": 2 }, @@ -142,4 +142,4 @@ } }, "zarr_consolidated_format": 1 -} +} \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/capacity_mwp/.zarray b/tests/data/gsp/test.zarr/capacity_mwp/.zarray index 7534fa734..2fa132365 100644 --- a/tests/data/gsp/test.zarr/capacity_mwp/.zarray +++ b/tests/data/gsp/test.zarr/capacity_mwp/.zarray @@ -1,7 +1,7 @@ { "chunks": [ - 9430, - 20 + 10, + 318 ], "compressor": { "blocksize": 0, @@ -15,8 +15,8 @@ "filters": null, "order": "C", "shape": [ - 49, - 22 + 96, + 318 ], "zarr_format": 2 -} +} \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/capacity_mwp/.zattrs b/tests/data/gsp/test.zarr/capacity_mwp/.zattrs index 758489d41..5d2aefa6f 100644 --- a/tests/data/gsp/test.zarr/capacity_mwp/.zattrs +++ b/tests/data/gsp/test.zarr/capacity_mwp/.zattrs @@ -3,4 +3,4 @@ "datetime_gmt", "gsp_id" ] -} +} \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/capacity_mwp/0.0 b/tests/data/gsp/test.zarr/capacity_mwp/0.0 index 8e5b65e7c70201e379325c6b4e378c56b52ae3e6..3c2036b08e735c335c38a4063666748ccc2901df 100644 GIT binary patch literal 4627 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%0Ds{>nJ^@W^Op9LTt?_1~_0=TJ?6sAq3Q% zlbh8vX=O$jK|+;C>q?)MrL;u%2Db{b3Q|JN)xNalVqxvTt4}oM>y@02{9A?}<*2&B zOZe9ZZsyBoTkgK+j_|t^*w$LPXh!h^1P!dufsz)HRIDIR=_c_kzkiO}`V?05ZipKD zOf%P2_io~i@RR)G=$VkkW|FMb-P8vATQMqjZP>r)cQV6oJd$f;IW%-XputTaKF zR<2*VvcnelBVllp0qd(m?w}+DwmdU-dv;PilN4>c#Cr{cnGHIW750vzWGyY@NH7KV z(V+x=Z&!KiP4U%kl6WC0>)u5(V1ZkV>;EDVa6YZw71U;O$e^{u+DoUS6}^Uh|xJS z)KJX)ui?>u3_bq8q5Qca=Kl;={xxK$to>!k@ajK?E(u?H7Kh^hH(@@d25z3^eMKtmBpM%#t47XSx~BZ5FP5l;M|+XyZrqCLpO5-X2xj=VFx74Zge&j zh?PxRDRDEro7JNxwOk6>BS`5anRkiFZVPVF901>gVA2cTo8-hi6x%$; ztXAZnh8QwBdU?s!aR`TlBcqU#9%7H23bCmIMC%O=Nlb~k0ZL4q^E2;)rkUZ;yPdId z0~iImg?}AQC2P+sPRAZh^03t+0iMiC9rifD6yfC7jyfXV65PSfb0(=C3pKZyI0Q3Po8UXkCb=kA|=bP&DC7zwS_0HGzKQUxtN&o-= literal 6396 zcmZQ#ROC3oD$c;bAi(fPf`I{u7;XqMFxWCNF#Km#{(i>b;KVxq$F(YK?~`AfC3`4JXp>oj5er1NS*f z0y=zb<;w71cc#hw{h0mv9=oq(^pi)>1eDNKB9Yx?a5j@=(-bXt?w$_Mu7{%u2p)_h zmw}RioHH7QH(RO|JAJ-TRAp|^>0iQW0!=^{3SX7xzb`8|_;AZh-8EY^SwF-jj3%Iw zn{MDfM@c~2WS?EoT-B&4YErKFY@c-f%1xRtp$X{nzf)@&tJ0adxq_Ka9A%7XY5Q|( zGy%bbapW?f2}l>*h;C1hetF3MlL;e-u*&TZn}w&Z35fRx2YrDUAAlGz3ONfJ!0IIwIO$ Nts|lV(GUQxBLVDYp(y|W diff --git a/tests/data/gsp/test.zarr/capacity_mwp/0.1 b/tests/data/gsp/test.zarr/capacity_mwp/0.1 deleted file mode 100644 index dd08e7c6c7e7b51342763acb2fda1ab4df2da86f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6235 zcmZQ#ROC3oD$c;bAixkU!N34S4CaCi3@S_v43%5DfIJ2URtA0%hX4PcYwp<1&dkUJ z;w|K70*Z`+ksAU+0pPR&PABWiP}9kp^wD%Oa#IGX&ymx~MN!mra%jhBIzbJsk;_9) zCq+T1=_D;?G@Xpxl!5AV#Kmgjw8BH7`JZb#@FBAYy5a5!-!J!@mMia+~PZiJr5DEY%2nBWq26+a? k(J_$GbTJwNqaiRF0+fb8tNf1)7Yh_gy=BrxfSy}7>yQM_PTyRZm(Q>AYUYAZu09jI`+aO;9^YZ0PN|?W~X6LDz zCvnmrd0-uh1MRH(%%6#aEszy1dc<}PcL-T7@UB1XJrEfzF9+LP!Q?4SSscz8rO8pD&=x-3+>~ysiZywS z4xdV=lji?s7pS!p-OZ8_sgEaz^|?bL1XPHkym)?%iXo3~dKKU10{ij5QlUF_2X)oa z-j+I$!~Z=Ivt#y4to=n@m7Z(6!||ss9L{(fhBC`gWj(KK{!_m-YI+cwpDk^L5#qna zOf77v;W0Z3uHDU|gWO>vs*U+1^b!g%!BRS!zL88>DAB)z zq&V|p2q_WI(Ja*QR>m+OaM}A^`y)pL5AkKT>=P>Cgh8C{O#+#8$F!>^%xtM`N-Oq8 z0jlpGu-)3}s=I$|a@M(K>@MDgHUaY5Eam-|%?iv5 z?_8KodgvEaD(UC_>YJ9*xJaaK66*8eiK*VmwEXsmc?ZdB>}@jny-7GZ-<0%#$jrr% zs_r(00GLIS+y!nW{DTa`#NWgovtMGrV6H6gy;bkD39RLGcJ^^*Q1J{1Nqv2n!kFMQ z%4g>h*!9t9+cmr?<58xvlr7sj?|Hs97NNEL22ErCbMmg18{%bu&#IO*p09Ys8~rIF zTYiKztPkBrJ0n2qP)7%sv;6iUXWUaw&< zUkuMCw&^lX`I>O1`!%`Qg2N%hZdlGko&5w>M%|C1Z^;^a<+G*!MpGV@UUc6i>%Jv^PQXseDXXKmS%tzbl{DeTP!uyPD>uG|Fl@b(P;!Pg|jAhn~+3S_1Em z8`!N&3x(y;@Loa})>A(Fm|m`|J;feDsvA|9QRo}yEPQ@8e%#U0J0-H1w#g_!tI1>Z z8u-Iy(x&^#JpVT70kB;whcy)*+>}pGRI$N|-F7_lzA+~760(6#-t@83mUWM5kSI5v ze^arOVC)N1&{p4VXMyv6D#n{<>ouQ}9L)BgtXf&5QFadXm`jnri9Ke&#Lir%Ej!t5 zU%`*Yqvv*0iyq{Sk33CiEe<~6oA3VW{$urr(m;(i{U%Sr>B-W#8fH;6KBT@TmAkv6 ztXe>J&y7;TQ#c~F-6h!xQJZHU3abB4OR{^5BiII=4ZRl8(p))DX5Rx5{Xb?wNfS2R zSx)l#HuH>hSdPKcCnsF+4Fi$SjpAo~JDHbb_aD;|r*6zQBD2%i(_gzTOkycIk|jMf zU|lyUY_*F-{7B+|$Qgc8Fw?o+pt)wmxLt%Pl;`T2;z=up3ti~BK+IzK)9dkxx~@vS zB-uf9#qcx>fNa?l-Z=?3dVfXT?iQXh8PSRBv5(08HQHSh!XAxD6;0_KNy@WM&dC zehse@z^Zg@dI3sBcia2qHH+~{FrvkU~q{Q+J;OY zprAhQg3l8?jIeE_pbyG{9r1mIM@^Yb=U6>v<*q^!W7d2O;1@SJ{@vMvw9oo#z?X)^ zzvxqN5ZtCRz)S=ku)3H^V&NvWpK6W@6 zycw-hX?>gB5<9QGf1pM*{7vjJn>jkUP(%oz5(q>hk-!1ii5vii08lgl3;==v!bAc) z05AYV08|=41pwd!!i1xY1E2w&AV3fSoC5$J05JeiDF8bQ90)`}BoJ)?0tz6P08|q| z5dfkMAaKA%-~x1P03rbBjxsWULVu2f+5qU$A4dcaK+Ml6P&Pmh1yJ6fDM0*;;H3a| q8^Dix)7Yh_gy=BrxfSy}7>yQM_PTyRZm(Q>AYUYAZu09jI`+aO;9^YZ0PN|?W~X6LDz zCvnmrd0-uh1MRH(%%6#aEszy1dc<}PcL-T7@UB1XJrEfzF9+LP!Q?4SSscz8rO8pD&=x-3+>~ysiZywS z4xdV=lji?s7pS!p-OZ8_sgEaz^|?bL1XPHkym)?%iXo3~dKKU10{ij5QlUF_2X)oa z-j+I$!~Z=Ivt#y4to=n@m7Z(6!||ss9L{(fhBC`gWj(KK{!_m-YI+cwpDk^L5#qna zOf77v;W0Z3uHDU|gWO>vs*U+1^b!g%!BRS!zL88>DAB)z zq&V|p2q_WI(Ja*QR>m+OaM}A^`y)pL5AkKT>=P>Cgh8C{O#+#8$F!>^%xtM`N-Oq8 z0jlpGu-)3}s=I$|a@M(K>@MDgHUaY5Eam-|%?iv5 z?_8KodgvEaD(UC_>YJ9*xJaaK66*8eiK*VmwEXsmc?ZdB>}@jny-7GZ-<0%#$jrr% zs_r(00GLIS+y!nW{DTa`#NWgovtMGrV6H6gy;bkD39RLGcJ^^*Q1J{1Nqv2n!kFMQ z%4g>h*!9t9+cmr?<58xvlr7sj?|Hs97NNEL22ErCbMmg18{%bu&#IO*p09Ys8~rIF zTYiKztPkBrJ0n2qP)7%sv;6iUXWUaw&< zUkuMCw&^lX`I>O1`!%`Qg2N%hZdlGko&5w>M%|C1Z^;^a<+G*!MpGV@UUc6i>%Jv^PQXseDXXKmS%tzbl{DeTP!uyPD>uG|Fl@b(P;!Pg|jAhn~+3S_1Em z8`!N&3x(y;@Loa})>A(Fm|m`|J;feDsvA|9QRo}yEPQ@8e%#U0J0-H1w#g_!tI1>Z z8u-Iy(x&^#JpVT70kB;whcy)*+>}pGRI$N|-F7_lzA+~760(6#-t@83mUWM5kSI5v ze^arOVC)N1&{p4VXMyv6D#n{<>ouQ}9L)BgtXf&5QFadXm`jnri9Ke&#Lir%Ej!t5 zU%`*Yqvv*0iyq{Sk33CiEe<~6oA3VW{$urr(m;(i{U%Sr>B-W#8fH;6KBT@TmAkv6 ztXe>J&y7;TQ#c~F-6h!xQJZHU3abB4OR{^5BiII=4ZRl8(p))DX5Rx5{Xb?wNfS2R zSx)l#HuH>hSdPKcCnsF+4Fi$SjpAo~JDHbb_aD;|r*6zQBD2%i(_gzTOkycIk|jMf zU|lyUY_*F-{7B+|$Qgc8Fw?o+pt)wmxLt%Pl;`T2;z=up3ti~BK+IzK)9dkxx~@vS zB-uf9#qcx>fNa?l-Z=?3dVfXT?iQXh8PSRBv5(08HQHSh!XAxD6;0_KNy@WM&dC zehse@z^Zg@dI3sBcia2qHH+~{FrvkU~q{Q+J;OY zprAhQg3l8?jIeE_pbyG{9r1mIM@^Yb=U6>v<*q^!W7d2O;1@SJ{@vMvw9oo#z?X)^ zzvxqN5ZtCRz)S=ku)3H^V&NvWpK6W@6 zycw-hX?>gB5<9QGf1pM*{7vjJn>jkUP(%oz5(q>hk-!1ii5vii08lgl3;==v!bAc) z05AYV08|=41pwd!!i1xY1E2w&AV3fSoC5$J05JeiDF8bQ90)`}BoJ)?0tz6P08|q| z5dfkMAaKA%-~x1P03rbBjxsWULVu2f+5qU$A4dcaK+Ml6P&Pmh1yJ6fDM0*;;H3a| q8^Dixkq6i+n2986{Z|>%HX8+jVv-9rk^T#_c6e7Z4=Lo=$oyY_LHvqsE0>Hrm zmc&u!;kvFDmQb{4KgPo&fser%V$>Tpn?=#lgksIum5+QxwC8b%*i869sIw=lhNRe)XTn)^N7t={0zDAlI zC#s*sNPpmgwa53jvFb5@A`Ub|mbvH=TUp#eWV!Gg?Io_=Z+^d5=K#;s23ed3{)W`8 z7a^$!zhXr+nUcj(O(y0Ew{Zj)HAl7YhBwu=x#>H(E{%!4<3z@jY)9vO(wXC zg^uXUwo(9eTze$Iq&WS25GfJM(InLVM#jL;f64Q0+am`A5Aj8&>=P>Cn0}1TO#+#8 z$E345#B{NBQY-pK9;){bu+`GxqQQjTG>^;H&aR+SAeEafGNvK2x_xka;XTjL?l!g^ z-LB?LAymC@qvyU%F&kbw5S%YOtH?Oo8W+)mc9nDAe}l88^e*0+ zHV$%HE#!TdO!LeN?wp@Vc<2*QBI)D#@~f88m`J!z0_xMjvB{qBl-#z5Is1vL?5#4n zJqb8D@1#_}@brbC%C1%gKbU!=+<9&#{DU-u_ogWOhTtXYu9;GWw*|4qgp5tp}5n9cy*EI4yEAL{l zE?)ZkjB0Vix$;N6ksre{<%dawS6XZVj8py>5Fs|#Fh?#fnv7LKr_Dw;wVOKal2KXx zGqD^TLxuQ(%UK3!$0BRvpGkZ)46>pGH?3a4=}l1;hMd!G6a4KL14E5_oy>8as8$ zU+rv7FrTkcC%>h&h?Vd#>QAuwi2JFfcQqeIYUXwB<6ax=hCEUKxDoAY^E@WPIcRmi z!hp>OMgc7D?iH!nsi)2yPC7&PwWd#kz=VF;%Fpt_uM>w>Sq7qS;bVd){t+zvZ^1|G zr{LbikX!f75@QDL37wJ?Qg-ML&E^h)1#GqCBvrh;8k^Rh)zdQPJkA!)e+OxCf1)6L zm7x5-p^9Bm9k{lK?HkqqMO&8_m(!?hFf zrqr4I z!fG*jmET=gTcL29p3fCp4DX8R->FRrhUL)k9zy5Wl0JEvT&k!!!5&7c9Z{H8=pEuL zcy=as%)!DlDZGfb!6-nh&SCWE|HXO2y6edt{}$;1uvsmGH5TmOluwOUvBrwsb~yd6 zA44Twb72 zwhwffOOXEvF8{aSBWCe;51zh6TXMABx{M!*MbGXe7e2@t8_thwDGEI1oqOfw{m1GL zrGXl4>UEBSBzJxC@;@ojW&p4->^x+5`wYbrRWAMhI5Wk)5yXY!wZLrN@*zd5ac z-3WF(d0f>a63zvh*7^etN3&u%mdd*ih;D4VJ!fNNYs6Ve*J_*v_FvT(eztHPPjdd& zr8UCTcdzk!RgQ%13;5~26~DRyvC3gtNNefY!Yi)_BMKQ{jkK&M!+L(dF-XSwJ^)%f zl`Y1M_=5bm2IZ%sD=Ht6WJ}h21ak~LXMZ^pU#gS=++&1W8kpO8{2EFP#>IE!D<)s9 z&sG{{8hTI*B)zJ`4AkV_J6@gjabC=DElcklxj2IWV)&hxyvQ?ez}S} zt;C!n$9O8%t|H*um$YQ>S6=0;K?WxM`PB0Bh5SaVaw9GQI=g!O%&0mY5jR3|yfXIsqE(ji&;iys|vfj9I;J~fD+ z_U>R_ir#xni=VtP*MQ7ST}yrCGCzT(oRlo?rU9$k31Q0}B;tDl|3l8u6!( zev_)RqStpo`iXGV!0qcZ`N~r0Tj>_zrxl;4&V66^xc#AXZtzz5^O*mLJ<5KHZF5q* z$b<%DFHQB9HG9DHRgQ&Qg^ydGB4MYfw@YRw@#0tUDt@d=*QVy7RD`>Z!~%kyDz6|_ z{UzOueRWk=$sIYy_$SJSL*IvK+Y|DF&cg`XLJE4J?Aa0DmU+~a z$#f1?qn1}xNMg*Ij{*F`2FIVh3p!jD!3J!wXPzIRsfPGeH6G<$bMZiwa zR2qvI-`-)Ny~N7kjB%|PM{5KNN>y;3nf0pN3ilQW+KON~=D*CwEXlqqc@ce?&DWg5 zisIE)T^NUmQeFAwNv4PmHcrIyDWF(qZjQ8KmH;>A;#73F# zL{2RiDP=YZ;OHrQ0kSe?#Gs-jtiB;`e8I~WCxbVoRVb}(v0Gs0wD%SQfgJ!C03rY?4WI%5@Bv}MA>#mOKqm+g zL;&XifCoVI0aOaW&H#G?5fBMPYk+_P$VC9v1W*KkXafiwa1pow9UFiM0J=j)22kja za!_jkJ$&OZ!2=NWqY9J_&_e-~_Xh=t9|&FoV7CGM@QxfnNCRL6=n(*ZIF4Wd=mB)x X03Pt8?BV^thoQgcqk|69qksJc+*`4B literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/capacity_mwp/4.0 b/tests/data/gsp/test.zarr/capacity_mwp/4.0 new file mode 100644 index 0000000000000000000000000000000000000000..c3cf2cedf31db441ab20c4f65f53414ec7d5bbe5 GIT binary patch literal 4252 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 literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/capacity_mwp/5.0 b/tests/data/gsp/test.zarr/capacity_mwp/5.0 new file mode 100644 index 0000000000000000000000000000000000000000..565dfe7ec7b403928a8400ed30476d480238437b GIT binary patch literal 2665 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 literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/datetime_gmt/.zarray b/tests/data/gsp/test.zarr/datetime_gmt/.zarray index 3fad0aa37..480d663c2 100644 --- a/tests/data/gsp/test.zarr/datetime_gmt/.zarray +++ b/tests/data/gsp/test.zarr/datetime_gmt/.zarray @@ -1,6 +1,6 @@ { "chunks": [ - 37717 + 96 ], "compressor": { "blocksize": 0, @@ -14,7 +14,7 @@ "filters": null, "order": "C", "shape": [ - 49 + 96 ], "zarr_format": 2 -} +} \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/datetime_gmt/.zattrs b/tests/data/gsp/test.zarr/datetime_gmt/.zattrs index 83ad40ac4..c4110214b 100644 --- a/tests/data/gsp/test.zarr/datetime_gmt/.zattrs +++ b/tests/data/gsp/test.zarr/datetime_gmt/.zattrs @@ -3,5 +3,5 @@ "datetime_gmt" ], "calendar": "proleptic_gregorian", - "units": "minutes since 2014-01-01" -} + "units": "minutes since 2020-04-01 00:00:00" +} \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/datetime_gmt/0 b/tests/data/gsp/test.zarr/datetime_gmt/0 index e1b4c4836ea87ff3b91d807f3507310b6b6b40b9..136f201fb637cffd9e35b6de831f042245e731dc 100644 GIT binary patch literal 200 zcmZQ#G~{4lW?%r}6F^D?h&KT7hXMvUo2ZIuTP}Uz)A7n^TXN{ZKN+itvMHM`yyMaG zNNZVi;NBl8i?EVO8_vDq(r`;@TCnfVFA1}dq6zEHyy8%ENott4=hhD~lc0jWHK$&% zsW>In&DnM1n}|_BUeBr%&sdazmOua_3@||fGYGIU2(vIUh_Es;2(tlcb|B3Gq&a~! V7bAlTH+MV(KNG|M|4IR1QvljWHkJSY literal 1399 zcmZQ#RODDOi-iG%%UKy1L>L$tt^o0Wb%(f`Sv#(M5i;=0=~{8@36p|dOx28SS3U{o z`DAr0JMxG@&L*m2+LlWn_;kE7+Lj!80My06z{>D{@PSzYV7F}pl0qts48p363?gbk zS{+Dh0BKD|26+a?!RYdN5SK3ox?J9fkwJEFJ98e$nS(PR&qD%o)P)q}&e1TWAn>4C HpF`x diff --git a/tests/data/gsp/test.zarr/generation_mw/.zarray b/tests/data/gsp/test.zarr/generation_mw/.zarray index b6ee6cf8c..2fa132365 100644 --- a/tests/data/gsp/test.zarr/generation_mw/.zarray +++ b/tests/data/gsp/test.zarr/generation_mw/.zarray @@ -1,12 +1,12 @@ { "chunks": [ - 9430, - 20 + 10, + 318 ], "compressor": { "blocksize": 0, "clevel": 5, - "cname": "zstd", + "cname": "lz4", "id": "blosc", "shuffle": 1 }, @@ -15,8 +15,8 @@ "filters": null, "order": "C", "shape": [ - 49, - 22 + 96, + 318 ], "zarr_format": 2 -} +} \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/generation_mw/.zattrs b/tests/data/gsp/test.zarr/generation_mw/.zattrs index 758489d41..5d2aefa6f 100644 --- a/tests/data/gsp/test.zarr/generation_mw/.zattrs +++ b/tests/data/gsp/test.zarr/generation_mw/.zattrs @@ -3,4 +3,4 @@ "datetime_gmt", "gsp_id" ] -} +} \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/generation_mw/0.0 b/tests/data/gsp/test.zarr/generation_mw/0.0 index c0bb8bdd8aceed58d8b616e3c65590e887404d30..5a41ef13df0a1ccffb681d423b4d92053d08ea63 100644 GIT binary patch literal 236 ncmZQ#ROCoVW?%r}Hw+96B0ww-#PSS`4F8cpd;m~zh)V$gwd!eu literal 4206 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 diff --git a/tests/data/gsp/test.zarr/generation_mw/0.1 b/tests/data/gsp/test.zarr/generation_mw/0.1 deleted file mode 100644 index 567024122274c107314c2d8f1cf8026906f4975c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 847 zcmZQ#oXBy2Rh)r=frY`JnSp_Wk%2)2$h!^1p+MXO#Or}r6Nojo>i=B;Qs@Iz$H2(& z=l>Hwj$Q@^fgknE41erzwEk!R{q_IFSOMB8200Dvj03F9D9&hwID^G#i-JIuK#XuB zHxo&&aArdC-yTi|6@G>Ziw>@;ol<^A>%ZcX3kvoL|03t-sa|NPs=H8J_k8o;iSPRL zMfay~d054g?e>>l-Ei(Nc@BnFhRjdeo9{8STwq|>baoZPRUQV@NCxNI3^w8nrEeLw zA7YT;VrV$Vu#%l&{#Ax3Glo2dgxL%m>KTInGo(&o`0|=zemp~26~ojbpobW8a}Uiw zYks-nSak$vm`z;fFA<$tOsWn-iaZIed{KwjI`u7nY%aZAdt&sVZ}b1l#X0Sp5Wrx> zAa%0+Y45%!CF4&fi{5Yv$QSFiPfJmc7Y!QlHIK=61u+ zwE9B~`xtUp2ngI@f6!_u^do7`AxhdW5e8c`f{FJue)cNa9n(( zaqF&}qGaXd7u|bIqJ{r$Oz!&{Z~x4w_RP+6J`D32vX`zc;Pk2e>=6*le0v>_)q|{M zL9?yYr6pd~-#n+Ccu$hw^~;oNvnF!$7E9Efeo(SF=ksk~^8B$f{mK6CPdHwSf0AQR zQv0FtUhB(Om47nd#eNC@7W&7_!2JI|)9;_(KYe-r>C)ph=Zv>AH~|w;y}iAI0}$KW zGcY8rFzAwh(3;3A^rJt)PUwH|!Xr7=nl}B4yB_nEZ;t+!nE314d{;9Th2S5_9czk& hyXO?lDK%Io@Fj+|@u;Wxlj>cEqH_u-U3#`R9{^B3Rha+) diff --git a/tests/data/gsp/test.zarr/generation_mw/1.0 b/tests/data/gsp/test.zarr/generation_mw/1.0 new file mode 100644 index 0000000000000000000000000000000000000000..cf836e4062f9f190d7bce1f41a3d702a439db879 GIT binary patch literal 19379 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 literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/generation_mw/2.0 b/tests/data/gsp/test.zarr/generation_mw/2.0 new file mode 100644 index 0000000000000000000000000000000000000000..93d0551d8352d7601f8467f8da7135210a84571d GIT binary patch literal 23916 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 Y0*$qaoWKtNq>w!Wd}tSU|NnjZKgiq9)c^nh literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/generation_mw/4.0 b/tests/data/gsp/test.zarr/generation_mw/4.0 new file mode 100644 index 0000000000000000000000000000000000000000..5a41ef13df0a1ccffb681d423b4d92053d08ea63 GIT binary patch literal 236 ncmZQ#ROCoVW?%r}Hw+96B0ww-#PSS`4F8cpd;m~zh)V$gwd!eu literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/generation_mw/5.0 b/tests/data/gsp/test.zarr/generation_mw/5.0 new file mode 100644 index 0000000000000000000000000000000000000000..c5e6b786feb7761e175851bec3d2d4ea52ac2d79 GIT binary patch literal 330 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 DH4&n9 literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/generation_mw/7.0 b/tests/data/gsp/test.zarr/generation_mw/7.0 new file mode 100644 index 0000000000000000000000000000000000000000..7cb60a60aed24c015a40be5b4a91222b1ca4aa20 GIT binary patch literal 23944 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 literal 175 zcmZQ#ROI-;$-n@@>lqjrM1WWwi2w62FfuW-u(GjpaB^|;@bd8s2nq>{hytY;7+8VQ S0bmvnkd$X&WcaT}A3gvzYZ1Z# diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/.zarray b/tests/data/gsp/test.zarr/installedcapacity_mwp/.zarray index 7534fa734..2fa132365 100644 --- a/tests/data/gsp/test.zarr/installedcapacity_mwp/.zarray +++ b/tests/data/gsp/test.zarr/installedcapacity_mwp/.zarray @@ -1,7 +1,7 @@ { "chunks": [ - 9430, - 20 + 10, + 318 ], "compressor": { "blocksize": 0, @@ -15,8 +15,8 @@ "filters": null, "order": "C", "shape": [ - 49, - 22 + 96, + 318 ], "zarr_format": 2 -} +} \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/.zattrs b/tests/data/gsp/test.zarr/installedcapacity_mwp/.zattrs index 758489d41..5d2aefa6f 100644 --- a/tests/data/gsp/test.zarr/installedcapacity_mwp/.zattrs +++ b/tests/data/gsp/test.zarr/installedcapacity_mwp/.zattrs @@ -3,4 +3,4 @@ "datetime_gmt", "gsp_id" ] -} +} \ No newline at end of file diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/0.0 b/tests/data/gsp/test.zarr/installedcapacity_mwp/0.0 index ef2d86fc6e1ce0083e924a7b66bb9339dd27b712..31e97017c6f2d8991def4bcdb0050b332786a13a 100644 GIT binary patch literal 4590 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 literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/2.0 b/tests/data/gsp/test.zarr/installedcapacity_mwp/2.0 new file mode 100644 index 0000000000000000000000000000000000000000..d03374c2af605a63b50b9de985287bd9b37d012a GIT binary patch literal 2675 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 literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/3.0 b/tests/data/gsp/test.zarr/installedcapacity_mwp/3.0 new file mode 100644 index 0000000000000000000000000000000000000000..d03374c2af605a63b50b9de985287bd9b37d012a GIT binary patch literal 2675 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 literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/4.0 b/tests/data/gsp/test.zarr/installedcapacity_mwp/4.0 new file mode 100644 index 0000000000000000000000000000000000000000..8dc697c7c80fc6c134306351ec845046e3f79f41 GIT binary patch literal 2715 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 literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/5.0 b/tests/data/gsp/test.zarr/installedcapacity_mwp/5.0 new file mode 100644 index 0000000000000000000000000000000000000000..6e62da389c8f2d910347f85f2626b4e66e739928 GIT binary patch literal 2675 zcmZ9Oc{CL68pq!mW=6J|v2T;zU@Xnp3p2K#Y&CW!B)bwqVv?njsKRF*V|?31pJeo%^wWj_98|mex(@*cown?GHHa9x=wY zi^-;#Ja_Rg= z_>41vf^xb8Uoj;p*lG5H9xkC|Wl29Uw6zfx>z zt|uRBdf3~Pc%x`LN&lI)rSISF(O&q;3J5y0KSFWf)~_LY=$Vw(PYEVrW*J@p{s4XP zYwG<>XVc;`C4oS`oJspb%;!7&eQxb{jm&RJN6{5)8RtiL)66d}pg*;iom1!75(xYl zO4?|W@UEj0|3!b=Hq$@3YVd@-VsG+?*aP-ktOah$GFRDS@myS7PK!jbriHHZ>5pl0 z-sz`83PYPd#%!#gdcrs@Bu)!6ElH)j-?_*8&gnr-a=~%^Yjmm5ji4eemKPRL?8(D1 zI2ZHq6m8{eXRA#&C&Bai8W*7Bt^c3wIBM8dbsJCm>@%y}TZ466I@&jjXmOR3;gKeM zm~K=&-Ta;jPa1|d&b5#e6Vks*&guv4Ud@*WtL!qcSeuoXrH4>;8V)Sj-LKz%Eyo`! zU9eT&{i^5cyXsY~*dF5uPgctMY4rL8H}g#oQJoPT(pnV$yrL|#GibNxnbTQ+QO_l- z{*0We5Ru2`JAiZk+$6{0K%ZdoHd!9?G=MI>)#o7gaso3TNx-NTeVlwV{o5RQ>|o^38ce1#W;oY_9<>iv;sMje2?ay)h*jSJE_LY+bFi z;VIk8ZRmLTYEpRWk&w+(`IQMa?n9+aj5+BJ`6;^4bJN@!)LZK#PFxphi#_9Oi!-hr`Jrlhkt!%j>z3EonT;7@4N9Z9;VPEw9*5)ETVKJW` zS(9N2pYm}lWL|8LC2OB{MZ{!UsSA7h>e&41cyxSz=_B{|7DCU z93&;9|Z0xJ;M)QtPOx@>y7Q2&#b1mC?&yj8B zz163NGkT;rLmi>Wr&1m^23!OFxkg205+<6Lp5W)@kpz`)bgCdooD&KIvX75a5ndV# zFqJ4axT$8k#RGXcrnmgj7h#=^vvcK*ooWhwtHPNqPm#iHyfgI!O}nGd(+9|XK-%T# zgn1O+*~7xDWo-VDXGTDzBNye)&JeRpRh!-=?lm#D(+sWC#Az|N@t;z2Zb^(l|AXya z>~W7m42Ech*CH{$Xy|v7)>ex@&KUVw_etH(yRX@X)dKol3c5u#cLsFa1Mfo1f=+eo z+tU8}OYnYj`P8aRC9bETKRCLZ8S~|9Wh}1LXmE-x#RO#(jjg4Pu+f%|7XQb|S>Brn;|$Vjx>UP*mJ9NusWY7% zu#MbmnHwTkO|@v-H91PR$))*JM%9>$2sZCJtI+0Pt!vWh-s!E;Y$co4I$tfxAO8lq zGnGQX3F}ttal*SjRj6pl|JC;<9ur9cij<>N8!Lw$J04>D#(%*cJPkLqQG}Pcb|D*7cEp9J0*8 z_4a9x+hznUp=*}e-dhiIaD=Ev{!J%EwLJ0TPxT`i55$IK>Z-k|xLw7qS~U~?V% zW+LPme4$Tn%G4DPA*^Wt=`WXdkUnHA9?mW1Zc0|fBj}r3NM;=#hd+sLm-ae^;1<-v zta2g)lvnsSAdwrfNa65h6sJ7uhrFYNJ1Wf16(uM{)cQ_`O8cD^Jz_*gtf6=iOU6L0 z!QO$3!ij|t)HP%V6s9aG_ILBna>&5=kTy5e-lzG+>1Ts5k&@ z00IXRBq9m`RsbXcQ~^Lm0T2cf#Jvm+!~u&KfRg|W4FEp?@&L-ae+irbVI(36?3El~ zfdYsFfbsz-96%HR1O|ACynqD>Afg9Z9DqXiM?xt8y7$grw=Mv00d#K|5+I-eD!88l z_BMe5c?dW1b`@j?KN=ikA|rN7!rVL0O10#Z~)~7zyl!vf8h@D!G`|@$ScG) literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/6.0 b/tests/data/gsp/test.zarr/installedcapacity_mwp/6.0 new file mode 100644 index 0000000000000000000000000000000000000000..6e62da389c8f2d910347f85f2626b4e66e739928 GIT binary patch literal 2675 zcmZ9Oc{CL68pq!mW=6J|v2T;zU@Xnp3p2K#Y&CW!B)bwqVv?njsKRF*V|?31pJeo%^wWj_98|mex(@*cown?GHHa9x=wY zi^-;#Ja_Rg= z_>41vf^xb8Uoj;p*lG5H9xkC|Wl29Uw6zfx>z zt|uRBdf3~Pc%x`LN&lI)rSISF(O&q;3J5y0KSFWf)~_LY=$Vw(PYEVrW*J@p{s4XP zYwG<>XVc;`C4oS`oJspb%;!7&eQxb{jm&RJN6{5)8RtiL)66d}pg*;iom1!75(xYl zO4?|W@UEj0|3!b=Hq$@3YVd@-VsG+?*aP-ktOah$GFRDS@myS7PK!jbriHHZ>5pl0 z-sz`83PYPd#%!#gdcrs@Bu)!6ElH)j-?_*8&gnr-a=~%^Yjmm5ji4eemKPRL?8(D1 zI2ZHq6m8{eXRA#&C&Bai8W*7Bt^c3wIBM8dbsJCm>@%y}TZ466I@&jjXmOR3;gKeM zm~K=&-Ta;jPa1|d&b5#e6Vks*&guv4Ud@*WtL!qcSeuoXrH4>;8V)Sj-LKz%Eyo`! zU9eT&{i^5cyXsY~*dF5uPgctMY4rL8H}g#oQJoPT(pnV$yrL|#GibNxnbTQ+QO_l- z{*0We5Ru2`JAiZk+$6{0K%ZdoHd!9?G=MI>)#o7gaso3TNx-NTeVlwV{o5RQ>|o^38ce1#W;oY_9<>iv;sMje2?ay)h*jSJE_LY+bFi z;VIk8ZRmLTYEpRWk&w+(`IQMa?n9+aj5+BJ`6;^4bJN@!)LZK#PFxphi#_9Oi!-hr`Jrlhkt!%j>z3EonT;7@4N9Z9;VPEw9*5)ETVKJW` zS(9N2pYm}lWL|8LC2OB{MZ{!UsSA7h>e&41cyxSz=_B{|7DCU z93&;9|Z0xJ;M)QtPOx@>y7Q2&#b1mC?&yj8B zz163NGkT;rLmi>Wr&1m^23!OFxkg205+<6Lp5W)@kpz`)bgCdooD&KIvX75a5ndV# zFqJ4axT$8k#RGXcrnmgj7h#=^vvcK*ooWhwtHPNqPm#iHyfgI!O}nGd(+9|XK-%T# zgn1O+*~7xDWo-VDXGTDzBNye)&JeRpRh!-=?lm#D(+sWC#Az|N@t;z2Zb^(l|AXya z>~W7m42Ech*CH{$Xy|v7)>ex@&KUVw_etH(yRX@X)dKol3c5u#cLsFa1Mfo1f=+eo z+tU8}OYnYj`P8aRC9bETKRCLZ8S~|9Wh}1LXmE-x#RO#(jjg4Pu+f%|7XQb|S>Brn;|$Vjx>UP*mJ9NusWY7% zu#MbmnHwTkO|@v-H91PR$))*JM%9>$2sZCJtI+0Pt!vWh-s!E;Y$co4I$tfxAO8lq zGnGQX3F}ttal*SjRj6pl|JC;<9ur9cij<>N8!Lw$J04>D#(%*cJPkLqQG}Pcb|D*7cEp9J0*8 z_4a9x+hznUp=*}e-dhiIaD=Ev{!J%EwLJ0TPxT`i55$IK>Z-k|xLw7qS~U~?V% zW+LPme4$Tn%G4DPA*^Wt=`WXdkUnHA9?mW1Zc0|fBj}r3NM;=#hd+sLm-ae^;1<-v zta2g)lvnsSAdwrfNa65h6sJ7uhrFYNJ1Wf16(uM{)cQ_`O8cD^Jz_*gtf6=iOU6L0 z!QO$3!ij|t)HP%V6s9aG_ILBna>&5=kTy5e-lzG+>1Ts5k&@ z00IXRBq9m`RsbXcQ~^Lm0T2cf#Jvm+!~u&KfRg|W4FEp?@&L-ae+irbVI(36?3El~ zfdYsFfbsz-96%HR1O|ACynqD>Afg9Z9DqXiM?xt8y7$grw=Mv00d#K|5+I-eD!88l z_BMe5c?dW1b`@j?KN=ikA|rN7!rVL0O10#Z~)~7zyl!vf8h@D!G`|@$ScG) literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/7.0 b/tests/data/gsp/test.zarr/installedcapacity_mwp/7.0 new file mode 100644 index 0000000000000000000000000000000000000000..6e62da389c8f2d910347f85f2626b4e66e739928 GIT binary patch literal 2675 zcmZ9Oc{CL68pq!mW=6J|v2T;zU@Xnp3p2K#Y&CW!B)bwqVv?njsKRF*V|?31pJeo%^wWj_98|mex(@*cown?GHHa9x=wY zi^-;#Ja_Rg= z_>41vf^xb8Uoj;p*lG5H9xkC|Wl29Uw6zfx>z zt|uRBdf3~Pc%x`LN&lI)rSISF(O&q;3J5y0KSFWf)~_LY=$Vw(PYEVrW*J@p{s4XP zYwG<>XVc;`C4oS`oJspb%;!7&eQxb{jm&RJN6{5)8RtiL)66d}pg*;iom1!75(xYl zO4?|W@UEj0|3!b=Hq$@3YVd@-VsG+?*aP-ktOah$GFRDS@myS7PK!jbriHHZ>5pl0 z-sz`83PYPd#%!#gdcrs@Bu)!6ElH)j-?_*8&gnr-a=~%^Yjmm5ji4eemKPRL?8(D1 zI2ZHq6m8{eXRA#&C&Bai8W*7Bt^c3wIBM8dbsJCm>@%y}TZ466I@&jjXmOR3;gKeM zm~K=&-Ta;jPa1|d&b5#e6Vks*&guv4Ud@*WtL!qcSeuoXrH4>;8V)Sj-LKz%Eyo`! zU9eT&{i^5cyXsY~*dF5uPgctMY4rL8H}g#oQJoPT(pnV$yrL|#GibNxnbTQ+QO_l- z{*0We5Ru2`JAiZk+$6{0K%ZdoHd!9?G=MI>)#o7gaso3TNx-NTeVlwV{o5RQ>|o^38ce1#W;oY_9<>iv;sMje2?ay)h*jSJE_LY+bFi z;VIk8ZRmLTYEpRWk&w+(`IQMa?n9+aj5+BJ`6;^4bJN@!)LZK#PFxphi#_9Oi!-hr`Jrlhkt!%j>z3EonT;7@4N9Z9;VPEw9*5)ETVKJW` zS(9N2pYm}lWL|8LC2OB{MZ{!UsSA7h>e&41cyxSz=_B{|7DCU z93&;9|Z0xJ;M)QtPOx@>y7Q2&#b1mC?&yj8B zz163NGkT;rLmi>Wr&1m^23!OFxkg205+<6Lp5W)@kpz`)bgCdooD&KIvX75a5ndV# zFqJ4axT$8k#RGXcrnmgj7h#=^vvcK*ooWhwtHPNqPm#iHyfgI!O}nGd(+9|XK-%T# zgn1O+*~7xDWo-VDXGTDzBNye)&JeRpRh!-=?lm#D(+sWC#Az|N@t;z2Zb^(l|AXya z>~W7m42Ech*CH{$Xy|v7)>ex@&KUVw_etH(yRX@X)dKol3c5u#cLsFa1Mfo1f=+eo z+tU8}OYnYj`P8aRC9bETKRCLZ8S~|9Wh}1LXmE-x#RO#(jjg4Pu+f%|7XQb|S>Brn;|$Vjx>UP*mJ9NusWY7% zu#MbmnHwTkO|@v-H91PR$))*JM%9>$2sZCJtI+0Pt!vWh-s!E;Y$co4I$tfxAO8lq zGnGQX3F}ttal*SjRj6pl|JC;<9ur9cij<>N8!Lw$J04>D#(%*cJPkLqQG}Pcb|D*7cEp9J0*8 z_4a9x+hznUp=*}e-dhiIaD=Ev{!J%EwLJ0TPxT`i55$IK>Z-k|xLw7qS~U~?V% zW+LPme4$Tn%G4DPA*^Wt=`WXdkUnHA9?mW1Zc0|fBj}r3NM;=#hd+sLm-ae^;1<-v zta2g)lvnsSAdwrfNa65h6sJ7uhrFYNJ1Wf16(uM{)cQ_`O8cD^Jz_*gtf6=iOU6L0 z!QO$3!ij|t)HP%V6s9aG_ILBna>&5=kTy5e-lzG+>1Ts5k&@ z00IXRBq9m`RsbXcQ~^Lm0T2cf#Jvm+!~u&KfRg|W4FEp?@&L-ae+irbVI(36?3El~ zfdYsFfbsz-96%HR1O|ACynqD>Afg9Z9DqXiM?xt8y7$grw=Mv00d#K|5+I-eD!88l z_BMe5c?dW1b`@j?KN=ikA|rN7!rVL0O10#Z~)~7zyl!vf8h@D!G`|@$ScG) literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/8.0 b/tests/data/gsp/test.zarr/installedcapacity_mwp/8.0 new file mode 100644 index 0000000000000000000000000000000000000000..6e62da389c8f2d910347f85f2626b4e66e739928 GIT binary patch literal 2675 zcmZ9Oc{CL68pq!mW=6J|v2T;zU@Xnp3p2K#Y&CW!B)bwqVv?njsKRF*V|?31pJeo%^wWj_98|mex(@*cown?GHHa9x=wY zi^-;#Ja_Rg= z_>41vf^xb8Uoj;p*lG5H9xkC|Wl29Uw6zfx>z zt|uRBdf3~Pc%x`LN&lI)rSISF(O&q;3J5y0KSFWf)~_LY=$Vw(PYEVrW*J@p{s4XP zYwG<>XVc;`C4oS`oJspb%;!7&eQxb{jm&RJN6{5)8RtiL)66d}pg*;iom1!75(xYl zO4?|W@UEj0|3!b=Hq$@3YVd@-VsG+?*aP-ktOah$GFRDS@myS7PK!jbriHHZ>5pl0 z-sz`83PYPd#%!#gdcrs@Bu)!6ElH)j-?_*8&gnr-a=~%^Yjmm5ji4eemKPRL?8(D1 zI2ZHq6m8{eXRA#&C&Bai8W*7Bt^c3wIBM8dbsJCm>@%y}TZ466I@&jjXmOR3;gKeM zm~K=&-Ta;jPa1|d&b5#e6Vks*&guv4Ud@*WtL!qcSeuoXrH4>;8V)Sj-LKz%Eyo`! zU9eT&{i^5cyXsY~*dF5uPgctMY4rL8H}g#oQJoPT(pnV$yrL|#GibNxnbTQ+QO_l- z{*0We5Ru2`JAiZk+$6{0K%ZdoHd!9?G=MI>)#o7gaso3TNx-NTeVlwV{o5RQ>|o^38ce1#W;oY_9<>iv;sMje2?ay)h*jSJE_LY+bFi z;VIk8ZRmLTYEpRWk&w+(`IQMa?n9+aj5+BJ`6;^4bJN@!)LZK#PFxphi#_9Oi!-hr`Jrlhkt!%j>z3EonT;7@4N9Z9;VPEw9*5)ETVKJW` zS(9N2pYm}lWL|8LC2OB{MZ{!UsSA7h>e&41cyxSz=_B{|7DCU z93&;9|Z0xJ;M)QtPOx@>y7Q2&#b1mC?&yj8B zz163NGkT;rLmi>Wr&1m^23!OFxkg205+<6Lp5W)@kpz`)bgCdooD&KIvX75a5ndV# zFqJ4axT$8k#RGXcrnmgj7h#=^vvcK*ooWhwtHPNqPm#iHyfgI!O}nGd(+9|XK-%T# zgn1O+*~7xDWo-VDXGTDzBNye)&JeRpRh!-=?lm#D(+sWC#Az|N@t;z2Zb^(l|AXya z>~W7m42Ech*CH{$Xy|v7)>ex@&KUVw_etH(yRX@X)dKol3c5u#cLsFa1Mfo1f=+eo z+tU8}OYnYj`P8aRC9bETKRCLZ8S~|9Wh}1LXmE-x#RO#(jjg4Pu+f%|7XQb|S>Brn;|$Vjx>UP*mJ9NusWY7% zu#MbmnHwTkO|@v-H91PR$))*JM%9>$2sZCJtI+0Pt!vWh-s!E;Y$co4I$tfxAO8lq zGnGQX3F}ttal*SjRj6pl|JC;<9ur9cij<>N8!Lw$J04>D#(%*cJPkLqQG}Pcb|D*7cEp9J0*8 z_4a9x+hznUp=*}e-dhiIaD=Ev{!J%EwLJ0TPxT`i55$IK>Z-k|xLw7qS~U~?V% zW+LPme4$Tn%G4DPA*^Wt=`WXdkUnHA9?mW1Zc0|fBj}r3NM;=#hd+sLm-ae^;1<-v zta2g)lvnsSAdwrfNa65h6sJ7uhrFYNJ1Wf16(uM{)cQ_`O8cD^Jz_*gtf6=iOU6L0 z!QO$3!ij|t)HP%V6s9aG_ILBna>&5=kTy5e-lzG+>1Ts5k&@ z00IXRBq9m`RsbXcQ~^Lm0T2cf#Jvm+!~u&KfRg|W4FEp?@&L-ae+irbVI(36?3El~ zfdYsFfbsz-96%HR1O|ACynqD>Afg9Z9DqXiM?xt8y7$grw=Mv00d#K|5+I-eD!88l z_BMe5c?dW1b`@j?KN=ikA|rN7!rVL0O10#Z~)~7zyl!vf8h@D!G`|@$ScG) literal 0 HcmV?d00001 diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/9.0 b/tests/data/gsp/test.zarr/installedcapacity_mwp/9.0 new file mode 100644 index 0000000000000000000000000000000000000000..836867bf63941d58820df628e8d5cf6ecdb7556c GIT binary patch literal 4530 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 AGXMYp literal 0 HcmV?d00001 diff --git a/tests/training/test_common.py b/tests/training/test_common.py index 89efef6a2..4af6cd3c7 100644 --- a/tests/training/test_common.py +++ b/tests/training/test_common.py @@ -9,6 +9,7 @@ get_and_return_overlapping_time_periods_and_t0, open_and_return_datapipes, create_t0_and_loc_datapipes, + construct_loctime_pipelines, ) import fsspec @@ -111,3 +112,17 @@ def test_create_t0_and_loc_datapipes(configuration_filename): loc0, t0 = next(iter(location_pipe.zip(t0_datapipe))) assert isinstance(loc0, Location) assert isinstance(t0, np.datetime64) + + +def test_construct_loctime_pipelines(configuration_filename): + start_time = datetime(1900, 1, 1) + end_time = datetime(2050, 1, 1) + + loc_pipe, t0_pipe = construct_loctime_pipelines( + configuration_filename, + start_time=start_time, + end_time=end_time, + ) + + next(iter(loc_pipe)) + next(iter(t0_pipe)) diff --git a/tests/training/test_pvnet.py b/tests/training/test_pvnet.py index 16b5f6c0d..89bde4be7 100644 --- a/tests/training/test_pvnet.py +++ b/tests/training/test_pvnet.py @@ -6,26 +6,12 @@ construct_sliced_data_pipeline, pvnet_datapipe, ) -from ocf_datapipes.training.common import construct_loctime_pipelines from ocf_datapipes.batch import BatchKey, NWPBatchKey from ocf_datapipes.utils import Location -def test_construct_loctime_pipelines(configuration_filename): - start_time = datetime(1900, 1, 1) - end_time = datetime(2050, 1, 1) - loc_pipe, t0_pipe = construct_loctime_pipelines( - configuration_filename, - start_time=start_time, - end_time=end_time, - ) - - next(iter(loc_pipe)) - next(iter(t0_pipe)) - - -def test_construct_sliced_data_pipeline(configuration_filename): +def test_construct_sliced_data_pipeline(pvnet_config_filename): # This is randomly chosen, but real, GSP location loc_pipe = IterableWrapper([Location(x=246699.328125, y=849771.9375, id=18)]) @@ -33,7 +19,7 @@ def test_construct_sliced_data_pipeline(configuration_filename): t0_pipe = IterableWrapper([datetime(2020, 4, 1, 13, 30)]) dp = construct_sliced_data_pipeline( - configuration_filename, + pvnet_config_filename, location_pipe=loc_pipe, t0_datapipe=t0_pipe, ) @@ -44,12 +30,12 @@ def test_construct_sliced_data_pipeline(configuration_filename): assert NWPBatchKey.nwp in batch[BatchKey.nwp][nwp_source] -def test_pvnet_datapipe(configuration_filename): +def test_pvnet_datapipe(pvnet_config_filename): start_time = datetime(1900, 1, 1) end_time = datetime(2050, 1, 1) dp = pvnet_datapipe( - configuration_filename, + pvnet_config_filename, start_time=start_time, end_time=end_time, ) @@ -57,4 +43,4 @@ def test_pvnet_datapipe(configuration_filename): batch = next(iter(dp)) assert BatchKey.nwp in batch for nwp_source in batch[BatchKey.nwp].keys(): - assert NWPBatchKey.nwp in batch[BatchKey.nwp][nwp_source] + assert NWPBatchKey.nwp in batch[BatchKey.nwp][nwp_source] \ No newline at end of file diff --git a/tests/training/test_pvnet_all_gsp.py b/tests/training/test_pvnet_all_gsp.py new file mode 100644 index 000000000..5b0afb182 --- /dev/null +++ b/tests/training/test_pvnet_all_gsp.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from torch.utils.data.datapipes.iter import IterableWrapper + +from ocf_datapipes.training.pvnet_all_gsp import ( + construct_sliced_data_pipeline, + pvnet_all_gsp_datapipe, +) +from ocf_datapipes.batch import BatchKey, NWPBatchKey + + + +def test_construct_sliced_data_pipeline(pvnet_config_filename): + + # This is a randomly chosen time in the middle of the test data + t0_pipe = IterableWrapper([datetime(2020, 4, 1, 13, 30)]) + + dp = construct_sliced_data_pipeline( + pvnet_config_filename, + t0_datapipe=t0_pipe, + ) + + batch = next(iter(dp)) + assert BatchKey.nwp in batch + for nwp_source in batch[BatchKey.nwp].keys(): + assert NWPBatchKey.nwp in batch[BatchKey.nwp][nwp_source] + + +def test_pvnet_all_gsp_datapipe(pvnet_config_filename): + start_time = datetime(1900, 1, 1) + end_time = datetime(2050, 1, 1) + + dp = pvnet_all_gsp_datapipe( + pvnet_config_filename, + start_time=start_time, + end_time=end_time, + ) + + batch = next(iter(dp)) + assert BatchKey.nwp in batch + for nwp_source in batch[BatchKey.nwp].keys(): + assert NWPBatchKey.nwp in batch[BatchKey.nwp][nwp_source] From 4eb77a38323986d777b5959e1d04482e12721ace Mon Sep 17 00:00:00 2001 From: James Fulton Date: Mon, 3 Jun 2024 13:37:00 +0000 Subject: [PATCH 16/27] remove hack --- ocf_datapipes/load/nwp/providers/ecmwf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ocf_datapipes/load/nwp/providers/ecmwf.py b/ocf_datapipes/load/nwp/providers/ecmwf.py index a2aac5793..318f889b0 100644 --- a/ocf_datapipes/load/nwp/providers/ecmwf.py +++ b/ocf_datapipes/load/nwp/providers/ecmwf.py @@ -18,7 +18,6 @@ def open_ifs(zarr_path) -> xr.DataArray: """ # Open the data nwp = open_zarr_paths(zarr_path) - nwp = nwp.reindex(latitude=np.concatenate([np.arange(62, 60, -0.05), nwp.latitude.values])) dataVars = list(nwp.data_vars.keys()) if len(dataVars) > 1: raise Exception("Too many TLDVs") From d0b31f7ed3455cc6e359538aaf3b018d6c8b1723 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 13:37:32 +0000 Subject: [PATCH 17/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ocf_datapipes/load/nwp/providers/ecmwf.py | 1 - tests/data/configs/pvnet_test_config.yaml | 25 +++++++++---------- tests/data/gsp/test.zarr/.zattrs | 2 +- tests/data/gsp/test.zarr/.zgroup | 2 +- tests/data/gsp/test.zarr/.zmetadata | 2 +- tests/data/gsp/test.zarr/capacity_mwp/.zarray | 2 +- tests/data/gsp/test.zarr/capacity_mwp/.zattrs | 2 +- tests/data/gsp/test.zarr/datetime_gmt/.zarray | 2 +- tests/data/gsp/test.zarr/datetime_gmt/.zattrs | 2 +- .../data/gsp/test.zarr/generation_mw/.zarray | 2 +- .../data/gsp/test.zarr/generation_mw/.zattrs | 2 +- tests/data/gsp/test.zarr/gsp_id/.zarray | 2 +- tests/data/gsp/test.zarr/gsp_id/.zattrs | 2 +- .../test.zarr/installedcapacity_mwp/.zarray | 2 +- .../test.zarr/installedcapacity_mwp/.zattrs | 2 +- tests/training/test_common.py | 2 +- tests/training/test_pvnet.py | 3 +-- tests/training/test_pvnet_all_gsp.py | 2 -- 18 files changed, 27 insertions(+), 32 deletions(-) diff --git a/ocf_datapipes/load/nwp/providers/ecmwf.py b/ocf_datapipes/load/nwp/providers/ecmwf.py index 318f889b0..a4997b8e9 100644 --- a/ocf_datapipes/load/nwp/providers/ecmwf.py +++ b/ocf_datapipes/load/nwp/providers/ecmwf.py @@ -1,7 +1,6 @@ """ECMWF provider loaders""" import pandas as pd import xarray as xr -import numpy as np from ocf_datapipes.load.nwp.providers.utils import open_zarr_paths diff --git a/tests/data/configs/pvnet_test_config.yaml b/tests/data/configs/pvnet_test_config.yaml index d3a5c8ff6..fb4d6c3ea 100644 --- a/tests/data/configs/pvnet_test_config.yaml +++ b/tests/data/configs/pvnet_test_config.yaml @@ -15,20 +15,19 @@ input_data: dropout_fraction: 0 nwp: - ukv: - nwp_provider: ukv - nwp_zarr_path: tests/data/nwp_data/test.zarr - history_minutes: 60 - forecast_minutes: 120 - time_resolution_minutes: 60 - nwp_channels: + nwp_provider: ukv + nwp_zarr_path: tests/data/nwp_data/test.zarr + history_minutes: 60 + forecast_minutes: 120 + time_resolution_minutes: 60 + nwp_channels: - t # 2-metre temperature - nwp_image_size_pixels_height: 2 - nwp_image_size_pixels_width: 2 - dropout_timedeltas_minutes: [-180] - dropout_fraction: 1.0 - max_staleness_minutes: null + nwp_image_size_pixels_height: 2 + nwp_image_size_pixels_width: 2 + dropout_timedeltas_minutes: [-180] + dropout_fraction: 1.0 + max_staleness_minutes: null satellite: satellite_zarr_path: tests/data/sat_data.zarr @@ -41,4 +40,4 @@ input_data: satellite_image_size_pixels_height: 2 satellite_image_size_pixels_width: 2 dropout_timedeltas_minutes: null - dropout_fraction: 0 \ No newline at end of file + dropout_fraction: 0 diff --git a/tests/data/gsp/test.zarr/.zattrs b/tests/data/gsp/test.zarr/.zattrs index 9e26dfeeb..0967ef424 100644 --- a/tests/data/gsp/test.zarr/.zattrs +++ b/tests/data/gsp/test.zarr/.zattrs @@ -1 +1 @@ -{} \ No newline at end of file +{} diff --git a/tests/data/gsp/test.zarr/.zgroup b/tests/data/gsp/test.zarr/.zgroup index 3b7daf227..3f3fad2d1 100644 --- a/tests/data/gsp/test.zarr/.zgroup +++ b/tests/data/gsp/test.zarr/.zgroup @@ -1,3 +1,3 @@ { "zarr_format": 2 -} \ No newline at end of file +} diff --git a/tests/data/gsp/test.zarr/.zmetadata b/tests/data/gsp/test.zarr/.zmetadata index 2e0f2b7be..9fc94d56f 100644 --- a/tests/data/gsp/test.zarr/.zmetadata +++ b/tests/data/gsp/test.zarr/.zmetadata @@ -142,4 +142,4 @@ } }, "zarr_consolidated_format": 1 -} \ No newline at end of file +} diff --git a/tests/data/gsp/test.zarr/capacity_mwp/.zarray b/tests/data/gsp/test.zarr/capacity_mwp/.zarray index 2fa132365..1b355953d 100644 --- a/tests/data/gsp/test.zarr/capacity_mwp/.zarray +++ b/tests/data/gsp/test.zarr/capacity_mwp/.zarray @@ -19,4 +19,4 @@ 318 ], "zarr_format": 2 -} \ No newline at end of file +} diff --git a/tests/data/gsp/test.zarr/capacity_mwp/.zattrs b/tests/data/gsp/test.zarr/capacity_mwp/.zattrs index 5d2aefa6f..758489d41 100644 --- a/tests/data/gsp/test.zarr/capacity_mwp/.zattrs +++ b/tests/data/gsp/test.zarr/capacity_mwp/.zattrs @@ -3,4 +3,4 @@ "datetime_gmt", "gsp_id" ] -} \ No newline at end of file +} diff --git a/tests/data/gsp/test.zarr/datetime_gmt/.zarray b/tests/data/gsp/test.zarr/datetime_gmt/.zarray index 480d663c2..513e45066 100644 --- a/tests/data/gsp/test.zarr/datetime_gmt/.zarray +++ b/tests/data/gsp/test.zarr/datetime_gmt/.zarray @@ -17,4 +17,4 @@ 96 ], "zarr_format": 2 -} \ No newline at end of file +} diff --git a/tests/data/gsp/test.zarr/datetime_gmt/.zattrs b/tests/data/gsp/test.zarr/datetime_gmt/.zattrs index c4110214b..355ac1574 100644 --- a/tests/data/gsp/test.zarr/datetime_gmt/.zattrs +++ b/tests/data/gsp/test.zarr/datetime_gmt/.zattrs @@ -4,4 +4,4 @@ ], "calendar": "proleptic_gregorian", "units": "minutes since 2020-04-01 00:00:00" -} \ No newline at end of file +} diff --git a/tests/data/gsp/test.zarr/generation_mw/.zarray b/tests/data/gsp/test.zarr/generation_mw/.zarray index 2fa132365..1b355953d 100644 --- a/tests/data/gsp/test.zarr/generation_mw/.zarray +++ b/tests/data/gsp/test.zarr/generation_mw/.zarray @@ -19,4 +19,4 @@ 318 ], "zarr_format": 2 -} \ No newline at end of file +} diff --git a/tests/data/gsp/test.zarr/generation_mw/.zattrs b/tests/data/gsp/test.zarr/generation_mw/.zattrs index 5d2aefa6f..758489d41 100644 --- a/tests/data/gsp/test.zarr/generation_mw/.zattrs +++ b/tests/data/gsp/test.zarr/generation_mw/.zattrs @@ -3,4 +3,4 @@ "datetime_gmt", "gsp_id" ] -} \ No newline at end of file +} diff --git a/tests/data/gsp/test.zarr/gsp_id/.zarray b/tests/data/gsp/test.zarr/gsp_id/.zarray index dad10ae3c..dafc11290 100644 --- a/tests/data/gsp/test.zarr/gsp_id/.zarray +++ b/tests/data/gsp/test.zarr/gsp_id/.zarray @@ -17,4 +17,4 @@ 318 ], "zarr_format": 2 -} \ No newline at end of file +} diff --git a/tests/data/gsp/test.zarr/gsp_id/.zattrs b/tests/data/gsp/test.zarr/gsp_id/.zattrs index 243f4f1b5..6de1b9d63 100644 --- a/tests/data/gsp/test.zarr/gsp_id/.zattrs +++ b/tests/data/gsp/test.zarr/gsp_id/.zattrs @@ -2,4 +2,4 @@ "_ARRAY_DIMENSIONS": [ "gsp_id" ] -} \ No newline at end of file +} diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/.zarray b/tests/data/gsp/test.zarr/installedcapacity_mwp/.zarray index 2fa132365..1b355953d 100644 --- a/tests/data/gsp/test.zarr/installedcapacity_mwp/.zarray +++ b/tests/data/gsp/test.zarr/installedcapacity_mwp/.zarray @@ -19,4 +19,4 @@ 318 ], "zarr_format": 2 -} \ No newline at end of file +} diff --git a/tests/data/gsp/test.zarr/installedcapacity_mwp/.zattrs b/tests/data/gsp/test.zarr/installedcapacity_mwp/.zattrs index 5d2aefa6f..758489d41 100644 --- a/tests/data/gsp/test.zarr/installedcapacity_mwp/.zattrs +++ b/tests/data/gsp/test.zarr/installedcapacity_mwp/.zattrs @@ -3,4 +3,4 @@ "datetime_gmt", "gsp_id" ] -} \ No newline at end of file +} diff --git a/tests/training/test_common.py b/tests/training/test_common.py index 4af6cd3c7..052c4aeac 100644 --- a/tests/training/test_common.py +++ b/tests/training/test_common.py @@ -113,7 +113,7 @@ def test_create_t0_and_loc_datapipes(configuration_filename): assert isinstance(loc0, Location) assert isinstance(t0, np.datetime64) - + def test_construct_loctime_pipelines(configuration_filename): start_time = datetime(1900, 1, 1) end_time = datetime(2050, 1, 1) diff --git a/tests/training/test_pvnet.py b/tests/training/test_pvnet.py index 89bde4be7..d5073875f 100644 --- a/tests/training/test_pvnet.py +++ b/tests/training/test_pvnet.py @@ -10,7 +10,6 @@ from ocf_datapipes.utils import Location - def test_construct_sliced_data_pipeline(pvnet_config_filename): # This is randomly chosen, but real, GSP location loc_pipe = IterableWrapper([Location(x=246699.328125, y=849771.9375, id=18)]) @@ -43,4 +42,4 @@ def test_pvnet_datapipe(pvnet_config_filename): batch = next(iter(dp)) assert BatchKey.nwp in batch for nwp_source in batch[BatchKey.nwp].keys(): - assert NWPBatchKey.nwp in batch[BatchKey.nwp][nwp_source] \ No newline at end of file + assert NWPBatchKey.nwp in batch[BatchKey.nwp][nwp_source] diff --git a/tests/training/test_pvnet_all_gsp.py b/tests/training/test_pvnet_all_gsp.py index 5b0afb182..dbfc94b1b 100644 --- a/tests/training/test_pvnet_all_gsp.py +++ b/tests/training/test_pvnet_all_gsp.py @@ -9,9 +9,7 @@ from ocf_datapipes.batch import BatchKey, NWPBatchKey - def test_construct_sliced_data_pipeline(pvnet_config_filename): - # This is a randomly chosen time in the middle of the test data t0_pipe = IterableWrapper([datetime(2020, 4, 1, 13, 30)]) From 427a951ea8fcb8943aee7b2f9f537bec175105c4 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Mon, 3 Jun 2024 14:17:30 +0000 Subject: [PATCH 18/27] refactor convert submodule + test fix --- ocf_datapipes/convert/numpy_batch/__init__.py | 6 ++ ocf_datapipes/convert/numpy_batch/gsp.py | 51 ++++++------- ocf_datapipes/convert/numpy_batch/nwp.py | 50 +++++++------ ocf_datapipes/convert/numpy_batch/pv.py | 33 +++++---- .../convert/numpy_batch/satellite.py | 74 ++++++++++++------- ocf_datapipes/convert/numpy_batch/sensor.py | 38 +++++----- ocf_datapipes/convert/numpy_batch/wind.py | 32 ++++---- ocf_datapipes/training/pvnet_all_gsp.py | 68 +++++++++-------- 8 files changed, 198 insertions(+), 154 deletions(-) diff --git a/ocf_datapipes/convert/numpy_batch/__init__.py b/ocf_datapipes/convert/numpy_batch/__init__.py index 13dd7dfac..7c1cad3c4 100644 --- a/ocf_datapipes/convert/numpy_batch/__init__.py +++ b/ocf_datapipes/convert/numpy_batch/__init__.py @@ -1 +1,7 @@ """Conversion from Xarray to NumpyBatch""" +from .gsp import convert_gsp_to_numpy_batch +from .nwp import convert_nwp_to_numpy_batch +from .pv import convert_pv_to_numpy_batch +from .satellite import convert_satellite_to_numpy_batch +from .sensor import convert_sensor_to_numpy_batch +from .wind import convert_wind_to_numpy_batch \ No newline at end of file diff --git a/ocf_datapipes/convert/numpy_batch/gsp.py b/ocf_datapipes/convert/numpy_batch/gsp.py index f860939e5..9ccfef59c 100644 --- a/ocf_datapipes/convert/numpy_batch/gsp.py +++ b/ocf_datapipes/convert/numpy_batch/gsp.py @@ -9,6 +9,31 @@ logger = logging.getLogger(__name__) +def convert_gsp_to_numpy_batch(xr_data): + """Convert from Xarray to NumpyBatch""" + + example: NumpyBatch = { + BatchKey.gsp: xr_data.values, + BatchKey.gsp_t0_idx: xr_data.attrs["t0_idx"], + BatchKey.gsp_id: xr_data.gsp_id.values, + BatchKey.gsp_nominal_capacity_mwp: xr_data.isel(time_utc=0)["nominal_capacity_mwp"].values, + BatchKey.gsp_effective_capacity_mwp: ( + xr_data.isel(time_utc=0)["effective_capacity_mwp"].values + ), + BatchKey.gsp_time_utc: datetime64_to_float(xr_data["time_utc"].values), + } + + # Coordinates + for batch_key, dataset_key in ( + (BatchKey.gsp_y_osgb, "y_osgb"), + (BatchKey.gsp_x_osgb, "x_osgb"), + ): + if dataset_key in xr_data.coords.keys(): + example[batch_key] = xr_data[dataset_key].values + + return example + + @functional_datapipe("convert_gsp_to_numpy_batch") class ConvertGSPToNumpyBatchIterDataPipe(IterDataPipe): """Convert GSP Xarray to NumpyBatch""" @@ -25,29 +50,5 @@ def __init__(self, source_datapipe: IterDataPipe): def __iter__(self) -> NumpyBatch: """Convert from Xarray to NumpyBatch""" - logger.debug("Converting GSP to numpy to batch") for xr_data in self.source_datapipe: - example: NumpyBatch = { - BatchKey.gsp: xr_data.values, - BatchKey.gsp_t0_idx: xr_data.attrs["t0_idx"], - BatchKey.gsp_id: xr_data.gsp_id.values, - BatchKey.gsp_nominal_capacity_mwp: xr_data.isel(time_utc=0)[ - "nominal_capacity_mwp" - ].values, - BatchKey.gsp_effective_capacity_mwp: ( - xr_data.isel(time_utc=0)["effective_capacity_mwp"].values - ), - BatchKey.gsp_time_utc: datetime64_to_float(xr_data["time_utc"].values), - } - - # Coordinates - for batch_key, dataset_key in ( - (BatchKey.gsp_y_osgb, "y_osgb"), - (BatchKey.gsp_x_osgb, "x_osgb"), - ): - if dataset_key in xr_data.coords.keys(): - values = xr_data[dataset_key].values - # Expand dims so AddFourierSpaceTime works! - example[batch_key] = values # np.expand_dims(values, axis=1) - - yield example + yield convert_gsp_to_numpy_batch(xr_data) diff --git a/ocf_datapipes/convert/numpy_batch/nwp.py b/ocf_datapipes/convert/numpy_batch/nwp.py index 6dc3d953f..e016218ff 100644 --- a/ocf_datapipes/convert/numpy_batch/nwp.py +++ b/ocf_datapipes/convert/numpy_batch/nwp.py @@ -6,6 +6,32 @@ from ocf_datapipes.utils.utils import datetime64_to_float +def convert_nwp_to_numpy_batch(xr_data): + """Convert from Xarray to NWPBatchKey""" + + example: NWPNumpyBatch = { + NWPBatchKey.nwp: xr_data.values, + NWPBatchKey.nwp_t0_idx: xr_data.attrs["t0_idx"], + NWPBatchKey.nwp_channel_names: xr_data.channel.values, + NWPBatchKey.nwp_step: (xr_data.step.values / np.timedelta64(1, "h")).astype(np.int64), + NWPBatchKey.nwp_init_time_utc: datetime64_to_float(xr_data.init_time_utc.values), + } + + if "target_time_utc" in xr_data: + example[NWPBatchKey.nwp_target_time_utc] = datetime64_to_float( + xr_data.target_time_utc.values + ) + + for batch_key, dataset_key in ( + (NWPBatchKey.nwp_y_osgb, "y_osgb"), + (NWPBatchKey.nwp_x_osgb, "x_osgb"), + ): + if dataset_key in xr_data: + example[batch_key] = xr_data[dataset_key].values + + return example + + @functional_datapipe("convert_nwp_to_numpy_batch") class ConvertNWPToNumpyBatchIterDataPipe(IterDataPipe): """Convert NWP Xarray objects to NWPNumpyBatch""" @@ -23,26 +49,4 @@ def __init__(self, source_datapipe: IterDataPipe): def __iter__(self) -> NWPNumpyBatch: """Convert from Xarray to NWPBatchKey""" for xr_data in self.source_datapipe: - example: NWPNumpyBatch = { - NWPBatchKey.nwp: xr_data.values, - NWPBatchKey.nwp_t0_idx: xr_data.attrs["t0_idx"], - } - if "target_time_utc" in xr_data.coords: - target_time = xr_data.target_time_utc.values - example[NWPBatchKey.nwp_target_time_utc] = datetime64_to_float(target_time) - example[NWPBatchKey.nwp_channel_names] = xr_data.channel.values - example[NWPBatchKey.nwp_step] = (xr_data.step.values / np.timedelta64(1, "h")).astype( - np.int64 - ) - example[NWPBatchKey.nwp_init_time_utc] = datetime64_to_float( - xr_data.init_time_utc.values - ) - - for batch_key, dataset_key in ( - (NWPBatchKey.nwp_y_osgb, "y_osgb"), - (NWPBatchKey.nwp_x_osgb, "x_osgb"), - ): - if dataset_key in xr_data.coords.keys(): - example[batch_key] = xr_data[dataset_key].values - - yield example + yield convert_nwp_to_numpy_batch(xr_data) diff --git a/ocf_datapipes/convert/numpy_batch/pv.py b/ocf_datapipes/convert/numpy_batch/pv.py index fd9a478cc..2d6d432be 100644 --- a/ocf_datapipes/convert/numpy_batch/pv.py +++ b/ocf_datapipes/convert/numpy_batch/pv.py @@ -9,6 +9,21 @@ logger = logging.getLogger(__name__) +def convert_pv_to_numpy_batch(xr_data): + """Convert PV Xarray to NumpyBatch""" + example: NumpyBatch = { + BatchKey.pv: xr_data.values, + BatchKey.pv_t0_idx: xr_data.attrs["t0_idx"], + BatchKey.pv_ml_id: xr_data["ml_id"].values, + BatchKey.pv_id: xr_data["pv_system_id"].values.astype(np.float32), + BatchKey.pv_observed_capacity_wp: (xr_data["observed_capacity_wp"].values), + BatchKey.pv_nominal_capacity_wp: (xr_data["nominal_capacity_wp"].values), + BatchKey.pv_time_utc: datetime64_to_float(xr_data["time_utc"].values), + BatchKey.pv_latitude: xr_data["latitude"].values, + BatchKey.pv_longitude: xr_data["longitude"].values, + } + + return example @functional_datapipe("convert_pv_to_numpy_batch") class ConvertPVToNumpyBatchIterDataPipe(IterDataPipe): @@ -25,20 +40,6 @@ def __init__(self, source_datapipe: IterDataPipe): self.source_datapipe = source_datapipe def __iter__(self) -> NumpyBatch: - """Iterate and convert PV Xarray to NumpyBatch""" + """Convert PV Xarray to NumpyBatch""" for xr_data in self.source_datapipe: - logger.debug("Converting PV xarray to numpy example") - - example: NumpyBatch = { - BatchKey.pv: xr_data.values, - BatchKey.pv_t0_idx: xr_data.attrs["t0_idx"], - BatchKey.pv_ml_id: xr_data["ml_id"].values, - BatchKey.pv_id: xr_data["pv_system_id"].values.astype(np.float32), - BatchKey.pv_observed_capacity_wp: (xr_data["observed_capacity_wp"].values), - BatchKey.pv_nominal_capacity_wp: (xr_data["nominal_capacity_wp"].values), - BatchKey.pv_time_utc: datetime64_to_float(xr_data["time_utc"].values), - BatchKey.pv_latitude: xr_data["latitude"].values, - BatchKey.pv_longitude: xr_data["longitude"].values, - } - - yield example + yield convert_pv_to_numpy_batch(xr_data) diff --git a/ocf_datapipes/convert/numpy_batch/satellite.py b/ocf_datapipes/convert/numpy_batch/satellite.py index ec650894d..518d2ad62 100644 --- a/ocf_datapipes/convert/numpy_batch/satellite.py +++ b/ocf_datapipes/convert/numpy_batch/satellite.py @@ -5,6 +5,51 @@ from ocf_datapipes.utils.utils import datetime64_to_float +def _convert_satellite_to_numpy_batch(xr_data): + + example: NumpyBatch = { + BatchKey.satellite_actual: xr_data.values, + BatchKey.satellite_t0_idx: xr_data.attrs["t0_idx"], + BatchKey.satellite_time_utc: datetime64_to_float(xr_data["time_utc"].values), + } + + for batch_key, dataset_key in ( + (BatchKey.satellite_y_geostationary, "y_geostationary"), + (BatchKey.satellite_x_geostationary, "x_geostationary"), + ): + # HRVSatellite coords are already float32. + example[batch_key] = xr_data[dataset_key].values + + return example + + +def _convert_hrvsatellite_to_numpy_batch(xr_data): + + example: NumpyBatch = { + BatchKey.hrvsatellite_actual: xr_data.values, + BatchKey.hrvsatellite_t0_idx: xr_data.attrs["t0_idx"], + BatchKey.hrvsatellite_time_utc: datetime64_to_float(xr_data["time_utc"].values), + } + + for batch_key, dataset_key in ( + (BatchKey.hrvsatellite_y_geostationary, "y_geostationary"), + (BatchKey.hrvsatellite_x_geostationary, "x_geostationary"), + ): + # Satellite coords are already float32. + example[batch_key] = xr_data[dataset_key].values + + return example + + +def convert_satellite_to_numpy_batch(xr_data, is_hrv=False): + """Converts Xarray Satellite to NumpyBatch object""" + if is_hrv: + example = _convert_hrvsatellite_to_numpy_batch(xr_data) + else: + example = _convert_satellite_to_numpy_batch(xr_data) + return example + + @functional_datapipe("convert_satellite_to_numpy_batch") class ConvertSatelliteToNumpyBatchIterDataPipe(IterDataPipe): """Converts Xarray Satellite to NumpyBatch object""" @@ -24,31 +69,4 @@ def __init__(self, source_datapipe: IterDataPipe, is_hrv: bool = False): def __iter__(self) -> NumpyBatch: """Convert each example to a NumpyBatch object""" for xr_data in self.source_datapipe: - if self.is_hrv: - example: NumpyBatch = { - BatchKey.hrvsatellite_actual: xr_data.values, - BatchKey.hrvsatellite_t0_idx: xr_data.attrs["t0_idx"], - BatchKey.hrvsatellite_time_utc: datetime64_to_float(xr_data["time_utc"].values), - } - - for batch_key, dataset_key in ( - (BatchKey.hrvsatellite_y_geostationary, "y_geostationary"), - (BatchKey.hrvsatellite_x_geostationary, "x_geostationary"), - ): - # HRVSatellite coords are already float32. - example[batch_key] = xr_data[dataset_key].values - else: - example: NumpyBatch = { - BatchKey.satellite_actual: xr_data.values, - BatchKey.satellite_t0_idx: xr_data.attrs["t0_idx"], - BatchKey.satellite_time_utc: datetime64_to_float(xr_data["time_utc"].values), - } - - for batch_key, dataset_key in ( - (BatchKey.satellite_y_geostationary, "y_geostationary"), - (BatchKey.satellite_x_geostationary, "x_geostationary"), - ): - # HRVSatellite coords are already float32. - example[batch_key] = xr_data[dataset_key].values - - yield example + yield convert_satellite_to_numpy_batch(xr_data, self.is_hrv) \ No newline at end of file diff --git a/ocf_datapipes/convert/numpy_batch/sensor.py b/ocf_datapipes/convert/numpy_batch/sensor.py index 754aec447..e80f2d904 100644 --- a/ocf_datapipes/convert/numpy_batch/sensor.py +++ b/ocf_datapipes/convert/numpy_batch/sensor.py @@ -10,34 +10,38 @@ logger = logging.getLogger(__name__) +def convert_sensor_to_numpy_batch(xr_data): + """Convert Sensor Xarray to NumpyBatch""" + + example: NumpyBatch = { + BatchKey.sensor: xr_data.values, + BatchKey.sensor_t0_idx: xr_data.attrs["t0_idx"], + BatchKey.sensor_id: xr_data["station_id"].values.astype(np.float32), + # BatchKey.sensor_observed_capacity_wp: (xr_data["observed_capacity_wp"].values), + # BatchKey.sensor_nominal_capacity_wp: (xr_data["nominal_capacity_wp"].values), + BatchKey.sensor_time_utc: datetime64_to_float(xr_data["time_utc"].values), + BatchKey.sensor_latitude: xr_data["latitude"].values, + BatchKey.sensor_longitude: xr_data["longitude"].values, + } + return example + + + @functional_datapipe("convert_sensor_to_numpy_batch") class ConvertSensorToNumpyBatchIterDataPipe(IterDataPipe): """Convert Sensor Xarray to NumpyBatch""" def __init__(self, source_datapipe: IterDataPipe): """ - Convert PV Xarray objects to NumpyBatch objects + Convert sensor Xarray objects to NumpyBatch objects Args: - source_datapipe: Datapipe emitting PV Xarray objects + source_datapipe: Datapipe emitting sensor Xarray objects """ super().__init__() self.source_datapipe = source_datapipe def __iter__(self) -> NumpyBatch: - """Iterate and convert PV Xarray to NumpyBatch""" + """Iterate and convert sensor Xarray to NumpyBatch""" for xr_data in self.source_datapipe: - logger.debug("Converting Sensor xarray to numpy example") - - example: NumpyBatch = { - BatchKey.sensor: xr_data.values, - BatchKey.sensor_t0_idx: xr_data.attrs["t0_idx"], - BatchKey.sensor_id: xr_data["station_id"].values.astype(np.float32), - # BatchKey.sensor_observed_capacity_wp: (xr_data["observed_capacity_wp"].values), - # BatchKey.sensor_nominal_capacity_wp: (xr_data["nominal_capacity_wp"].values), - BatchKey.sensor_time_utc: datetime64_to_float(xr_data["time_utc"].values), - BatchKey.sensor_latitude: xr_data["latitude"].values, - BatchKey.sensor_longitude: xr_data["longitude"].values, - } - - yield example + yield convert_sensor_to_numpy_batch(xr_data) diff --git a/ocf_datapipes/convert/numpy_batch/wind.py b/ocf_datapipes/convert/numpy_batch/wind.py index 8495a6584..a8b11faf5 100644 --- a/ocf_datapipes/convert/numpy_batch/wind.py +++ b/ocf_datapipes/convert/numpy_batch/wind.py @@ -9,6 +9,22 @@ logger = logging.getLogger(__name__) +def convert_wind_to_numpy_batch(xr_data): + """Convert Wind Xarray to NumpyBatch""" + + example: NumpyBatch = { + BatchKey.wind: xr_data.values, + BatchKey.wind_t0_idx: xr_data.attrs["t0_idx"], + BatchKey.wind_ml_id: xr_data["ml_id"].values, + BatchKey.wind_id: xr_data["wind_system_id"].values.astype(np.float32), + BatchKey.wind_observed_capacity_mwp: (xr_data["observed_capacity_mwp"].values), + BatchKey.wind_nominal_capacity_mwp: (xr_data["nominal_capacity_mwp"].values), + BatchKey.wind_time_utc: datetime64_to_float(xr_data["time_utc"].values), + BatchKey.wind_latitude: xr_data["latitude"].values, + BatchKey.wind_longitude: xr_data["longitude"].values, + } + + return example @functional_datapipe("convert_wind_to_numpy_batch") class ConvertWindToNumpyBatchIterDataPipe(IterDataPipe): @@ -27,18 +43,4 @@ def __init__(self, source_datapipe: IterDataPipe): def __iter__(self) -> NumpyBatch: """Iterate and convert PV Xarray to NumpyBatch""" for xr_data in self.source_datapipe: - logger.debug("Converting Wind xarray to numpy example") - - example: NumpyBatch = { - BatchKey.wind: xr_data.values, - BatchKey.wind_t0_idx: xr_data.attrs["t0_idx"], - BatchKey.wind_ml_id: xr_data["ml_id"].values, - BatchKey.wind_id: xr_data["wind_system_id"].values.astype(np.float32), - BatchKey.wind_observed_capacity_mwp: (xr_data["observed_capacity_mwp"].values), - BatchKey.wind_nominal_capacity_mwp: (xr_data["nominal_capacity_mwp"].values), - BatchKey.wind_time_utc: datetime64_to_float(xr_data["time_utc"].values), - BatchKey.wind_latitude: xr_data["latitude"].values, - BatchKey.wind_longitude: xr_data["longitude"].values, - } - - yield example + yield convert_wind_to_numpy_batch(xr_data) diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py index ad9174bbe..11fd39647 100644 --- a/ocf_datapipes/training/pvnet_all_gsp.py +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -11,12 +11,13 @@ from ocf_datapipes.batch import MergeNumpyModalities, MergeNWPNumpyModalities from ocf_datapipes.batch.merge_numpy_examples_to_batch import stack_np_examples_into_batch from ocf_datapipes.config.model import Configuration -from ocf_datapipes.convert import ( - ConvertGSPToNumpyBatch, - ConvertNWPToNumpyBatch, - ConvertPVToNumpyBatch, - ConvertSatelliteToNumpyBatch, +from ocf_datapipes.convert.numpy_batch import ( + convert_gsp_to_numpy_batch, + convert_nwp_to_numpy_batch, + convert_pv_to_numpy_batch, + convert_satellite_to_numpy_batch, ) + from ocf_datapipes.load.gsp.utils import GSPLocationLookup from ocf_datapipes.select.select_spatial_slice import ( select_spatial_slice_meters, @@ -53,6 +54,7 @@ def xr_compute(xr_data): return xr_data.compute() +@functional_datapipe("sample_repeat") class SampleRepeat: """Use a single input element to create a list of identical values""" @@ -69,23 +71,28 @@ def __call__(self, x): return [x for _ in range(self.num_repeats)] -class ConvertWrapper(IterDataPipe): - """A class to adapt our Convert[X]ToNumpyBatch datapipes to work on a list of samples""" - - def __init__(self, source_datapipe: IterDataPipe, convert_class: Type[IterDataPipe]): - """A class to adapt our Convert[X]ToNumpyBatch datapipes to work on a list of samples - +@functional_datapipe("list_map") +class ListMap(IterDataPipe): + """Datapipe used to appky function to each item in yielded list""" + def __init__(self, source_datapipe: IterDataPipe, func, *args, **kwargs): + """Datapipe used to appky function to each item in yielded list. + Args: source_datapipe: The source datapipe yielding lists of samples - convert_class: The class to apply to the output of source_datapipe + function: The function to apply to all items in the list + *args: Args to pass to the function + **kwargs: Keyword arguments to pass to the function + """ + self.source_datapipe = source_datapipe - self.convert_class = convert_class - + self.func = func + self._args = args + self._kwargs = kwargs + def __iter__(self): - for concurrent_samples in self.source_datapipe: - dp = self.convert_class(IterableWrapper(concurrent_samples)) - yield [x for x in dp] + for element_list in self.source_datapipe: + yield [self.func(x, *self._args, **self._kwargs) for x in element_list] # ------------------------------ Multi-location datapipes ------------------------------ @@ -373,10 +380,11 @@ def post_spatial_slice_process(datapipes_dict, check_satellite_no_nans=False): nwp_numpy_modalities = dict() for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): - nwp_numpy_modalities[nwp_key] = ConvertWrapper( - nwp_datapipe, - ConvertNWPToNumpyBatch, - ).map(stack_np_examples_into_batch) + nwp_numpy_modalities[nwp_key] = ( + nwp_datapipe + .list_map(convert_nwp_to_numpy_batch) + .map(stack_np_examples_into_batch) + ) # Combine the NWPs into NumpyBatch nwp_numpy_modalities = MergeNWPNumpyModalities(nwp_numpy_modalities) @@ -384,23 +392,23 @@ def post_spatial_slice_process(datapipes_dict, check_satellite_no_nans=False): if "sat" in datapipes_dict: numpy_modalities.append( - ConvertWrapper(datapipes_dict["sat"], ConvertSatelliteToNumpyBatch).map( - stack_np_examples_into_batch - ) + datapipes_dict["sat"] + .list_map(convert_satellite_to_numpy_batch) + .map(stack_np_examples_into_batch) ) if "pv" in datapipes_dict: numpy_modalities.append( - ConvertWrapper(datapipes_dict["pv"], ConvertPVToNumpyBatch).map( - stack_np_examples_into_batch - ) + datapipes_dict["pv"] + .list_map(convert_pv_to_numpy_batch) + .map(stack_np_examples_into_batch) ) # GSP always assumed to be in data numpy_modalities.append( - ConvertWrapper(datapipes_dict["gsp"], ConvertGSPToNumpyBatch).map( - stack_np_examples_into_batch - ) + datapipes_dict["gsp"] + .list_map(convert_gsp_to_numpy_batch) + .map(stack_np_examples_into_batch) ) # Combine all the data sources From 1b5cda1472223de541f44d7a0d0a618eba5349c9 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Mon, 3 Jun 2024 14:17:57 +0000 Subject: [PATCH 19/27] fix test --- tests/training/test_common.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/training/test_common.py b/tests/training/test_common.py index 4af6cd3c7..df12bc2f2 100644 --- a/tests/training/test_common.py +++ b/tests/training/test_common.py @@ -1,9 +1,13 @@ +from datetime import datetime +import numpy as np import pytest + from torch.utils.data.datapipes.datapipe import IterDataPipe from torch.utils.data.datapipes.iter import Zipper +from torch.utils.data import DataLoader + from ocf_datapipes.config.model import Configuration from ocf_datapipes.utils import Location -from torch.utils.data import DataLoader from ocf_datapipes.training.common import ( add_selected_time_slices_from_datapipes, get_and_return_overlapping_time_periods_and_t0, @@ -12,12 +16,6 @@ construct_loctime_pipelines, ) -import fsspec -from pyaml_env import parse_config - -import pandas as pd -import numpy as np - def test_open_and_return_datapipes(configuration_filename): used_datapipes = open_and_return_datapipes(configuration_filename) From 91355b151703e99f0522c79fa700be4e74164464 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 14:18:44 +0000 Subject: [PATCH 20/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ocf_datapipes/convert/numpy_batch/__init__.py | 2 +- ocf_datapipes/convert/numpy_batch/gsp.py | 6 ++--- ocf_datapipes/convert/numpy_batch/nwp.py | 8 +++---- ocf_datapipes/convert/numpy_batch/pv.py | 4 +++- .../convert/numpy_batch/satellite.py | 8 +++---- ocf_datapipes/convert/numpy_batch/sensor.py | 3 +-- ocf_datapipes/convert/numpy_batch/wind.py | 6 +++-- ocf_datapipes/training/pvnet_all_gsp.py | 23 ++++++++----------- 8 files changed, 28 insertions(+), 32 deletions(-) diff --git a/ocf_datapipes/convert/numpy_batch/__init__.py b/ocf_datapipes/convert/numpy_batch/__init__.py index 7c1cad3c4..e576e4860 100644 --- a/ocf_datapipes/convert/numpy_batch/__init__.py +++ b/ocf_datapipes/convert/numpy_batch/__init__.py @@ -4,4 +4,4 @@ from .pv import convert_pv_to_numpy_batch from .satellite import convert_satellite_to_numpy_batch from .sensor import convert_sensor_to_numpy_batch -from .wind import convert_wind_to_numpy_batch \ No newline at end of file +from .wind import convert_wind_to_numpy_batch diff --git a/ocf_datapipes/convert/numpy_batch/gsp.py b/ocf_datapipes/convert/numpy_batch/gsp.py index 9ccfef59c..0b6909aad 100644 --- a/ocf_datapipes/convert/numpy_batch/gsp.py +++ b/ocf_datapipes/convert/numpy_batch/gsp.py @@ -11,7 +11,7 @@ def convert_gsp_to_numpy_batch(xr_data): """Convert from Xarray to NumpyBatch""" - + example: NumpyBatch = { BatchKey.gsp: xr_data.values, BatchKey.gsp_t0_idx: xr_data.attrs["t0_idx"], @@ -29,8 +29,8 @@ def convert_gsp_to_numpy_batch(xr_data): (BatchKey.gsp_x_osgb, "x_osgb"), ): if dataset_key in xr_data.coords.keys(): - example[batch_key] = xr_data[dataset_key].values - + example[batch_key] = xr_data[dataset_key].values + return example diff --git a/ocf_datapipes/convert/numpy_batch/nwp.py b/ocf_datapipes/convert/numpy_batch/nwp.py index e016218ff..b5d9452b0 100644 --- a/ocf_datapipes/convert/numpy_batch/nwp.py +++ b/ocf_datapipes/convert/numpy_batch/nwp.py @@ -8,7 +8,7 @@ def convert_nwp_to_numpy_batch(xr_data): """Convert from Xarray to NWPBatchKey""" - + example: NWPNumpyBatch = { NWPBatchKey.nwp: xr_data.values, NWPBatchKey.nwp_t0_idx: xr_data.attrs["t0_idx"], @@ -16,19 +16,19 @@ def convert_nwp_to_numpy_batch(xr_data): NWPBatchKey.nwp_step: (xr_data.step.values / np.timedelta64(1, "h")).astype(np.int64), NWPBatchKey.nwp_init_time_utc: datetime64_to_float(xr_data.init_time_utc.values), } - + if "target_time_utc" in xr_data: example[NWPBatchKey.nwp_target_time_utc] = datetime64_to_float( xr_data.target_time_utc.values ) - + for batch_key, dataset_key in ( (NWPBatchKey.nwp_y_osgb, "y_osgb"), (NWPBatchKey.nwp_x_osgb, "x_osgb"), ): if dataset_key in xr_data: example[batch_key] = xr_data[dataset_key].values - + return example diff --git a/ocf_datapipes/convert/numpy_batch/pv.py b/ocf_datapipes/convert/numpy_batch/pv.py index 2d6d432be..1476c7140 100644 --- a/ocf_datapipes/convert/numpy_batch/pv.py +++ b/ocf_datapipes/convert/numpy_batch/pv.py @@ -9,6 +9,7 @@ logger = logging.getLogger(__name__) + def convert_pv_to_numpy_batch(xr_data): """Convert PV Xarray to NumpyBatch""" example: NumpyBatch = { @@ -22,9 +23,10 @@ def convert_pv_to_numpy_batch(xr_data): BatchKey.pv_latitude: xr_data["latitude"].values, BatchKey.pv_longitude: xr_data["longitude"].values, } - + return example + @functional_datapipe("convert_pv_to_numpy_batch") class ConvertPVToNumpyBatchIterDataPipe(IterDataPipe): """Convert PV Xarray to NumpyBatch""" diff --git a/ocf_datapipes/convert/numpy_batch/satellite.py b/ocf_datapipes/convert/numpy_batch/satellite.py index 518d2ad62..ae7afaf97 100644 --- a/ocf_datapipes/convert/numpy_batch/satellite.py +++ b/ocf_datapipes/convert/numpy_batch/satellite.py @@ -6,7 +6,6 @@ def _convert_satellite_to_numpy_batch(xr_data): - example: NumpyBatch = { BatchKey.satellite_actual: xr_data.values, BatchKey.satellite_t0_idx: xr_data.attrs["t0_idx"], @@ -19,12 +18,11 @@ def _convert_satellite_to_numpy_batch(xr_data): ): # HRVSatellite coords are already float32. example[batch_key] = xr_data[dataset_key].values - + return example def _convert_hrvsatellite_to_numpy_batch(xr_data): - example: NumpyBatch = { BatchKey.hrvsatellite_actual: xr_data.values, BatchKey.hrvsatellite_t0_idx: xr_data.attrs["t0_idx"], @@ -37,7 +35,7 @@ def _convert_hrvsatellite_to_numpy_batch(xr_data): ): # Satellite coords are already float32. example[batch_key] = xr_data[dataset_key].values - + return example @@ -69,4 +67,4 @@ def __init__(self, source_datapipe: IterDataPipe, is_hrv: bool = False): def __iter__(self) -> NumpyBatch: """Convert each example to a NumpyBatch object""" for xr_data in self.source_datapipe: - yield convert_satellite_to_numpy_batch(xr_data, self.is_hrv) \ No newline at end of file + yield convert_satellite_to_numpy_batch(xr_data, self.is_hrv) diff --git a/ocf_datapipes/convert/numpy_batch/sensor.py b/ocf_datapipes/convert/numpy_batch/sensor.py index e80f2d904..3e8874106 100644 --- a/ocf_datapipes/convert/numpy_batch/sensor.py +++ b/ocf_datapipes/convert/numpy_batch/sensor.py @@ -12,7 +12,7 @@ def convert_sensor_to_numpy_batch(xr_data): """Convert Sensor Xarray to NumpyBatch""" - + example: NumpyBatch = { BatchKey.sensor: xr_data.values, BatchKey.sensor_t0_idx: xr_data.attrs["t0_idx"], @@ -24,7 +24,6 @@ def convert_sensor_to_numpy_batch(xr_data): BatchKey.sensor_longitude: xr_data["longitude"].values, } return example - @functional_datapipe("convert_sensor_to_numpy_batch") diff --git a/ocf_datapipes/convert/numpy_batch/wind.py b/ocf_datapipes/convert/numpy_batch/wind.py index a8b11faf5..3f04a9a0d 100644 --- a/ocf_datapipes/convert/numpy_batch/wind.py +++ b/ocf_datapipes/convert/numpy_batch/wind.py @@ -9,9 +9,10 @@ logger = logging.getLogger(__name__) + def convert_wind_to_numpy_batch(xr_data): """Convert Wind Xarray to NumpyBatch""" - + example: NumpyBatch = { BatchKey.wind: xr_data.values, BatchKey.wind_t0_idx: xr_data.attrs["t0_idx"], @@ -23,9 +24,10 @@ def convert_wind_to_numpy_batch(xr_data): BatchKey.wind_latitude: xr_data["latitude"].values, BatchKey.wind_longitude: xr_data["longitude"].values, } - + return example + @functional_datapipe("convert_wind_to_numpy_batch") class ConvertWindToNumpyBatchIterDataPipe(IterDataPipe): """Convert Wind Xarray to NumpyBatch""" diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py index 11fd39647..e9956bc42 100644 --- a/ocf_datapipes/training/pvnet_all_gsp.py +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -1,12 +1,11 @@ """Create the training/validation datapipe for training the PVNet Model""" import logging from datetime import datetime -from typing import List, Optional, Tuple, Type, Union +from typing import List, Optional, Tuple, Union import xarray as xr from torch.utils.data.datapipes._decorator import functional_datapipe from torch.utils.data.datapipes.datapipe import IterDataPipe -from torch.utils.data.datapipes.iter import IterableWrapper from ocf_datapipes.batch import MergeNumpyModalities, MergeNWPNumpyModalities from ocf_datapipes.batch.merge_numpy_examples_to_batch import stack_np_examples_into_batch @@ -17,7 +16,6 @@ convert_pv_to_numpy_batch, convert_satellite_to_numpy_batch, ) - from ocf_datapipes.load.gsp.utils import GSPLocationLookup from ocf_datapipes.select.select_spatial_slice import ( select_spatial_slice_meters, @@ -74,22 +72,23 @@ def __call__(self, x): @functional_datapipe("list_map") class ListMap(IterDataPipe): """Datapipe used to appky function to each item in yielded list""" + def __init__(self, source_datapipe: IterDataPipe, func, *args, **kwargs): """Datapipe used to appky function to each item in yielded list. - + Args: source_datapipe: The source datapipe yielding lists of samples function: The function to apply to all items in the list *args: Args to pass to the function **kwargs: Keyword arguments to pass to the function - + """ - + self.source_datapipe = source_datapipe self.func = func self._args = args self._kwargs = kwargs - + def __iter__(self): for element_list in self.source_datapipe: yield [self.func(x, *self._args, **self._kwargs) for x in element_list] @@ -380,10 +379,8 @@ def post_spatial_slice_process(datapipes_dict, check_satellite_no_nans=False): nwp_numpy_modalities = dict() for nwp_key, nwp_datapipe in datapipes_dict["nwp"].items(): - nwp_numpy_modalities[nwp_key] = ( - nwp_datapipe - .list_map(convert_nwp_to_numpy_batch) - .map(stack_np_examples_into_batch) + nwp_numpy_modalities[nwp_key] = nwp_datapipe.list_map(convert_nwp_to_numpy_batch).map( + stack_np_examples_into_batch ) # Combine the NWPs into NumpyBatch @@ -406,9 +403,7 @@ def post_spatial_slice_process(datapipes_dict, check_satellite_no_nans=False): # GSP always assumed to be in data numpy_modalities.append( - datapipes_dict["gsp"] - .list_map(convert_gsp_to_numpy_batch) - .map(stack_np_examples_into_batch) + datapipes_dict["gsp"].list_map(convert_gsp_to_numpy_batch).map(stack_np_examples_into_batch) ) # Combine all the data sources From 50a6f9e9a38ff8003c00597e64cf42fd139f3a87 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Mon, 3 Jun 2024 14:23:27 +0000 Subject: [PATCH 21/27] linting --- ocf_datapipes/training/pvnet_all_gsp.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py index e9956bc42..3ae552507 100644 --- a/ocf_datapipes/training/pvnet_all_gsp.py +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -52,7 +52,6 @@ def xr_compute(xr_data): return xr_data.compute() -@functional_datapipe("sample_repeat") class SampleRepeat: """Use a single input element to create a list of identical values""" @@ -78,7 +77,7 @@ def __init__(self, source_datapipe: IterDataPipe, func, *args, **kwargs): Args: source_datapipe: The source datapipe yielding lists of samples - function: The function to apply to all items in the list + func: The function to apply to all items in the list *args: Args to pass to the function **kwargs: Keyword arguments to pass to the function From 5d1551b0ec2b7107cd1864a83b08ccde06781541 Mon Sep 17 00:00:00 2001 From: James Fulton Date: Mon, 3 Jun 2024 14:43:34 +0000 Subject: [PATCH 22/27] fix --- ocf_datapipes/convert/numpy_batch/nwp.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/ocf_datapipes/convert/numpy_batch/nwp.py b/ocf_datapipes/convert/numpy_batch/nwp.py index b5d9452b0..fbf5a7fa5 100644 --- a/ocf_datapipes/convert/numpy_batch/nwp.py +++ b/ocf_datapipes/convert/numpy_batch/nwp.py @@ -13,20 +13,19 @@ def convert_nwp_to_numpy_batch(xr_data): NWPBatchKey.nwp: xr_data.values, NWPBatchKey.nwp_t0_idx: xr_data.attrs["t0_idx"], NWPBatchKey.nwp_channel_names: xr_data.channel.values, - NWPBatchKey.nwp_step: (xr_data.step.values / np.timedelta64(1, "h")).astype(np.int64), NWPBatchKey.nwp_init_time_utc: datetime64_to_float(xr_data.init_time_utc.values), + NWPBatchKey.nwp_step: (xr_data.step.values / np.timedelta64(1, "h")).astype(np.int64), } - - if "target_time_utc" in xr_data: - example[NWPBatchKey.nwp_target_time_utc] = datetime64_to_float( - xr_data.target_time_utc.values - ) - + + if "target_time_utc" in xr_data.coords: + target_time = xr_data.target_time_utc.values + example[NWPBatchKey.nwp_target_time_utc] = datetime64_to_float(target_time) + for batch_key, dataset_key in ( (NWPBatchKey.nwp_y_osgb, "y_osgb"), (NWPBatchKey.nwp_x_osgb, "x_osgb"), ): - if dataset_key in xr_data: + if dataset_key in xr_data.coords: example[batch_key] = xr_data[dataset_key].values return example From 4a9fcb78808ce3e8d17b7e5c3bbede5ebee3f7d2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 14:44:10 +0000 Subject: [PATCH 23/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ocf_datapipes/convert/numpy_batch/nwp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ocf_datapipes/convert/numpy_batch/nwp.py b/ocf_datapipes/convert/numpy_batch/nwp.py index fbf5a7fa5..f17240e27 100644 --- a/ocf_datapipes/convert/numpy_batch/nwp.py +++ b/ocf_datapipes/convert/numpy_batch/nwp.py @@ -16,11 +16,11 @@ def convert_nwp_to_numpy_batch(xr_data): NWPBatchKey.nwp_init_time_utc: datetime64_to_float(xr_data.init_time_utc.values), NWPBatchKey.nwp_step: (xr_data.step.values / np.timedelta64(1, "h")).astype(np.int64), } - + if "target_time_utc" in xr_data.coords: target_time = xr_data.target_time_utc.values example[NWPBatchKey.nwp_target_time_utc] = datetime64_to_float(target_time) - + for batch_key, dataset_key in ( (NWPBatchKey.nwp_y_osgb, "y_osgb"), (NWPBatchKey.nwp_x_osgb, "x_osgb"), From fb1358c6082518d62f51db3957d80ea72b2bf17f Mon Sep 17 00:00:00 2001 From: James Fulton Date: Mon, 3 Jun 2024 15:12:45 +0000 Subject: [PATCH 24/27] update tests --- tests/transform/xarray/test_normalize.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/transform/xarray/test_normalize.py b/tests/transform/xarray/test_normalize.py index fb00ee6f3..0798460cd 100644 --- a/tests/transform/xarray/test_normalize.py +++ b/tests/transform/xarray/test_normalize.py @@ -39,7 +39,9 @@ def test_normalize_gsp(gsp_datapipe): ) data = next(iter(normed_gsp_datapipe)) assert np.min(data) >= 0.0 - assert np.max(data) <= 1.0 + + # Some GSPs are noisey and seem to have values above 1 + assert np.max(data) <= 1.5 def test_normalize_passiv(passiv_datapipe): From 9afccec2b3041905ebee8df32766fd270782d129 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 15:13:29 +0000 Subject: [PATCH 25/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/transform/xarray/test_normalize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/transform/xarray/test_normalize.py b/tests/transform/xarray/test_normalize.py index 0798460cd..37b787bff 100644 --- a/tests/transform/xarray/test_normalize.py +++ b/tests/transform/xarray/test_normalize.py @@ -39,7 +39,7 @@ def test_normalize_gsp(gsp_datapipe): ) data = next(iter(normed_gsp_datapipe)) assert np.min(data) >= 0.0 - + # Some GSPs are noisey and seem to have values above 1 assert np.max(data) <= 1.5 From 7728511bef060f1ccb2ead3207ad8e8f2099807b Mon Sep 17 00:00:00 2001 From: James Fulton Date: Tue, 4 Jun 2024 10:02:50 +0000 Subject: [PATCH 26/27] docs --- ocf_datapipes/select/pick_locations.py | 2 ++ ocf_datapipes/training/pvnet_all_gsp.py | 28 +++++++++++++------------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/ocf_datapipes/select/pick_locations.py b/ocf_datapipes/select/pick_locations.py index 8e82c4678..81b913956 100644 --- a/ocf_datapipes/select/pick_locations.py +++ b/ocf_datapipes/select/pick_locations.py @@ -36,6 +36,7 @@ def __init__( self.shuffle = shuffle def _yield_all_iter(self, xr_dataset): + """Samples without replacement from possible locations""" # Get the spatial coords xr_coord_system, xr_x_dim, xr_y_dim = spatial_coord_type(xr_dataset) @@ -62,6 +63,7 @@ def _yield_all_iter(self, xr_dataset): yield location def _yield_random_iter(self, xr_dataset): + """Samples with replacement from possible locations""" # Get the spatial coords xr_coord_system, xr_x_dim, xr_y_dim = spatial_coord_type(xr_dataset) diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py index 3ae552507..61cabdfaa 100644 --- a/ocf_datapipes/training/pvnet_all_gsp.py +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -1,4 +1,18 @@ -"""Create the training/validation datapipe for training the PVNet Model""" +"""Create the training/validation datapipe for UK PVNet batches for all GSPs + + +The main public functions are: + + [1] `pvnet_all_gsp_datapipe()` + This constructs a datapipe yielding batches with inputs for all 317 UK GSPs for random t0 + times. + + [2] `construct_sliced_data_pipeline()` + Given a datapipe yielding t0 times, this function constructs a datapipe yielding batches + with inputs for all 317 UK GSPs for the yielded t0 times. This function is used inside [1]. + +""" + import logging from datetime import datetime from typing import List, Optional, Tuple, Union @@ -496,15 +510,3 @@ def pvnet_all_gsp_datapipe( ) return datapipe - - -if __name__ == "__main__": - import time - - t0 = time.time() - dp = pvnet_all_gsp_datapipe( - config_filename="/home/jamesfulton/repos/PVNet/configs/datamodule/configuration/gcp_configuration.yaml" - ) - - b = next(iter(dp)) - print(time.time() - t0) From e79560bba35a89a4af53cb15a664450d55d4e54e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Jun 2024 10:03:41 +0000 Subject: [PATCH 27/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ocf_datapipes/training/pvnet_all_gsp.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ocf_datapipes/training/pvnet_all_gsp.py b/ocf_datapipes/training/pvnet_all_gsp.py index 61cabdfaa..aa3da98c9 100644 --- a/ocf_datapipes/training/pvnet_all_gsp.py +++ b/ocf_datapipes/training/pvnet_all_gsp.py @@ -1,14 +1,13 @@ """Create the training/validation datapipe for UK PVNet batches for all GSPs - The main public functions are: [1] `pvnet_all_gsp_datapipe()` This constructs a datapipe yielding batches with inputs for all 317 UK GSPs for random t0 times. - + [2] `construct_sliced_data_pipeline()` - Given a datapipe yielding t0 times, this function constructs a datapipe yielding batches + Given a datapipe yielding t0 times, this function constructs a datapipe yielding batches with inputs for all 317 UK GSPs for the yielded t0 times. This function is used inside [1]. """