1111import logging
1212import os
1313import sys
14+ import time
1415from concurrent .futures import ThreadPoolExecutor
1516from concurrent .futures import as_completed
1617from typing import Any
1718
19+ import numpy as np
1820import tqdm
1921from anemoi .utils .remote import Transfer
2022from anemoi .utils .remote import TransferMethodNotImplementedError
2123
24+ from anemoi .datasets .compat import ZarrFileNotFoundError
25+ from anemoi .datasets .compat import zarr_append_mode
26+ from anemoi .datasets .compat import zarr_private_files
27+ from anemoi .datasets .compat import zarr_version
2228from anemoi .datasets .misc .check import check_zarr
2329from anemoi .datasets .usage .store import dataset_lookup
2430from anemoi .datasets .usage .store import open_zarr
@@ -63,6 +69,8 @@ class ZarrCopier:
6369 Flag to obfuscate the data during transfer. This will generate random data that match the statistics. Useful for testing and benchmarking.
6470 rechunk : str
6571 Rechunk size for the target data array.
72+ reshard : str
73+ Reshard size for the target data array.
6674 """
6775
6876 def __init__ (
@@ -77,6 +85,7 @@ def __init__(
7785 nested : bool ,
7886 obfuscate : bool ,
7987 rechunk : str ,
88+ reshard : str = None ,
8089 ** kwargs : Any ,
8190 ) -> None :
8291 """Initialize the ZarrCopier.
@@ -103,6 +112,8 @@ def __init__(
103112 Flag to obfuscate the data during transfer.
104113 rechunk : str
105114 Rechunk size for the target data array.
115+ reshard : str
116+ Reshard size for the target data array.
106117 **kwargs : Any
107118 Additional keyword arguments.
108119 """
@@ -118,6 +129,7 @@ def __init__(
118129 self .obfuscate = obfuscate
119130
120131 self .rechunking = rechunk .split ("," ) if rechunk else []
132+ self .resharding = reshard .split ("," ) if reshard else []
121133
122134 source_is_ssh = self .source .startswith ("ssh://" )
123135 target_is_ssh = self .target .startswith ("ssh://" )
@@ -202,6 +214,25 @@ def copy_chunk(self, n: int, m: int, source: Any, target: Any, _copy: Any, verbo
202214
203215 return slice (n , m )
204216
217+ def _parse_reshaping (self , new , old , shape ) -> tuple :
218+ if old is not None :
219+ old = list (old )
220+
221+ if new is None :
222+ return old
223+
224+ result = [s for s in (shape if old is None else old )]
225+
226+ for i , c in enumerate (new ):
227+ if c in ("full" , "-1" , "" ):
228+ continue
229+ c = int (c )
230+ c = min (c , shape [i ])
231+ result [i ] = c
232+
233+ result = tuple (result )
234+ return result
235+
205236 def parse_rechunking (self , rechunking : list [str ], source_data : Any ) -> tuple :
206237 """Parse the rechunking configuration.
207238
@@ -217,25 +248,35 @@ def parse_rechunking(self, rechunking: list[str], source_data: Any) -> tuple:
217248 tuple
218249 Parsed chunk sizes.
219250 """
220- shape = source_data .shape
221- chunks = list (source_data .chunks )
222- for i , c in enumerate (rechunking ):
223- if not c :
224- continue
225- elif c == "full" :
226- chunks [i ] = shape [i ]
227- continue
228- c = int (c )
229- c = min (c , shape [i ])
230- chunks [i ] = c
231- chunks = tuple (chunks )
251+ chunks = self ._parse_reshaping (new = rechunking , old = source_data .chunks , shape = source_data .shape )
232252
233253 if chunks != source_data .chunks :
234254 LOG .info (f"Rechunking data from { source_data .chunks } to { chunks } " )
235- # if self.transfers > 1:
236- # raise NotImplementedError("Rechunking with multiple transfers is not implemented")
255+
237256 return chunks
238257
258+ def parse_resharding (self , resharding : list [str ], source_data : Any ) -> tuple :
259+ """Parse the resharding configuration.
260+
261+ Parameters
262+ ----------
263+ resharding : list of str
264+ List of reshard sizes.
265+ source_data : Any
266+ Source data.
267+
268+ Returns
269+ -------
270+ tuple
271+ Parsed shard sizes.
272+ """
273+ shards = self ._parse_reshaping (new = resharding , old = source_data .shards , shape = source_data .shape )
274+
275+ if shards != source_data .shards :
276+ LOG .info (f"Resharding data from { source_data .shards } to { shards } (shape is { source_data .shape } )" )
277+
278+ return shards
279+
239280 def copy_data (self , source : Any , target : Any , _copy : Any , verbosity : int ) -> None :
240281 """Copy data from source to target.
241282
@@ -252,25 +293,72 @@ def copy_data(self, source: Any, target: Any, _copy: Any, verbosity: int) -> Non
252293 """
253294 LOG .info ("Copying data" )
254295 source_data = source ["data" ]
296+ start = time .time ()
297+
298+ extra = {}
299+ if self .rechunking :
300+ extra ["chunks" ] = self .parse_rechunking (self .rechunking , source_data )
301+
302+ if self .resharding :
303+ extra ["shards" ] = self .parse_resharding (self .resharding , source_data )
304+ extra ["chunks" ] = (
305+ self .parse_rechunking (self .rechunking , source_data ) if self .rechunking else source_data .chunks
306+ )
307+ ratio = []
308+ for shard , chunk in zip (extra ["shards" ], extra ["chunks" ]):
309+ if shard % chunk != 0 :
310+ raise ValueError (f"Shard size { shard } is not a multiple of chunk size { chunk } ." )
311+ ratio .append (shard // chunk )
255312
256- self .data_chunks = self .parse_rechunking (self .rechunking , source_data )
313+ LOG .info (f"Shards for target data array: { extra ['shards' ]} (ratio={ ratio } )" )
314+
315+ LOG .info (f"Chunks: source={ source_data .chunks } " )
316+ if zarr_version >= 3 :
317+ LOG .info (f"Shards: source={ source_data .shards } " )
318+
319+ if extra :
320+ LOG .info (f"Using extra parameters for target data array: { extra } " )
321+
322+ self .data_chunks = extra .get ("chunks" , source_data .chunks )
257323
258324 if self .block_size is None :
259325 self .block_size = max (self .data_chunks [0 ], 100 )
260326
261- target_data = (
262- target ["data" ]
263- if "data" in target
264- else target .create_dataset (
327+ if "data" in target :
328+ target_data = target ["data" ]
329+ if extra :
330+ LOG .warning ("Target data array already exists, ignoring resharding/rechunking parameters." )
331+ LOG .warning (f"Existing target data array chunks: { target_data .chunks } " )
332+ if zarr_version >= 3 :
333+ LOG .warning (f"Existing target data array shards: { target_data .shards } " )
334+ else :
335+ extra .setdefault ("chunks" , source_data .chunks )
336+ target_data = target .create_array (
265337 "data" ,
266338 shape = source_data .shape ,
267- chunks = self .data_chunks ,
268339 dtype = source_data .dtype ,
269340 fill_value = source_data .fill_value ,
341+ ** extra ,
270342 )
271- )
272343
273- LOG .info (f"Using { self .transfers } parallel transfers and block size of { self .block_size } " )
344+ size = 1
345+ size = target_data .chunks [0 ] if target_data .chunks else size
346+ if zarr_version >= 3 :
347+ size = target_data .shards [0 ] if target_data .shards else size
348+
349+ block_size = self .block_size
350+
351+ block_size = (block_size // size ) * size
352+ if block_size < size :
353+ block_size = size
354+
355+ if block_size != self .block_size :
356+ LOG .info (
357+ f"Adjusted block size from { self .block_size } to { block_size } to be multiple of chunk/shard size { size } { target_data .chunks } ."
358+ )
359+ self .block_size = block_size
360+
361+ LOG .info (f"Using block size { self .block_size } , parallel transfers { self .transfers } " )
274362
275363 executor = ThreadPoolExecutor (max_workers = self .transfers )
276364 tasks = []
@@ -297,7 +385,8 @@ def copy_data(self, source: Any, target: Any, _copy: Any, verbosity: int) -> Non
297385
298386 target ["_copy" ] = _copy
299387
300- LOG .info ("Copied data" )
388+ end = time .time ()
389+ LOG .info (f"Copied data in { end - start :.2f} seconds" )
301390
302391 def copy_array (self , name : str , source : Any , target : Any , _copy : Any , verbosity : int ) -> None :
303392 """Copy an array from source to target.
@@ -325,10 +414,22 @@ def copy_array(self, name: str, source: Any, target: Any, _copy: Any, verbosity:
325414 self .copy_data (source , target , _copy , verbosity )
326415 return
327416
328- LOG .info (f"Copying { name } " )
329- target [name ] = source [name ]
417+ LOG .info (f"Copying { name } { source [name ].shape } " )
418+ data = source [name ][...]
419+ if name in target :
420+ del target [name ]
421+ target .create_dataset (name , data = data , shape = data .shape )
330422 LOG .info (f"Copied { name } " )
331423
424+ def children (self , group ):
425+ """Return sorted child keys, filtering out zarr private files."""
426+ children = list (group .keys ())
427+ # https://github.com/zarr-developers/zarr-python/issues/3575
428+ children = [k for k in children if k != "" ]
429+ children = [k for k in children if k not in zarr_private_files ]
430+ children = sorted (children )
431+ return children
432+
332433 def copy_group (self , source : Any , target : Any , _copy : Any , verbosity : int ) -> None :
333434 """Copy a group from source to target.
334435
@@ -355,21 +456,21 @@ def copy_group(self, source: Any, target: Any, _copy: Any, verbosity: int) -> No
355456 LOG .info (f"Copying attribute { k } = { textwrap .shorten (str (v ), 40 )} " )
356457 target .attrs [k ] = v
357458
358- source_keys = list ( source . keys () )
459+ source_keys = self . children ( source )
359460
360461 if not source_keys :
361462 raise ValueError (f"Source group { source } is empty." )
362463
363464 if self .verbosity > 1 :
364465 LOG .info (f"Keys { source_keys } " )
365466
366- for name in sorted ( source_keys ) :
467+ for name in source_keys :
367468 if name .startswith ("." ):
368469 if self .verbosity > 1 :
369470 LOG .info (f"Skipping { name } " )
370471 continue
371472
372- if isinstance (source [name ], zarr .hierarchy . Group ):
473+ if isinstance (source [name ], zarr .Group ):
373474 group = target [name ] if name in target else target .create_group (name )
374475 self .copy_group (
375476 source [name ],
@@ -398,19 +499,17 @@ def copy(self, source: Any, target: Any, verbosity: int) -> None:
398499 verbosity : int
399500 Verbosity level of logging.
400501 """
401- import zarr
402502
403503 if "_copy" not in target :
404- target ["_copy" ] = zarr .zeros (
405- source ["data" ].shape [0 ],
504+ target .create_dataset (
505+ "_copy" ,
506+ shape = (source ["data" ].shape [0 ],),
406507 dtype = bool ,
407508 )
408509 _copy = target ["_copy" ]
409510 _copy_np = _copy [:]
410511
411512 if self .verbosity > 1 :
412- import numpy as np
413-
414513 LOG .info (f"copy { np .sum (_copy_np )} of { len (_copy_np )} " )
415514
416515 self .filter = Identity ()
@@ -431,27 +530,30 @@ def run(self) -> None:
431530 # assert ext == ".zarr", ext
432531 # assert "." not in base, base
433532 LOG .info (f"Copying { self .source } to { self .target } " )
533+ LOG .info (f"Zarr version { zarr .__version__ } " )
434534
435535 def target_exists () -> bool :
436536 try :
437537 zarr .open (self ._store (self .target ), mode = "r" )
438538 return True
439- except ValueError :
539+ except ( ValueError , ZarrFileNotFoundError ) :
440540 return False
441541
442542 def target_finished () -> bool :
443543 target = zarr .open (self ._store (self .target ), mode = "r" )
544+ source = zarr .open (self ._store (self .source ), mode = "r" )
545+ last_key = self .children (source )[- 1 ]
444546 if "_copy" in target :
445547 done = sum (1 if x else 0 for x in target ["_copy" ])
446- todo = len ( target ["_copy" ])
548+ todo = target ["_copy" ]. shape [ 0 ]
447549 LOG .info (
448550 "Resuming copy, done %s out or %s, %s%%" ,
449551 done ,
450552 todo ,
451553 int (done / todo * 100 + 0.5 ),
452554 )
453555 return False
454- elif "sums" in target and "data" in target : # sums is copied last
556+ elif last_key in target and "data" in target :
455557 return True
456558 return False
457559
@@ -470,7 +572,7 @@ def open_target() -> Any:
470572 sys .exit (0 )
471573
472574 LOG .error ("Target already exists, resuming copy." )
473- return zarr .open (self ._store (self .target , self .nested ), mode = "w+" )
575+ return zarr .open (self ._store (self .target , self .nested ), mode = zarr_append_mode )
474576
475577 LOG .error ("Target already exists, use either --overwrite or --resume." )
476578 sys .exit (1 )
@@ -540,6 +642,12 @@ def add_arguments(self, command_parser: Any) -> None:
540642 action = "store_true" ,
541643 help = "Obfuscate the data during transfer. This will generate random data that match the statistics. Useful for testing and benchmarking." ,
542644 )
645+
646+ if zarr_version >= 3 :
647+ command_parser .add_argument (
648+ "--reshard" ,
649+ help = "Reshard the target data array. This option will adjust --block-size to that it is divisible by the reshard size. Zarr 3 only." ,
650+ )
543651 command_parser .add_argument ("source" , help = "Source location." )
544652 command_parser .add_argument ("target" , help = "Target location." )
545653
@@ -564,7 +672,12 @@ def run(self, args: Any) -> None:
564672 if not basename .endswith (".zarr" ) or basename == ".zarr" :
565673 raise ValueError (f"{ name } path must match '*.zarr' pattern: { path !r} " )
566674
567- if not args .rechunk and not args .obfuscate :
675+ if zarr_version >= 3 :
676+ reshaping_requested = args .rechunk or getattr (args , "reshard" , None )
677+ else :
678+ reshaping_requested = args .rechunk
679+
680+ if not reshaping_requested and not args .obfuscate :
568681 # rechunking is only supported for ZARR datasets, it is implemented in this package
569682 try :
570683 if args .source .startswith ("s3://" ) and not args .source .endswith ("/" ):
0 commit comments