22#
33# This file is part of 'IMPROVER' and is released under the BSD 3-Clause license.
44# See LICENSE in the root of the repository for full licensing details.
5- """Script to load inputs and train a model using Quantile Regression Random Forest (QRF)."""
5+ """Script to load inputs and train a model using Quantile Regression Random Forest
6+ (QRF)."""
67
78import pathlib
89from pathlib import Path
10+ from typing import Optional
911
1012import iris
1113import numpy as np
@@ -56,8 +58,24 @@ def __init__(
5658 self .pre_transform_addition = pre_transform_addition
5759 self .compression = compression
5860
59- def _split_cubes_and_parquet_files (self , file_paths ):
60- """Split the input file paths into cubes and parquet files."""
61+ def _split_cubes_and_parquet_files (
62+ self , file_paths : list [pathlib .Path | str ]
63+ ) -> tuple [Optional [pathlib .Path ], Optional [pathlib .Path ], iris .cube .CubeList ]:
64+ """Split the input file paths into cubes and parquet files.
65+
66+ Args:
67+ file_paths: List of file paths.
68+
69+ Returns:
70+ Tuple containing the items below if found:
71+ - Path to the forecast parquet file.
72+ - Path to the truth parquet file.
73+ - List of cubes loaded from the NetCDF files.
74+
75+ Raises:
76+ ValueError: If the number of cubes loaded does not match the number of
77+ features expected.
78+ """
6179
6280 forecast_table_path = None
6381 truth_table_path = None
@@ -69,7 +87,8 @@ def _split_cubes_and_parquet_files(self, file_paths):
6987 cube = load_cube (str (file_path ))
7088 cube_inputs .append (cube )
7189 except IsADirectoryError :
72- # For loop here because the read_schema must read a .parquet file rather than a directory.
90+ # For loop here because the read_schema must read a .parquet file
91+ # rather than a directory.
7392 for file in Path (file_path ).glob ("**/*.parquet" ):
7493 try :
7594 pq .read_schema (file ).field ("forecast_period" )
@@ -79,17 +98,18 @@ def _split_cubes_and_parquet_files(self, file_paths):
7998 if forecast_table_path and truth_table_path :
8099 break
81100 except OSError :
82- print ("The directory doesn't exist, calibration is skipped for this cycle" )
83- return
101+ # This will occur when the filepath does not exist. In this case,
102+ # return None.
103+ return None , None , None
84104
85105 if len (self .feature_config .keys ()) not in [
86106 len (cube_inputs ),
87107 len (cube_inputs ) + 1 ,
88108 ]:
89109 msg = (
90110 "The number of cubes loaded does not match the number of features "
91- "expected. These can mismatch if the some features are coming from the "
92- "historic forecast. The number of cubes loaded was: "
111+ "expected. These can mismatch if some features are coming from the "
112+ "historic forecast table . The number of cubes loaded was: "
93113 f"{ len (cube_inputs )} . The number of features expected was: "
94114 f"{ len (self .feature_config .keys ())} ."
95115 )
@@ -98,9 +118,29 @@ def _split_cubes_and_parquet_files(self, file_paths):
98118 return forecast_table_path , truth_table_path , cube_inputs
99119
100120 def _read_parquet_files (
101- self , forecast_table_path , truth_table_path , forecast_periods
102- ):
103- """Read the forecast and truth data from parquet files."""
121+ self ,
122+ forecast_table_path : pathlib .Path | str ,
123+ truth_table_path : pathlib .Path | str ,
124+ forecast_periods : list [int ],
125+ ) -> tuple [pd .DataFrame , pd .DataFrame ]:
126+ """Read the forecast and truth data from parquet files.
127+
128+ Args:
129+ forecast_table_path: Path to the forecast parquet file.
130+ truth_table_path: Path to the truth parquet file.
131+ forecast_periods: List of forecast periods in seconds.
132+
133+ Returns:
134+ Tuple containing:
135+ - DataFrame containing the forecast data.
136+ - DataFrame containing the truth data.
137+
138+ Raises:
139+ ValueError: If the forecast parquet file does not contain the expected
140+ fields.
141+ ValueError: If the truth parquet file does not contain the expected
142+ fields.
143+ """
104144 cycletimes = []
105145
106146 for forecast_period in forecast_periods :
@@ -151,7 +191,8 @@ def _read_parquet_files(
151191 engine = "pyarrow" ,
152192 )
153193
154- # Convert df columns from ms to pandas timestamp object to work with existing code
194+ # Convert df columns from ms to pandas timestamp object to work with existing
195+ # code
155196 for column in ["time" , "forecast_reference_time" , "blend_time" ]:
156197 forecast_df [column ] = pd .to_datetime (
157198 forecast_df [column ], unit = "ns" , utc = True
@@ -166,7 +207,7 @@ def _read_parquet_files(
166207 truth_df = pd .read_parquet (
167208 truth_table_path , filters = filters , schema = TRUTH_SCHEMA , engine = "pyarrow"
168209 )
169-
210+
170211 truth_df ["time" ] = pd .to_datetime (truth_df ["time" ], unit = "ns" , utc = True )
171212
172213 if truth_df .empty :
@@ -177,9 +218,28 @@ def _read_parquet_files(
177218 raise IOError (msg )
178219 return forecast_df , truth_df
179220
180- def _dataframe_to_cubes (self , forecast_df , truth_df , forecast_periods ):
221+ def _dataframe_to_cubes (
222+ self ,
223+ forecast_df : pd .DataFrame ,
224+ truth_df : pd .DataFrame ,
225+ forecast_periods : list [int ],
226+ ) -> tuple [iris .cube .Cube , iris .cube .Cube ]:
181227 """Convert the forecast and truth dataframes to cubes at each forecast period
182- required."""
228+ required.
229+
230+ Args:
231+ forecast_df: DataFrame containing the forecast data.
232+ truth_df: DataFrame containing the truth data.
233+ forecast_periods: List of forecast periods in seconds.
234+
235+ Returns:
236+ Tuple containing:
237+ - Cube containing the forecast data.
238+ - Cube containing the truth data.
239+
240+ Raises:
241+ ValueError: The forecast has failed to concatenate into a single cube.
242+ """
183243 forecast_cubes = iris .cube .CubeList ([])
184244 truth_cubes = iris .cube .CubeList ([])
185245
@@ -226,14 +286,28 @@ def _dataframe_to_cubes(self, forecast_df, truth_df, forecast_periods):
226286 return forecast_cube , truth_cube
227287
228288 @staticmethod
229- def filter_bad_sites (forecast_cube , truth_cube , cube_inputs ):
230- """Remove sites that have NaNs in the data."""
231- bad_site_ids = []
289+ def filter_bad_sites (
290+ forecast_cube : iris .cube .Cube ,
291+ truth_cube : iris .cube .Cube ,
292+ cube_inputs : iris .cube .CubeList ,
293+ ) -> tuple [iris .cube .Cube , iris .cube .Cube , iris .cube .CubeList ]:
294+ """Remove sites that have NaNs in the data.
295+
296+ Args:
297+ forecast_cube: Cube containing the forecast data.
298+ truth_cube: Cube containing the truth data.
299+ cube_inputs: List of additional feature cubes.
300+
301+ Returns:
302+ Tuple containing:
303+ - Cube containing the forecast data with bad sites removed.
304+ - Cube containing the truth data with bad sites removed.
305+ - List of additional feature cubes with bad sites removed.
306+ """
232307 nan_mask = np .any (np .isnan (truth_cube .data ), axis = truth_cube .coord_dims ("time" ))
233308 all_site_ids = truth_cube .coord ("wmo_id" ).points
234309 bad_site_ids = all_site_ids [nan_mask ]
235- wmo_ids = set (all_site_ids ) - set (bad_site_ids )
236- constr = iris .Constraint (wmo_id = lambda cell : cell in wmo_ids )
310+ constr = iris .Constraint (wmo_id = lambda cell : cell not in bad_site_ids .tolist ())
237311 truth_cube = truth_cube .extract (constr )
238312 forecast_cube = forecast_cube .extract (constr )
239313 feature_cube_inputs = iris .cube .CubeList ([])
@@ -247,13 +321,11 @@ def process(
247321 self ,
248322 file_paths : list [pathlib .Path | str ],
249323 model_output : str = None ,
250- ):
251- """Loading input files and training a model using Quantile Regression Random Forest.
252-
253- Loads in arguments for training a Quantile Regression Random Forest (QRF)
254- model which can later be applied to calibrate the forecast.
255- Two sources of input data must be provided: historical forecasts and
256- historical truth data (to use in calibration). The model is output as a pickle file.
324+ ) -> None :
325+ """Load input files and training a Quantile Regression Random Forest (QRF)
326+ model. This model can be applied later to calibrate the forecast. Two sources
327+ of input data must be provided: historical forecasts and historical truth data
328+ (to use in calibration). The model is output as a pickle file.
257329
258330 Args:
259331 file_paths (cli.inputpaths):
@@ -266,15 +338,16 @@ def process(
266338 for calibration.
267339 - Optionally, paths to NetCDF files containing additional predictors.
268340 feature_config (dict):
269- Feature configuration defining the features to be used for quantile regression.
270- The configuration is a dictionary of strings, where the keys are the names of
271- the input cube(s) supplied, and the values are a list. This list can contain both
272- computed features, such as the mean or standard deviation (std), or static
273- features, such as the altitude. The computed features will be computed using
274- the cube defined in the dictionary key. If the key is the feature itself e.g.
275- a distance to water cube, then the value should state "static". This will ensure
276- the cube's data is used as the feature.
277- The config will have the structure:
341+ Feature configuration defining the features to be used for quantile
342+ regression. The configuration is a dictionary of strings, where the
343+ keys are the names of the input cube(s) supplied, and the values are
344+ a list. This list can contain both computed features, such as the mean
345+ or standard deviation (std), or static features, such as the altitude.
346+ The computed features will be computed using the cube defined in the
347+ dictionary key. If the key is the feature itself e.g. a distance to
348+ water cube, then the value should state "static". This will ensure
349+ the cube's data is used as the feature. The config will have the
350+ structure:
278351 "DYNAMIC_VARIABLE_NAME": ["FEATURE1", "FEATURE2"] e.g:
279352 {
280353 "air_temperature": ["mean", "std", "altitude"],
@@ -286,8 +359,10 @@ def process(
286359 calibrated. This will be used to filter the target forecast and truth
287360 dataframes.
288361 forecast_period (int):
289- Range of forecast periods to be including in training in hours in the
290- form: "start:end:interval" e.g. "6:18:6".
362+ Range of forecast periods to be calibrated in hours in the form:
363+ "start:end:interval" e.g. "6:18:6" or a single forecast period e.g. "6".
364+ The end value is exclusive, so "6:18:6" will calibrate the 6 and 12
365+ hours.
291366 cycletime (str):
292367 Cycletime of the forecast to be calibrated in a format similar to
293368 20170109T0000Z. This is used to filter the correct blendtimes from
@@ -321,8 +396,19 @@ def process(
321396 if not forecast_table_path or not truth_table_path :
322397 return None
323398
324- forecast_periods = list (range (* map (int , self .forecast_periods .split (":" ))))
325- forecast_periods = [fp * 3600 for fp in forecast_periods ]
399+ if ":" in self .forecast_periods :
400+ forecast_periods = list (range (* map (int , self .forecast_periods .split (":" ))))
401+ forecast_periods = [fp * 3600 for fp in forecast_periods ]
402+ else :
403+ try :
404+ forecast_periods = [int (self .forecast_periods ) * 3600 ]
405+ except ValueError :
406+ msg = (
407+ "The forecast_periods argument must be a single integer or "
408+ "a range in the form 'start:end:interval'. The forecast period"
409+ f"provided was: { self .forecast_periods } ."
410+ )
411+ raise ValueError (msg )
326412
327413 forecast_df , truth_df = self ._read_parquet_files (
328414 forecast_table_path , truth_table_path , forecast_periods
0 commit comments