marEx 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
marEx/helper.py ADDED
@@ -0,0 +1,251 @@
1
+ """
2
+ HPC Dask Helper: Utilities for High-Performance Computing with Dask
3
+ --------------------------------------------------------------------
4
+
5
+ This module provides utilities for setting up and managing Dask clusters
6
+ in HPC environments, with specific support for the DKRZ Levante Supercomputer.
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import psutil
13
+ from tempfile import TemporaryDirectory
14
+ from getpass import getuser
15
+ from pathlib import Path
16
+ from typing import Dict, Optional, Union, Any
17
+
18
+ import dask
19
+ from dask_jobqueue import SLURMCluster
20
+ from dask.distributed import Client, LocalCluster
21
+
22
+
23
+ # Default configuration values
24
+ DEFAULT_DASK_CONFIG = {
25
+ 'array.slicing.split_large_chunks': False,
26
+ 'distributed.comm.timeouts.connect': '120s', # Increased from default
27
+ 'distributed.comm.timeouts.tcp': '240s', # Double the connection timeout
28
+ 'distributed.comm.retry.count': 10, # More retries before giving up
29
+ }
30
+
31
+ # DKRZ-specific paths and configuration
32
+ DKRZ_SCRATCH_PATH = Path('/scratch') / getuser()[0] / getuser() / 'clients'
33
+ DKRZ_LOG_PATH = Path('/home/b') / getuser() / '.log_trash'
34
+ DKRZ_ACCOUNT = 'bk1377'
35
+
36
+ # Memory configuration for different node types
37
+ MEMORY_CONFIGS = {
38
+ 256: {'client_memory': '250GB', 'constraint': '256', 'job_extra': ['--mem=0']},
39
+ 512: {'client_memory': '500GB', 'constraint': '512', 'job_extra': ['--constraint=512G --mem=0']},
40
+ 1024: {'client_memory': '1000GB', 'constraint': '1024', 'job_extra': ['--constraint=1024G --mem=0']}
41
+ }
42
+
43
+
44
+ def configure_dask(scratch_dir: Optional[Union[str, Path]] = None,
45
+ config: Optional[Dict[str, Any]] = None) -> TemporaryDirectory:
46
+ """
47
+ Configure Dask with appropriate settings for HPC environments.
48
+
49
+ Parameters
50
+ ----------
51
+ scratch_dir : str or Path, optional
52
+ Directory to use for temporary files.
53
+ config : dict, optional
54
+ Additional Dask configuration settings to apply.
55
+
56
+ Returns
57
+ -------
58
+ TemporaryDirectory
59
+ Temporary directory object that should be kept alive while Dask is in use.
60
+ """
61
+ # Use provided scratch directory or default to DKRZ scratch
62
+ scratch_path = Path(scratch_dir) if scratch_dir else DKRZ_SCRATCH_PATH
63
+
64
+ # Create temporary directory
65
+ if not scratch_path.exists():
66
+ scratch_path.mkdir(parents=True, exist_ok=True)
67
+
68
+ temp_dir = TemporaryDirectory(dir=scratch_path)
69
+
70
+ # Apply default configuration
71
+ dask.config.set(temporary_directory=temp_dir.name)
72
+ print(f"Dask Scratch: {repr(temp_dir.name)}")
73
+
74
+ # Apply default settings
75
+ for key, value in DEFAULT_DASK_CONFIG.items():
76
+ dask.config.set({key: value})
77
+
78
+ # Apply any additional configuration
79
+ if config:
80
+ dask.config.set(config)
81
+
82
+ return temp_dir
83
+
84
+
85
+ def get_cluster_info(client: Client) -> Dict[str, str]:
86
+ """
87
+ Get and print cluster connection information.
88
+
89
+ Parameters
90
+ ----------
91
+ client : Client
92
+ Dask client connected to a cluster.
93
+
94
+ Returns
95
+ -------
96
+ dict
97
+ Dictionary containing connection information.
98
+ """
99
+ # Get hostname and dashboard port
100
+ remote_node = subprocess.run(['hostname'], capture_output=True, text=True).stdout.strip().split('.')[0]
101
+ port = re.search(r':(\d+)/', client.dashboard_link).group(1)
102
+
103
+ # Print connection information
104
+ print(f"Hostname: {remote_node}")
105
+ print(f"Forward Port: {remote_node}:{port}")
106
+ print(f"Dashboard Link: localhost:{port}/status")
107
+
108
+ return {
109
+ 'hostname': remote_node,
110
+ 'port': port,
111
+ 'dashboard_link': f"localhost:{port}/status"
112
+ }
113
+
114
+
115
+ def start_local_cluster(n_workers: int = 4, threads_per_worker: int = 1,
116
+ scratch_dir: Optional[Union[str, Path]] = None,
117
+ **kwargs) -> Client:
118
+ """
119
+ Start a local Dask cluster.
120
+
121
+ Parameters
122
+ ----------
123
+ n_workers : int, default=4
124
+ Number of worker processes to start.
125
+ threads_per_worker : int, default=1
126
+ Number of threads per worker.
127
+ scratch_dir : str or Path, optional
128
+ Directory to use for temporary files.
129
+ **kwargs
130
+ Additional keyword arguments to pass to LocalCluster.
131
+
132
+ Returns
133
+ -------
134
+ Client
135
+ Dask client connected to the local cluster.
136
+ """
137
+ # Configure Dask
138
+ temp_dir = configure_dask(scratch_dir)
139
+
140
+ # Check system resources
141
+ physical_cores = psutil.cpu_count(logical=False)
142
+ logical_cores = psutil.cpu_count(logical=True)
143
+ memory = psutil.virtual_memory()
144
+
145
+ # Warn if requested resources exceed available
146
+ total_threads = n_workers * threads_per_worker
147
+ if total_threads > physical_cores:
148
+ print(f"Warning: Requested {n_workers} workers with {threads_per_worker} threads each, but only {physical_cores} physical cores available.")
149
+ print("Hyper-threading can reduce performance for compute-intensive tasks!")
150
+ elif total_threads > logical_cores:
151
+ print(f"Warning: Requested {n_workers} workers with {threads_per_worker} threads each, but only {logical_cores} logical cores available.")
152
+ print(f"Reducing to {logical_cores // threads_per_worker} workers.")
153
+ n_workers = logical_cores // threads_per_worker
154
+
155
+ print(f"Memory per Worker: {memory.total / n_workers / (1024**3):.2f} GB")
156
+
157
+ # Create cluster and client
158
+ cluster = LocalCluster(n_workers=n_workers, threads_per_worker=threads_per_worker, **kwargs)
159
+ client = Client(cluster)
160
+
161
+ # Store temporary directory (so it isn't garbage collected)
162
+ client._temp_dir = temp_dir
163
+
164
+ # Print connection information
165
+ get_cluster_info(client)
166
+
167
+ return client
168
+
169
+
170
+ def start_distributed_cluster(n_workers: int, workers_per_node: int,
171
+ runtime: int = 9, node_memory: int = 256,
172
+ dashboard_address: int = 8889, queue: str = 'compute',
173
+ scratch_dir: Optional[Union[str, Path]] = None,
174
+ account: Optional[str] = None, **kwargs) -> Client:
175
+ """
176
+ Start a distributed Dask cluster on a SLURM-based supercomputer.
177
+
178
+ Parameters
179
+ ----------
180
+ n_workers : int
181
+ Total number of workers to request.
182
+ workers_per_node : int
183
+ Number of workers per node.
184
+ runtime : int, default=9
185
+ Maximum runtime in minutes.
186
+ node_memory : int, default=256
187
+ Memory per node in GB (256, 512, or 1024).
188
+ dashboard_address : int, default=8889
189
+ Port for the Dask dashboard.
190
+ queue : str, default='compute'
191
+ SLURM queue to submit jobs to.
192
+ scratch_dir : str or Path, optional
193
+ Directory to use for temporary files.
194
+ account : str, optional
195
+ SLURM account to charge. Defaults to DKRZ_ACCOUNT.
196
+ **kwargs
197
+ Additional keyword arguments to pass to SLURMCluster.
198
+
199
+ Returns
200
+ -------
201
+ Client
202
+ Dask client connected to the distributed cluster.
203
+ """
204
+ # Configure Dask
205
+ temp_dir = configure_dask(scratch_dir)
206
+
207
+ # Use default account if none specified
208
+ if account is None:
209
+ account = DKRZ_ACCOUNT
210
+
211
+ # Validate node_memory
212
+ if node_memory not in MEMORY_CONFIGS:
213
+ raise ValueError(f"Unsupported node_memory value: {node_memory}. Must be one of {list(MEMORY_CONFIGS.keys())}.")
214
+
215
+ config = MEMORY_CONFIGS[node_memory]
216
+
217
+ # Calculate runtime in hours and minutes
218
+ runtime_hrs = runtime // 60
219
+ runtime_mins = runtime % 60
220
+
221
+ # Create SLURM cluster
222
+ cluster = SLURMCluster(
223
+ name='dask-cluster',
224
+ cores=workers_per_node,
225
+ memory=config['client_memory'],
226
+ processes=workers_per_node, # One process per core
227
+ interface='ib0',
228
+ queue=queue,
229
+ account=account,
230
+ walltime=f'{runtime_hrs:02d}:{runtime_mins:02d}:00',
231
+ asynchronous=0,
232
+ job_extra_directives=config['job_extra'],
233
+ log_directory=DKRZ_LOG_PATH,
234
+ local_directory=temp_dir.name,
235
+ scheduler_options={'dashboard_address': f':{dashboard_address}'},
236
+ **kwargs
237
+ )
238
+
239
+ print(f"Memory per Worker: {node_memory / workers_per_node:.2f} GB")
240
+
241
+ # Scale the cluster
242
+ cluster.scale(n_workers)
243
+ client = Client(cluster)
244
+
245
+ # Store temporary directory (so it isn't garbage collected)
246
+ client._temp_dir = temp_dir
247
+
248
+ # Print connection information
249
+ get_cluster_info(client)
250
+
251
+ return client
@@ -0,0 +1,83 @@
1
+ from .base import PlotterBase, PlotConfig
2
+ from .gridded import GriddedPlotter
3
+ from .unstructured import UnstructuredPlotter, clear_cache
4
+ import xarray as xr
5
+ import warnings
6
+
7
+ # Global variables to store grid information
8
+ _fpath_tgrid = None
9
+ _fpath_ckdtree = None
10
+ _grid_type = None
11
+
12
+ def _detect_grid_type(xarray_obj):
13
+ """
14
+ Deduce grid type based on coordinate structure.
15
+
16
+ Returns:
17
+ str: 'gridded' or 'unstructured'
18
+ """
19
+ has_lat_lon_coords = 'lat' in xarray_obj.coords and 'lon' in xarray_obj.coords
20
+ has_lat_lon_dims = 'lat' in xarray_obj.dims and 'lon' in xarray_obj.dims
21
+
22
+ # For unstructured data, lat/lon are coordinates but not dimensions
23
+ return 'unstructured' if (has_lat_lon_coords and not has_lat_lon_dims) else 'gridded'
24
+
25
+ def register_plotter(xarray_obj):
26
+ """
27
+ Determine the appropriate plotter to use based on the data structure.
28
+ This function is called automatically by xarray's accessor system.
29
+
30
+ First checks if grid type was specified via specify_grid(),
31
+ then falls back to coordinate-based detection if needed.
32
+
33
+ Returns:
34
+ Appropriate plotter instance for the data structure
35
+ """
36
+ global _grid_type
37
+
38
+ # Determine grid type
39
+ detected_type = _detect_grid_type(xarray_obj)
40
+
41
+ # If grid type was explicitly specified, check for consistency
42
+ if _grid_type is not None:
43
+ if _grid_type != detected_type:
44
+ warnings.warn(
45
+ f"Specified grid type '{_grid_type}' differs from detected type '{detected_type}' "
46
+ f"based on coordinate structure. Using specified type '{_grid_type}'."
47
+ )
48
+ final_type = _grid_type
49
+ else:
50
+ final_type = detected_type
51
+
52
+ # Create appropriate plotter
53
+ plotter_class = UnstructuredPlotter if final_type.lower() == 'unstructured' else GriddedPlotter
54
+ plotter = plotter_class(xarray_obj)
55
+
56
+ # Set grid path if available for unstructured grids
57
+ if final_type == 'unstructured' and _fpath_tgrid is not None and _fpath_ckdtree is not None:
58
+ plotter.specify_grid(fpath_tgrid=_fpath_tgrid, fpath_ckdtree=_fpath_ckdtree)
59
+
60
+ return plotter
61
+
62
+ def specify_grid(grid_type=None, fpath_tgrid=None, fpath_ckdtree=None):
63
+ """
64
+ Set the global grid specification that will be used by all plotters.
65
+
66
+ Args:
67
+ grid_type: str, either 'gridded' or 'unstructured'.
68
+ If specified, this will be used as the primary method
69
+ to determine grid type.
70
+ fpath_tgrid: Path to the triangulation grid file
71
+ fpath_ckdtree: Path to the pre-computed KDTree indices directory
72
+ """
73
+ global _fpath_tgrid, _fpath_ckdtree, _grid_type
74
+
75
+ if grid_type is not None and grid_type.lower() not in ['gridded', 'unstructured']:
76
+ raise ValueError("grid_type must be either 'gridded' or 'unstructured'")
77
+
78
+ _fpath_tgrid = str(fpath_tgrid) if fpath_tgrid else None
79
+ _fpath_ckdtree = str(fpath_ckdtree) if fpath_ckdtree else None
80
+ _grid_type = grid_type.lower() if grid_type else None
81
+
82
+ # Register the accessor
83
+ xr.register_dataarray_accessor('plotX')(register_plotter)