55import logging
66import shutil
77import time
8+ from collections .abc import Callable
89from functools import partial
910from logging import Logger
1011from warnings import warn
1718 validate_workable_files ,
1819)
1920
21+ # Module constants
22+ SUPPORTED_CONCAT_METHODS = ("xarray-concat" , "xarray-combine" )
23+ DEFAULT_XARRAY_SETTINGS = {"data_vars" : "minimal" , "coords" : "minimal" }
24+ DATATREE_OPEN_OPTIONS = {
25+ "decode_times" : False ,
26+ "decode_coords" : False ,
27+ "mask_and_scale" : False ,
28+ }
29+
2030default_logger = logging .getLogger (__name__ )
2131
2232
@@ -67,148 +77,151 @@ def stitchee(
6777 KeyError
6878 If datatrees have mismatched dataset nodes or sorting variable is not found
6979 """
80+ # Validate inputs
7081 if not files_to_concat :
7182 raise ValueError ("files_to_concat cannot be empty" )
7283 validate_input_path (files_to_concat )
73- concat_kwargs = concat_kwargs or {}
7484
75- _validate_concat_method_and_dim (concat_dim , concat_method )
85+ # Method validation is handled here
86+ concat_function = _create_concat_function (concat_method , concat_dim , concat_kwargs or {})
7687
7788 # Get workable files (those that can be opened and are not empty).
7889 input_files , num_input_files = validate_workable_files (files_to_concat , logger )
7990
80- # Handle zero files case : exit cleanly.
81- if num_input_files < 1 :
91+ # Zero files: exit cleanly.
92+ if num_input_files == 0 :
8293 logger .info ("No non-empty netCDF files found. Exiting." )
8394 return ""
8495
8596 output_file = validate_output_path (output_file , overwrite = overwrite_output_file )
8697
87- # Handle single file case : exit cleanly with the file copied.
98+ # Single file: exit cleanly with the file copied.
8899 if num_input_files == 1 :
89100 shutil .copyfile (input_files [0 ], output_file )
90- logger .info ("One workable netCDF file. Copied to output path without modification." )
101+ logger .info ("Single workable file, copied to output path without modification." )
91102 return output_file
92103
93- # Process multiple files.
94- start_time = time .time ()
95-
104+ # Process and concatenate multiple files.
96105 try :
106+ start_time = time .time ()
97107 datatree_list = _load_and_sort_datatrees (input_files , sorting_variable , logger )
98108
99- logger .info ("Concatenating files..." )
100- output_tree = _concatenate_datatrees (
101- datatree_list , concat_method , concat_dim , concat_kwargs
102- )
109+ logger .info ("Concatenating %d files..." , len (datatree_list ))
110+ output_tree = _concatenate_datatrees (datatree_list , concat_function )
103111
104112 _finalize_output (output_tree , output_file , history_to_append )
105113
106114 logger .info ("Total processing time: %.2f seconds" , time .time () - start_time )
115+ return output_file
107116
108117 except Exception as err :
109118 logger .error ("Stitchee encountered an error: %s" , str (err ))
110119 raise
111120
112- return output_file
113121
122+ def _create_concat_function (concat_method : str , concat_dim : str , concat_kwargs : dict ) -> Callable :
123+ """Create concatenation function after validating method and dimension requirements."""
124+ # Validate method
125+ if concat_method not in SUPPORTED_CONCAT_METHODS :
126+ raise ValueError (
127+ f"Unexpected concatenation method '{ concat_method } '. "
128+ f"Supported methods: { SUPPORTED_CONCAT_METHODS } "
129+ )
114130
115- def _validate_concat_method_and_dim (concat_dim : str , concat_method : str ) -> None :
116- """Validate concatenation method and warn if concat_dim won't be used."""
117- if concat_method not in ("xarray-concat" , "xarray-combine" ):
118- raise ValueError (f"Unexpected concatenation method: { concat_method } " )
131+ # Build base kwargs
132+ base_kwargs = {** DEFAULT_XARRAY_SETTINGS , ** concat_kwargs }
119133
120- if concat_method == "xarray-concat" and not concat_dim :
121- raise ValueError ("concat_dim is required when using 'xarray-concat' method" )
134+ # Method-specific validation and setup
135+ if concat_method == "xarray-concat" :
136+ if not concat_dim :
137+ raise ValueError ("concat_dim is required when using 'xarray-concat' method" )
138+ return partial (xr .concat , dim = concat_dim , ** base_kwargs )
122139
123- if concat_dim and (concat_method == "xarray-combine" ):
124- warn (
125- "'concat_dim' was specified but will not be used "
126- "because 'xarray-combine' method was selected."
127- )
140+ else : # concat_method == "xarray-combine"
141+ if concat_dim :
142+ warn (
143+ "'concat_dim' was specified but will not be used "
144+ "because 'xarray-combine' method was selected."
145+ )
146+ return partial (xr .combine_by_coords , ** base_kwargs )
128147
129148
130149def _load_and_sort_datatrees (
131150 input_files : list [str ], sorting_variable : str | None , logger : Logger
132151) -> list [xr .DataTree ]:
133- """Load datatrees while validating consistency, and return trees in a sorted list."""
134- datatree_list = []
135- sort_values = []
136- first_keys = None
152+ """Load datatrees from files, validate consistency, and return sorted list."""
153+ loaded_data = []
154+ expected_keys = None
137155
138156 for i , filepath in enumerate (input_files ):
139157 logger .info ("Processing file %03d/%03d <%s>" , i + 1 , len (input_files ), filepath )
140158
141- # Open data file and add to the datatree list.
142- datatree = xr .open_datatree (
143- filepath ,
144- decode_times = False ,
145- decode_coords = False ,
146- mask_and_scale = False ,
159+ # Load datatree with standard options
160+ datatree = xr .open_datatree (filepath , ** DATATREE_OPEN_OPTIONS )
161+
162+ # Validate consistency and get sort value
163+ expected_keys = _check_dataset_consistency (datatree , expected_keys , i + 1 , filepath )
164+ sort_value = _get_sort_value (datatree , sorting_variable , filepath , i , logger )
165+
166+ loaded_data .append ((sort_value , datatree ))
167+
168+ # Sort by values and return datatrees
169+ return [datatree for _ , datatree in sorted (loaded_data )]
170+
171+
172+ def _check_dataset_consistency (
173+ datatree : xr .DataTree , expected_keys : set | None , file_num : int , filepath : str
174+ ) -> set :
175+ """Check that dataset keys are consistent across files."""
176+ current_keys = set (datatree .to_dict ().keys ())
177+
178+ if expected_keys is None :
179+ return current_keys
180+
181+ if current_keys != expected_keys :
182+ diff = current_keys ^ expected_keys
183+ raise KeyError (
184+ f"File { file_num } ({ filepath } ) has mismatched dataset nodes. "
185+ f"Expected: { sorted (expected_keys )} , got: { sorted (current_keys )} , "
186+ f"differences: { sorted (diff )} "
147187 )
188+ return expected_keys
148189
149- # Check dataset node consistency immediately
150- current_keys = set (datatree .to_dict ().keys ())
151- if first_keys is None :
152- first_keys = current_keys
153- elif current_keys != first_keys :
154- mismatched = current_keys ^ first_keys
155- raise KeyError (
156- f"Mismatched dataset nodes. In file { i + 1 } ({ filepath } ), "
157- f"expected keys: { sorted (first_keys )} , got: { sorted (current_keys )} . "
158- f"Differences: { sorted (mismatched )} "
159- )
160190
161- datatree_list .append (datatree )
162-
163- # Validate and extract sorting value.
164- if sorting_variable :
165- try :
166- sort_value = datatree [sorting_variable ].values .flatten ()[0 ]
167- except KeyError as err :
168- logger .error (
169- f"Cannot extract sorting value from '{ sorting_variable } ' in { filepath } : { err } "
170- )
171- raise
172- else :
173- sort_value = i
174-
175- sort_values .append (sort_value )
176-
177- # Reorder the datatrees according to the sorting key values.
178- sorted_pairs = sorted (zip (sort_values , datatree_list ), key = lambda x : x [0 ])
179- datatree_list = [datatree for _ , datatree in sorted_pairs ]
180- return datatree_list
181-
182-
183- def _concatenate_datatrees (
184- datatree_list : list [xr .DataTree ], concat_method : str , concat_dim : str , concat_kwargs : dict
185- ) -> xr .DataTree :
186- """Concatenate the datatrees using the specified method."""
187- if not datatree_list : # Add this check
188- raise ValueError ("Cannot concatenate empty list of datatrees" )
191+ def _get_sort_value (
192+ datatree : xr .DataTree , sorting_variable : str | None , filepath : str , index : int , logger : Logger
193+ ) -> float | int :
194+ """Extract sorting value from datatree or return file index."""
195+ if not sorting_variable :
196+ return index
189197
190- concat_func = {
191- "xarray-concat" : partial (xr .concat , dim = concat_dim ),
192- "xarray-combine" : xr .combine_by_coords ,
193- }.get (concat_method )
198+ try :
199+ return datatree [sorting_variable ].values .flatten ()[0 ]
200+ except Exception as err :
201+ logger .error (
202+ "Cannot extract sorting value from '%s' in %s: %s" , sorting_variable , filepath , err
203+ )
204+ raise
194205
195- if concat_func is None :
196- raise ValueError (f"Unexpected concatenation method: { concat_method } " )
197206
207+ def _concatenate_datatrees (datatree_list : list [xr .DataTree ], concat_func : Callable ) -> xr .DataTree :
208+ """Concatenate datatrees using a pre-configured concatenation function (e.g., partial(xr.concat, dim='time'))"""
209+ if not datatree_list :
210+ raise ValueError ("Cannot concatenate empty list of datatrees" )
211+
212+ # Convert to dictionaries and get dataset keys (should be consistent across all trees)
198213 tree_dicts = [tree .to_dict () for tree in datatree_list ]
199- first_keys = set (tree_dicts [0 ].keys ())
200-
201- return xr .DataTree .from_dict (
202- {
203- key : concat_func (
204- [td [key ] for td in tree_dicts ],
205- data_vars = "minimal" ,
206- coords = "minimal" ,
207- ** concat_kwargs ,
208- )
209- for key in first_keys
210- }
211- )
214+ dataset_keys = set (tree_dicts [0 ].keys ())
215+
216+ # Concatenate each dataset separately
217+ concatenated_datasets = {}
218+ for key in dataset_keys :
219+ # Extract the same dataset from each tree
220+ datasets_to_concat = [tree_dict [key ] for tree_dict in tree_dicts ]
221+ # Concatenate this dataset across all trees
222+ concatenated_datasets [key ] = concat_func (datasets_to_concat )
223+
224+ return xr .DataTree .from_dict (concatenated_datasets )
212225
213226
214227def _finalize_output (
@@ -218,5 +231,3 @@ def _finalize_output(
218231 if history_to_append is not None :
219232 output_tree .attrs ["history_json" ] = history_to_append
220233 output_tree .to_netcdf (output_file )
221-
222- # new_global_attributes = create_new_attributes(combined_ds, request_parameters=dict())
0 commit comments