Skip to content

Commit 88593ad

Browse files
consolidate code, including concatenation method validation and selection
1 parent 62e5567 commit 88593ad

1 file changed

Lines changed: 106 additions & 95 deletions

File tree

concatenator/stitchee.py

Lines changed: 106 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import shutil
77
import time
8+
from collections.abc import Callable
89
from functools import partial
910
from logging import Logger
1011
from warnings import warn
@@ -17,6 +18,15 @@
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+
2030
default_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

130149
def _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

214227
def _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

Comments
 (0)