Alaea 2.6.2__tar.gz → 2.6.4__tar.gz

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.
Files changed (63) hide show
  1. alaea-2.6.4/.VERSION +1 -0
  2. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Standard/xr_atmos.py +17 -3
  3. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Standard/xr_netcdf.py +2 -0
  4. {alaea-2.6.2 → alaea-2.6.4}/Alaea/__init__.py +1 -0
  5. alaea-2.6.4/Alaea/netcdf_input.py +669 -0
  6. {alaea-2.6.2 → alaea-2.6.4}/Alaea.egg-info/PKG-INFO +10 -2
  7. {alaea-2.6.2 → alaea-2.6.4}/Alaea.egg-info/SOURCES.txt +1 -0
  8. {alaea-2.6.2 → alaea-2.6.4}/ChangeLog.md +9 -0
  9. {alaea-2.6.2 → alaea-2.6.4}/PKG-INFO +10 -2
  10. {alaea-2.6.2 → alaea-2.6.4}/README.md +9 -1
  11. {alaea-2.6.2 → alaea-2.6.4}/setup.py +1 -1
  12. {alaea-2.6.2 → alaea-2.6.4}/tests/test_core.py +94 -2
  13. alaea-2.6.2/.VERSION +0 -1
  14. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Blogging.py +0 -0
  15. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Pyshell/RS_File.py +0 -0
  16. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Pyshell/__init__.py +0 -0
  17. {alaea-2.6.2 → alaea-2.6.4}/Alaea/S_DateTime.py +0 -0
  18. {alaea-2.6.2 → alaea-2.6.4}/Alaea/SameAsMATLAB/CoorTrans.py +0 -0
  19. {alaea-2.6.2 → alaea-2.6.4}/Alaea/SameAsMATLAB/__init__.py +0 -0
  20. {alaea-2.6.2 → alaea-2.6.4}/Alaea/SameAsMATLAB/calculatefuns.py +0 -0
  21. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Standard/__init__.py +0 -0
  22. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Standard/vars_name.py +0 -0
  23. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Standard/xr_attrs.py +0 -0
  24. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Standard/xr_ice.py +0 -0
  25. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Standard/xr_vars.py +0 -0
  26. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Standard/xr_wave.py +0 -0
  27. {alaea-2.6.2 → alaea-2.6.4}/Alaea/Standard/xr_wind.py +0 -0
  28. {alaea-2.6.2 → alaea-2.6.4}/Alaea/baysalt/Forage/__init__.py +0 -0
  29. {alaea-2.6.2 → alaea-2.6.4}/Alaea/baysalt/Forage/erosion_data/__init__.py +0 -0
  30. {alaea-2.6.2 → alaea-2.6.4}/Alaea/baysalt/__init__.py +0 -0
  31. {alaea-2.6.2 → alaea-2.6.4}/Alaea/baysalt/erosion_coastline.py +0 -0
  32. {alaea-2.6.2 → alaea-2.6.4}/Alaea/commonCode.py +0 -0
  33. {alaea-2.6.2 → alaea-2.6.4}/Alaea/cprintfs.py +0 -0
  34. {alaea-2.6.2 → alaea-2.6.4}/Alaea/exutils/Interp/Interp_g2s.py +0 -0
  35. {alaea-2.6.2 → alaea-2.6.4}/Alaea/exutils/Interp/Interp_s2g.py +0 -0
  36. {alaea-2.6.2 → alaea-2.6.4}/Alaea/exutils/Interp/__init__.py +0 -0
  37. {alaea-2.6.2 → alaea-2.6.4}/Alaea/exutils/Interp/interpolate.py +0 -0
  38. {alaea-2.6.2 → alaea-2.6.4}/Alaea/exutils/__init__.py +0 -0
  39. {alaea-2.6.2 → alaea-2.6.4}/Alaea/pFVCOM/__init__.py +0 -0
  40. {alaea-2.6.2 → alaea-2.6.4}/Alaea/pFVCOM/fvcom_class.py +0 -0
  41. {alaea-2.6.2 → alaea-2.6.4}/Alaea/pFVCOM/fvcom_image.py +0 -0
  42. {alaea-2.6.2 → alaea-2.6.4}/Alaea/pFVCOM/fvcom_load.py +0 -0
  43. {alaea-2.6.2 → alaea-2.6.4}/Alaea/pFVCOM/fvcom_tools.py +0 -0
  44. {alaea-2.6.2 → alaea-2.6.4}/Alaea/pFVCOM/writeFile.py +0 -0
  45. {alaea-2.6.2 → alaea-2.6.4}/Alaea/processBar.py +0 -0
  46. {alaea-2.6.2 → alaea-2.6.4}/Alaea/read_conf.py +0 -0
  47. {alaea-2.6.2 → alaea-2.6.4}/Alaea/server_info.py +0 -0
  48. {alaea-2.6.2 → alaea-2.6.4}/Alaea/storage.py +0 -0
  49. {alaea-2.6.2 → alaea-2.6.4}/Alaea/tools/P_params.py +0 -0
  50. {alaea-2.6.2 → alaea-2.6.4}/Alaea/tools/Read.py +0 -0
  51. {alaea-2.6.2 → alaea-2.6.4}/Alaea/tools/__init__.py +0 -0
  52. {alaea-2.6.2 → alaea-2.6.4}/Alaea/tools/download_check.py +0 -0
  53. {alaea-2.6.2 → alaea-2.6.4}/Alaea/tools/geo_distance.py +0 -0
  54. {alaea-2.6.2 → alaea-2.6.4}/Alaea/tools/kml2shp.py +0 -0
  55. {alaea-2.6.2 → alaea-2.6.4}/Alaea/warningTools/__init__.py +0 -0
  56. {alaea-2.6.2 → alaea-2.6.4}/Alaea.egg-info/dependency_links.txt +0 -0
  57. {alaea-2.6.2 → alaea-2.6.4}/Alaea.egg-info/requires.txt +0 -0
  58. {alaea-2.6.2 → alaea-2.6.4}/Alaea.egg-info/top_level.txt +0 -0
  59. {alaea-2.6.2 → alaea-2.6.4}/LICENSE +0 -0
  60. {alaea-2.6.2 → alaea-2.6.4}/MANIFEST.in +0 -0
  61. {alaea-2.6.2 → alaea-2.6.4}/pyproject.toml +0 -0
  62. {alaea-2.6.2 → alaea-2.6.4}/setup.cfg +0 -0
  63. {alaea-2.6.2 → alaea-2.6.4}/tests/test_logging_multiprocess.py +0 -0
alaea-2.6.4/.VERSION ADDED
@@ -0,0 +1 @@
1
+ v2.6.4
@@ -10,11 +10,20 @@ import pandas as pd
10
10
  import xarray as xr
11
11
 
12
12
  from .. import Ttime
13
- from .xr_attrs import add_attrs_lat, add_attrs_lon, add_attrs_precipitation, add_attrs_slp, add_attrs_temperature, add_attrs_time, add_attrs_TIME, add_attrs_wind, compress_scale_offset, fixed_encoding
13
+ from .xr_attrs import add_attrs_lat, add_attrs_lon, add_attrs_precipitation, add_attrs_slp, add_attrs_temperature, add_attrs_time, add_attrs_TIME, add_attrs_wind, calc_dtype, calc_fillvalue, compress_scale_offset, fixed_encoding
14
14
 
15
15
  __all__ = ['xr_atmos_dataset', 'xr_wind_dataset', 'xr_air_temperature_dataset', 'xr_precipitation_dataset', 'xr_slp_dataset']
16
16
 
17
- _ATMOS_VARIABLES = frozenset({'wind_U10', 'wind_V10', 'air_temperature', 'precipitation', 'slp'})
17
+ _ATMOS_VARIABLE_ATTRS = {
18
+ 'visibility': {'units': 'm', 'long_name': 'Surface horizontal visibility', 'standard_name': 'visibility_in_air'},
19
+ 'total_cloud_cover': {'units': '%', 'long_name': 'Total cloud cover for the entire atmosphere', 'standard_name': 'cloud_area_fraction', 'comment': 'Cloud fraction for the entire atmospheric column; values are percent, not the sum of level cloud fractions'},
20
+ 'sunshine_duration': {'units': 's', 'long_name': 'Hourly sunshine duration', 'standard_name': 'duration_of_sunshine', 'cell_methods': 'time: sum', 'comment': 'Each value is the sunshine duration during its forecast-hour interval'},
21
+ 'relative_humidity': {'units': '%', 'long_name': '2 metre relative humidity', 'standard_name': 'relative_humidity'},
22
+ 'wind_gust': {'units': 'm s-1', 'long_name': 'Surface wind gust', 'standard_name': 'wind_speed_of_gust'},
23
+ 'dew_point_temperature': {'units': 'K', 'long_name': '2 metre dew point temperature', 'standard_name': 'dew_point_temperature'},
24
+ 'apparent_temperature': {'units': 'K', 'long_name': '2 metre apparent temperature', 'comment': 'Perceived temperature supplied by the source model; this is not 2 metre air temperature'},
25
+ }
26
+ _ATMOS_VARIABLES = frozenset({'wind_U10', 'wind_V10', 'air_temperature', 'precipitation', 'slp', *_ATMOS_VARIABLE_ATTRS})
18
27
 
19
28
 
20
29
  def xr_atmos_dataset(_lon: np.ndarray, _lat: np.ndarray, _time: Union[Sequence[Union[str, datetime, np.datetime64, pd.Timestamp]], np.ndarray, pd.DatetimeIndex], _TIME_type: str = 'char', _COMPRESS: bool = True, _ATTRS_GLOBAL: Optional[dict] = None, _ENCODING_GLOBAL: Optional[dict] = None, **kwargs) -> xr.Dataset:
@@ -27,7 +36,7 @@ def xr_atmos_dataset(_lon: np.ndarray, _lat: np.ndarray, _time: Union[Sequence[U
27
36
  :param _COMPRESS: 是否为数值业务变量配置整数 scale/offset packing。
28
37
  :param _ATTRS_GLOBAL: 可选的全局属性。
29
38
  :param _ENCODING_GLOBAL: 可选的 Dataset 编码设置。
30
- :param kwargs: 可选的 wind_U10、wind_V10、air_temperature、precipitation 和 slp 三维数组。
39
+ :param kwargs: 可选的风、气温、降水、海平面气压、能见度、整层总云量、逐小时日照时长、2 米相对湿度、阵风、2 米露点和 2 米体感温度三维数组。
31
40
  :return: 时间、坐标、属性和编码已标准化的 xarray.Dataset。
32
41
  :raises ValueError: TIME 类型非法、没有业务变量或传入未知变量。
33
42
  """
@@ -57,6 +66,11 @@ def xr_atmos_dataset(_lon: np.ndarray, _lat: np.ndarray, _time: Union[Sequence[U
57
66
  dataset = add_attrs_precipitation(dataset, prec_name='precipitation')
58
67
  if 'slp' in variables:
59
68
  dataset = add_attrs_slp(dataset, slp_name='slp')
69
+ for name, attrs in _ATMOS_VARIABLE_ATTRS.items():
70
+ if name in variables:
71
+ dataset[name].attrs.update(attrs)
72
+ dtype = calc_dtype(dataset[name].data)
73
+ dataset[name].encoding = {'_FillValue': calc_fillvalue(dtype), 'dtype': dtype, 'zlib': True}
60
74
  if _ATTRS_GLOBAL is not None:
61
75
  dataset.attrs.update(_ATTRS_GLOBAL)
62
76
  dataset = fixed_encoding(dataset, **(_ENCODING_GLOBAL or {}))
@@ -142,6 +142,8 @@ def _write_zstd(_ds: xr.Dataset, path: Path, complevel: int, shuffle: bool) -> N
142
142
  dtype = np.dtype(encoded.dtype)
143
143
  fill_value = encoded.attrs.get('_FillValue')
144
144
  create_arguments = {} if fill_value is None else {'fill_value': fill_value}
145
+ if encoding.get('chunksizes') is not None:
146
+ create_arguments['chunksizes'] = tuple(int(size) for size in encoding['chunksizes'])
145
147
  target = output.createVariable(name, dtype, source.dims, **create_arguments)
146
148
  if source.dims and np.issubdtype(dtype, np.number) and complevel > 0:
147
149
  _apply_zstd_pipeline(target, complevel, shuffle)
@@ -14,6 +14,7 @@ from importlib.metadata import PackageNotFoundError, version
14
14
  from .Blogging import *
15
15
  from .commonCode import *
16
16
  from .cprintfs import *
17
+ from .netcdf_input import *
17
18
  # from .pFVCOM import *
18
19
  from .Pyshell import *
19
20
  from .read_conf import *
@@ -0,0 +1,669 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """为 NetCDF 输入选择直接、本地 SSD 或内存读取方式。"""
4
+
5
+ import ctypes
6
+ import errno
7
+ import os
8
+ import secrets
9
+ import shutil
10
+ import tempfile
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from contextlib import ExitStack, contextmanager
13
+ from functools import lru_cache
14
+
15
+ import xarray as xr
16
+ from netCDF4 import Dataset
17
+ from xarray.backends import NetCDF4DataStore
18
+
19
+ from .storage import run_storage_fallback
20
+
21
+ INPUT_COPY_BUFFER_BYTES = 32 * 1024 * 1024 # NFS 到本地 SSD 使用 32 MiB 顺序缓冲,减少大文件复制系统调用。
22
+ STAGED_INPUT_FILE_PREFIX = 'alaea-netcdf-input-'
23
+ SUPPORTED_INPUT_METHODS = {'FD_PATH', 'MEMORY', 'NAMED_FILE', 'O_TMPFILE'}
24
+ SUPPORTED_BATCH_INPUT_METHODS = {'LOCAL', 'NAMED_FILE', 'O_TMPFILE'}
25
+ DEFAULT_INPUT_AUTO_METHODS = ('O_TMPFILE', 'NAMED_FILE', 'MEMORY', 'FD_PATH')
26
+ DEFAULT_BATCH_INPUT_AUTO_METHODS = ('O_TMPFILE', 'NAMED_FILE', 'LOCAL')
27
+ DEFAULT_MEMORY_MAX_BYTES = 4 * 1024 * 1024 * 1024
28
+ AT_FDCWD = -100
29
+ AT_EMPTY_PATH = 0x1000
30
+ AT_SYMLINK_FOLLOW = 0x400
31
+
32
+ __all__ = ['DEFAULT_BATCH_INPUT_AUTO_METHODS', 'DEFAULT_INPUT_AUTO_METHODS', 'DEFAULT_MEMORY_MAX_BYTES', 'INPUT_METHOD_OPENERS', 'OpenedNetCDFInput', 'STAGED_INPUT_FILE_PREFIX', 'cleanup_orphaned_input_files', 'open_netcdf_input', 'stage_netcdf_inputs']
33
+
34
+
35
+ class OpenedNetCDFInput:
36
+ """保存一个已打开的 xarray/NetCDF4 共享读取器及其内存缓冲。
37
+
38
+ :param dataset: 供制瓦业务读取变量的 xarray 数据集。
39
+ :type dataset: xarray.Dataset
40
+ :param netcdf: 供现有时间坐标逻辑读取的 NetCDF4 数据集;与 ``dataset`` 共用同一底层句柄。
41
+ :type netcdf: netCDF4.Dataset
42
+ :param method: 实际成功使用的输入读取方式。
43
+ :type method: str
44
+ :param memory_buffer: MEMORY 模式必须保持存活的完整 NC 字节缓冲;其他模式为 ``None``。
45
+ :type memory_buffer: bytearray | None
46
+ """
47
+
48
+ def __init__(self, dataset, netcdf, method, memory_buffer=None):
49
+ """建立一个统一生命周期的制瓦输入读取器。
50
+
51
+ :param dataset: 供制瓦业务读取变量的 xarray 数据集。
52
+ :type dataset: xarray.Dataset
53
+ :param netcdf: 与 xarray 共用底层句柄的 NetCDF4 数据集。
54
+ :type netcdf: netCDF4.Dataset
55
+ :param method: 实际成功使用的输入读取方式。
56
+ :type method: str
57
+ :param memory_buffer: MEMORY 模式需保持存活的完整 NC 缓冲。
58
+ :type memory_buffer: bytearray | None
59
+ :return: 无返回值;初始化当前对象。
60
+ :rtype: None
61
+ """
62
+ self.dataset = dataset
63
+ self.netcdf = netcdf
64
+ self.method = method
65
+ self.memory_buffer = memory_buffer
66
+
67
+ def close(self):
68
+ """关闭共享 HDF5 读取器并释放可能存在的内存缓冲。
69
+
70
+ :return: 无返回值;重复调用安全。
71
+ :rtype: None
72
+ """
73
+ dataset = self.dataset
74
+ self.dataset = None
75
+ try:
76
+ if dataset is not None:
77
+ dataset.close()
78
+ finally:
79
+ self.netcdf = None
80
+ self.memory_buffer = None
81
+
82
+
83
+ def cleanup_orphaned_input_files(local_directory, logger=None, prefixes=None):
84
+ """删除 Linux 上已退出进程遗留的受控 NetCDF 具名输入副本。
85
+
86
+ :param local_directory: 制瓦输入副本所在的本地 SSD 目录。
87
+ :type local_directory: str | os.PathLike
88
+ :param logger: 可选标准 Logger,用于记录清理失败。
89
+ :type logger: logging.Logger | None
90
+ :param prefixes: 允许清理的文件名前缀;为空时仅使用 Alaea 当前前缀。
91
+ :type prefixes: collections.abc.Iterable[str] | None
92
+ :return: 删除的孤儿文件数,类型为 int。
93
+ :rtype: int
94
+ :raises ValueError: 前缀为空、重复或包含路径分隔符。
95
+ """
96
+ active_prefixes = tuple(prefixes or (STAGED_INPUT_FILE_PREFIX,))
97
+ if not active_prefixes or len(set(active_prefixes)) != len(active_prefixes) or any(not prefix or os.path.basename(prefix) != prefix for prefix in active_prefixes):
98
+ raise ValueError('prefixes 必须是不重复且不含路径的非空前缀')
99
+ if not os.path.isdir('/proc') or not os.path.isdir(local_directory):
100
+ return 0
101
+ removed = 0
102
+ for entry in os.scandir(local_directory):
103
+ matched_prefix = next((prefix for prefix in active_prefixes if entry.name.startswith(prefix)), None)
104
+ if matched_prefix is None or not entry.name.endswith('.nc') or not entry.is_file(follow_symlinks=False):
105
+ continue
106
+ pid_text = entry.name[len(matched_prefix):].split('-', 1)[0]
107
+ if not pid_text.isdigit() or os.path.exists(f'/proc/{pid_text}'):
108
+ continue
109
+ try:
110
+ os.unlink(entry.path)
111
+ removed += 1
112
+ except FileNotFoundError:
113
+ pass
114
+ except OSError as error:
115
+ if logger is not None:
116
+ logger.warning('清理已退出进程的本地 SSD 输入副本失败:path=%s, error=%s', entry.path, error)
117
+ return removed
118
+
119
+
120
+ def _ensure_local_directory(local_directory, logger=None):
121
+ """创建本地 SSD 暂存目录并清理可确认的孤儿文件。
122
+
123
+ :param local_directory: 本地 SSD 暂存目录。
124
+ :type local_directory: str | os.PathLike
125
+ :param logger: 可选标准 Logger,用于记录孤儿文件清理失败。
126
+ :type logger: logging.Logger | None
127
+ :return: 可传给文件 API 的目录字符串。
128
+ :rtype: str
129
+ :raises OSError: 目录无法创建或访问。
130
+ """
131
+ active_local_directory = os.fspath(local_directory)
132
+ os.makedirs(active_local_directory, mode=0o700, exist_ok=True)
133
+ cleanup_orphaned_input_files(active_local_directory, logger=logger)
134
+ return active_local_directory
135
+
136
+
137
+ def _source_signature(file_stat):
138
+ """提取足以发现复制期间替换或修改的源文件签名。
139
+
140
+ :param file_stat: ``os.stat`` 或 ``os.fstat`` 返回值。
141
+ :type file_stat: os.stat_result
142
+ :return: 设备、inode、字节数和纳秒修改时间组成的元组。
143
+ :rtype: tuple[int, int, int, int]
144
+ """
145
+ return file_stat.st_dev, file_stat.st_ino, file_stat.st_size, file_stat.st_mtime_ns
146
+
147
+
148
+ def _copy_stable_source(source_path, copy_stream):
149
+ """打开源文件、执行一次顺序读取并确认复制期间源内容未变化。
150
+
151
+ :param source_path: 正式输入文件路径。
152
+ :type source_path: str
153
+ :param copy_stream: 接收 ``(源文件流, 源字节数)`` 并返回复制结果的函数。
154
+ :type copy_stream: collections.abc.Callable
155
+ :return: ``copy_stream`` 的返回值和源文件字节数。
156
+ :rtype: tuple[typing.Any, int]
157
+ :raises OSError: 源文件读取失败或复制字节数不完整。
158
+ :raises RuntimeError: 复制开始前或期间源文件被替换、截断或修改。
159
+ """
160
+ source_stat = os.stat(source_path)
161
+ source_signature = _source_signature(source_stat)
162
+ with open(source_path, 'rb', buffering=0) as source_stream:
163
+ if _source_signature(os.fstat(source_stream.fileno())) != source_signature:
164
+ raise RuntimeError(f'NFS 输入在复制开始前已变化:{source_path}')
165
+ result = copy_stream(source_stream, source_stat.st_size)
166
+ completed_signature = _source_signature(os.fstat(source_stream.fileno()))
167
+ if completed_signature != source_signature or _source_signature(os.stat(source_path)) != source_signature:
168
+ raise RuntimeError(f'NFS 输入在复制期间已变化:{source_path}')
169
+ return result, source_stat.st_size
170
+
171
+
172
+ def _copy_source_to_file(source_path, staged_file):
173
+ """把稳定源文件顺序复制到已打开的本地文件。
174
+
175
+ :param source_path: 正式输入文件路径。
176
+ :type source_path: str
177
+ :param staged_file: 已打开且可写入的本地二进制文件。
178
+ :type staged_file: typing.BinaryIO
179
+ :return: 源文件字节数。
180
+ :rtype: int
181
+ :raises OSError: 复制失败或字节数不一致。
182
+ :raises RuntimeError: 复制期间源文件变化。
183
+ """
184
+ def copy_stream(source_stream, source_size):
185
+ """把当前源文件流复制到外层已打开的本地文件。
186
+
187
+ :param source_stream: 已校验身份的正式输入文件流。
188
+ :type source_stream: typing.BinaryIO
189
+ :param source_size: 正式输入的预期字节数。
190
+ :type source_size: int
191
+ :return: 实际写入本地文件的字节数。
192
+ :rtype: int
193
+ """
194
+ shutil.copyfileobj(source_stream, staged_file, length=INPUT_COPY_BUFFER_BYTES)
195
+ return staged_file.tell()
196
+
197
+ copied_bytes, source_size = _copy_stable_source(source_path, copy_stream)
198
+ if copied_bytes != source_size:
199
+ raise OSError(f'NFS 输入复制字节数不一致:{source_path}')
200
+ staged_file.flush()
201
+ staged_file.seek(0)
202
+ return source_size
203
+
204
+
205
+ def _read_source_into_memory(source_path, maximum_bytes):
206
+ """把稳定源文件顺序读取到一个预分配内存缓冲。
207
+
208
+ :param source_path: 正式输入文件路径。
209
+ :type source_path: str
210
+ :param maximum_bytes: 允许进入内存模式的最大 NC 字节数。
211
+ :type maximum_bytes: int
212
+ :return: 完整 NC 字节缓冲。
213
+ :rtype: bytearray
214
+ :raises MemoryError: 文件超过配置上限或内存分配失败。
215
+ :raises OSError: 源文件读取不完整。
216
+ :raises RuntimeError: 读取期间源文件变化。
217
+ """
218
+ def copy_stream(source_stream, source_size):
219
+ """把当前源文件流写入预分配 RAM 缓冲。
220
+
221
+ :param source_stream: 已校验身份的正式输入文件流。
222
+ :type source_stream: typing.BinaryIO
223
+ :param source_size: 正式输入的预期字节数。
224
+ :type source_size: int
225
+ :return: 已完整填充的 NC 字节缓冲。
226
+ :rtype: bytearray
227
+ """
228
+ if source_size > maximum_bytes:
229
+ raise MemoryError(f'NC 文件 {source_size} 字节超过内存读取上限 {maximum_bytes} 字节')
230
+ memory_buffer = bytearray(source_size)
231
+ memory_view = memoryview(memory_buffer)
232
+ offset = 0
233
+ try:
234
+ while offset < source_size:
235
+ read_size = source_stream.readinto(memory_view[offset:min(offset + INPUT_COPY_BUFFER_BYTES, source_size)])
236
+ if not read_size:
237
+ break
238
+ offset += read_size
239
+ finally:
240
+ memory_view.release()
241
+ if offset != source_size:
242
+ raise OSError(f'NFS 输入读取字节数不一致:{source_path}')
243
+ return memory_buffer
244
+
245
+ memory_buffer, _source_size = _copy_stable_source(source_path, copy_stream)
246
+ return memory_buffer
247
+
248
+
249
+ def _wrap_netcdf_dataset(netcdf, method, memory_buffer=None):
250
+ """让 xarray 与既有时间坐标逻辑共享同一个 NetCDF4 句柄。
251
+
252
+ :param netcdf: 已成功打开的 NetCDF4 数据集。
253
+ :type netcdf: netCDF4.Dataset
254
+ :param method: 实际输入读取方式。
255
+ :type method: str
256
+ :param memory_buffer: MEMORY 模式的完整 NC 缓冲。
257
+ :type memory_buffer: bytearray | None
258
+ :return: 可统一关闭的 NetCDF 输入读取器。
259
+ :rtype: OpenedNetCDFInput
260
+ """
261
+ try:
262
+ dataset = xr.open_dataset(NetCDF4DataStore(netcdf))
263
+ except BaseException:
264
+ netcdf.close()
265
+ raise
266
+ return OpenedNetCDFInput(dataset, netcdf, method, memory_buffer)
267
+
268
+
269
+ def _open_using_local_path(source_path, _local_directory, _memory_max_bytes):
270
+ """直接打开本地正式 NC,不复制数据。
271
+
272
+ :param source_path: 本地正式输入路径。
273
+ :type source_path: str
274
+ :param _local_directory: 未使用的统一方法参数。
275
+ :type _local_directory: str | os.PathLike
276
+ :param _memory_max_bytes: 未使用的统一方法参数。
277
+ :type _memory_max_bytes: int
278
+ :return: 已打开的 NetCDF 输入。
279
+ :rtype: OpenedNetCDFInput
280
+ """
281
+ return _wrap_netcdf_dataset(Dataset(source_path), 'LOCAL')
282
+
283
+
284
+ def _open_using_fd_path(source_path, local_directory, _memory_max_bytes):
285
+ """复制到匿名临时文件,并通过当前平台的文件描述符路径打开 HDF5。
286
+
287
+ :param source_path: 正式输入文件路径。
288
+ :type source_path: str
289
+ :param local_directory: 本地 SSD 暂存目录。
290
+ :type local_directory: str | os.PathLike
291
+ :param _memory_max_bytes: 未使用的统一方法参数。
292
+ :type _memory_max_bytes: int
293
+ :return: 已打开的 NetCDF 输入。
294
+ :rtype: OpenedNetCDFInput
295
+ :raises RuntimeError: 当前平台没有可用的文件描述符路径。
296
+ """
297
+ active_local_directory = _ensure_local_directory(local_directory)
298
+ with tempfile.TemporaryFile(mode='w+b', suffix='.nc', dir=active_local_directory) as staged_file:
299
+ _copy_source_to_file(source_path, staged_file)
300
+ descriptor_paths = (f'/dev/fd/{staged_file.fileno()}', f'/proc/self/fd/{staged_file.fileno()}')
301
+ staged_path = next((path for path in descriptor_paths if os.path.exists(path)), None)
302
+ if staged_path is None:
303
+ raise RuntimeError('当前系统没有可供 NetCDF/HDF5 打开的文件描述符路径')
304
+ return _wrap_netcdf_dataset(Dataset(staged_path), 'FD_PATH')
305
+
306
+
307
+ def _open_using_memory(source_path, _local_directory, memory_max_bytes):
308
+ """把完整 NC 顺序读入 RAM,再通过 NetCDF-C 内存接口打开。
309
+
310
+ :param source_path: 正式输入文件路径。
311
+ :type source_path: str
312
+ :param _local_directory: 未使用的统一方法参数。
313
+ :type _local_directory: str | os.PathLike
314
+ :param memory_max_bytes: 允许进入内存模式的最大 NC 字节数。
315
+ :type memory_max_bytes: int
316
+ :return: 已打开的 NetCDF 输入。
317
+ :rtype: OpenedNetCDFInput
318
+ """
319
+ memory_buffer = _read_source_into_memory(source_path, memory_max_bytes)
320
+ try:
321
+ netcdf = Dataset(os.path.basename(source_path), memory=memory_buffer)
322
+ except BaseException:
323
+ memory_buffer = None
324
+ raise
325
+ return _wrap_netcdf_dataset(netcdf, 'MEMORY', memory_buffer)
326
+
327
+
328
+ def _temporary_link_path(local_directory):
329
+ """生成只供 O_TMPFILE 短暂建立目录项的进程独占路径。
330
+
331
+ :param local_directory: 本地 SSD 暂存目录。
332
+ :type local_directory: str
333
+ :return: 尚不存在的临时链接路径。
334
+ :rtype: str
335
+ """
336
+ return os.path.join(local_directory, f'{STAGED_INPUT_FILE_PREFIX}{os.getpid()}-{secrets.token_hex(8)}.nc')
337
+
338
+
339
+ def _link_anonymous_file(file_descriptor, target_path):
340
+ """使用 Linux ``linkat(AT_EMPTY_PATH)`` 给 O_TMPFILE 临时建立目录项。
341
+
342
+ :param file_descriptor: 由 ``O_TMPFILE`` 创建的匿名文件描述符。
343
+ :type file_descriptor: int
344
+ :param target_path: 待建立的同文件系统具名路径。
345
+ :type target_path: str
346
+ :return: 无返回值。
347
+ :rtype: None
348
+ :raises OSError: 当前 libc、内核、权限或文件系统不支持该操作。
349
+ """
350
+ libc = ctypes.CDLL(None, use_errno=True)
351
+ linkat = getattr(libc, 'linkat', None)
352
+ if linkat is None:
353
+ raise OSError(errno.ENOSYS, '当前 libc 不提供 linkat')
354
+ linkat.argtypes = (ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_int)
355
+ linkat.restype = ctypes.c_int
356
+ encoded_target_path = os.fsencode(target_path)
357
+ if linkat(file_descriptor, b'', AT_FDCWD, encoded_target_path, AT_EMPTY_PATH) == 0:
358
+ return
359
+ # 普通服务用户通常没有 AT_EMPTY_PATH 所需的 CAP_DAC_READ_SEARCH;Linux 官方兼容路径
360
+ # 通过 /proc/self/fd 和 AT_SYMLINK_FOLLOW 链接同一 O_TMPFILE inode。
361
+ descriptor_path = os.fsencode(f'/proc/self/fd/{file_descriptor}')
362
+ if linkat(AT_FDCWD, descriptor_path, AT_FDCWD, encoded_target_path, AT_SYMLINK_FOLLOW) == 0:
363
+ return
364
+ error_number = ctypes.get_errno()
365
+ raise OSError(error_number, os.strerror(error_number), target_path)
366
+
367
+
368
+ @lru_cache(maxsize=None)
369
+ def _require_otmpfile_link_support(local_directory):
370
+ """用空文件探测并缓存指定文件系统的 O_TMPFILE/linkat 能力。
371
+
372
+ 先探测可避免权限或文件系统不支持时完整复制大型 NC 后才失败。
373
+
374
+ :param local_directory: 已创建的本地 SSD 暂存目录。
375
+ :type local_directory: str
376
+ :return: 无返回值;成功结果按目录缓存。
377
+ :rtype: None
378
+ :raises OSError: 当前 Python、libc、内核、权限或文件系统不支持该组合。
379
+ """
380
+ if not hasattr(os, 'O_TMPFILE'):
381
+ raise OSError(errno.ENOTSUP, '当前 Python/操作系统不提供 O_TMPFILE')
382
+ probe_path = _temporary_link_path(local_directory)
383
+ probe_descriptor = os.open(local_directory, os.O_RDWR | os.O_TMPFILE | getattr(os, 'O_CLOEXEC', 0), 0o600)
384
+ try:
385
+ _link_anonymous_file(probe_descriptor, probe_path)
386
+ finally:
387
+ try:
388
+ os.unlink(probe_path)
389
+ except FileNotFoundError:
390
+ pass
391
+ os.close(probe_descriptor)
392
+
393
+
394
+ def _open_using_named_file(source_path, local_directory, _memory_max_bytes):
395
+ """复制到具名 SSD 文件,HDF5 打开后立即删除文件名。
396
+
397
+ :param source_path: 正式输入文件路径。
398
+ :type source_path: str
399
+ :param local_directory: 本地 SSD 暂存目录。
400
+ :type local_directory: str | os.PathLike
401
+ :param _memory_max_bytes: 未使用的统一方法参数。
402
+ :type _memory_max_bytes: int
403
+ :return: 已打开的 NetCDF 输入。
404
+ :rtype: OpenedNetCDFInput
405
+ """
406
+ active_local_directory = _ensure_local_directory(local_directory)
407
+ staged_file = tempfile.NamedTemporaryFile(mode='w+b', prefix=f'{STAGED_INPUT_FILE_PREFIX}{os.getpid()}-', suffix='.nc', dir=active_local_directory, delete=False)
408
+ staged_path = staged_file.name
409
+ try:
410
+ _copy_source_to_file(source_path, staged_file)
411
+ return _wrap_netcdf_dataset(Dataset(staged_path), 'NAMED_FILE')
412
+ finally:
413
+ try:
414
+ os.unlink(staged_path)
415
+ except FileNotFoundError:
416
+ pass
417
+ staged_file.close()
418
+
419
+
420
+ def _open_using_otmpfile(source_path, local_directory, _memory_max_bytes):
421
+ """使用 Linux O_TMPFILE 复制,短暂 linkat 后让 HDF5 打开并立即解除路径。
422
+
423
+ :param source_path: 正式输入文件路径。
424
+ :type source_path: str
425
+ :param local_directory: 本地 SSD 暂存目录。
426
+ :type local_directory: str | os.PathLike
427
+ :param _memory_max_bytes: 未使用的统一方法参数。
428
+ :type _memory_max_bytes: int
429
+ :return: 已打开的 NetCDF 输入。
430
+ :rtype: OpenedNetCDFInput
431
+ :raises OSError: Python、libc、内核、权限或文件系统不支持 O_TMPFILE/linkat。
432
+ """
433
+ active_local_directory = _ensure_local_directory(local_directory)
434
+ _require_otmpfile_link_support(active_local_directory)
435
+ open_flags = os.O_RDWR | os.O_TMPFILE | getattr(os, 'O_CLOEXEC', 0)
436
+ staged_file = os.fdopen(os.open(active_local_directory, open_flags, 0o600), 'w+b')
437
+ linked_path = _temporary_link_path(active_local_directory)
438
+ try:
439
+ _copy_source_to_file(source_path, staged_file)
440
+ _link_anonymous_file(staged_file.fileno(), linked_path)
441
+ return _wrap_netcdf_dataset(Dataset(linked_path), 'O_TMPFILE')
442
+ finally:
443
+ try:
444
+ os.unlink(linked_path)
445
+ except FileNotFoundError:
446
+ pass
447
+ staged_file.close()
448
+
449
+
450
+ INPUT_METHOD_OPENERS = {
451
+ 'FD_PATH': _open_using_fd_path,
452
+ 'MEMORY': _open_using_memory,
453
+ 'NAMED_FILE': _open_using_named_file,
454
+ 'O_TMPFILE': _open_using_otmpfile,
455
+ }
456
+
457
+
458
+ def open_netcdf_input(input_file, storage_mode='AUTO', auto_methods=DEFAULT_INPUT_AUTO_METHODS, local_directory=None, memory_max_bytes=DEFAULT_MEMORY_MAX_BYTES, logger=None, context='netcdf-input'):
459
+ """直接或通过 SSD/内存暂存打开单个 NetCDF 输入。
460
+
461
+ :param input_file: 已存在的 NetCDF 输入路径。
462
+ :type input_file: str | os.PathLike
463
+ :param storage_mode: ``LOCAL``、``AUTO``、``O_TMPFILE``、``NAMED_FILE``、``MEMORY`` 或 ``FD_PATH``。
464
+ :type storage_mode: str
465
+ :param auto_methods: AUTO 模式的有序方法列表。
466
+ :type auto_methods: collections.abc.Iterable[str]
467
+ :param local_directory: SSD 暂存目录;使用文件暂存方式时不能为 None。
468
+ :type local_directory: str | os.PathLike | None
469
+ :param memory_max_bytes: MEMORY 模式允许的最大字节数。
470
+ :type memory_max_bytes: int
471
+ :param logger: 可选标准 Logger。
472
+ :type logger: logging.Logger | None
473
+ :param context: 不含敏感值的日志上下文。
474
+ :type context: str
475
+ :return: 已打开且必须由调用方关闭的 ``OpenedNetCDFInput``。
476
+ :raises ValueError: 模式、AUTO 顺序或内存上限非法。
477
+ :raises OSError: 所选方式无法读取输入。
478
+ :raises RuntimeError: 复制期间源文件变化或 AUTO 全部方式失败。
479
+ """
480
+ source_path = os.fspath(input_file)
481
+ active_storage_mode = str(storage_mode).strip().upper()
482
+ active_auto_methods = tuple(str(method).strip().upper() for method in auto_methods)
483
+ requested_methods = active_auto_methods if active_storage_mode == 'AUTO' else (active_storage_mode,)
484
+ if 'MEMORY' in requested_methods and (not isinstance(memory_max_bytes, int) or memory_max_bytes <= 0):
485
+ raise ValueError('memory_max_bytes 必须是正整数')
486
+ if any(method in {'FD_PATH', 'NAMED_FILE', 'O_TMPFILE'} for method in requested_methods) and local_directory is None:
487
+ raise ValueError('文件暂存方式必须提供 local_directory')
488
+ source_bytes = os.stat(source_path).st_size
489
+ operations = {'LOCAL': lambda: _open_using_local_path(source_path, local_directory, memory_max_bytes)} | {method: (lambda active_method=method: INPUT_METHOD_OPENERS[active_method](source_path, local_directory, memory_max_bytes)) for method in SUPPORTED_INPUT_METHODS}
490
+ return run_storage_fallback(active_storage_mode, active_auto_methods, operations, logger=logger, context=f'{context} source={source_path}, bytes={source_bytes}')
491
+
492
+
493
+ def _stage_named_source(source_path, local_directory):
494
+ """把单个稳定 NetCDF 源文件复制为具名 SSD 候选。
495
+
496
+ :param source_path: 源 NetCDF 路径。
497
+ :type source_path: str | os.PathLike
498
+ :param local_directory: 已创建的 SSD 目录。
499
+ :type local_directory: str | os.PathLike
500
+ :return: 完整复制的候选路径,类型为 str。
501
+ :raises OSError: 候选创建、复制或刷盘失败。
502
+ :raises RuntimeError: 复制期间源文件变化。
503
+ """
504
+ descriptor, staged_path = tempfile.mkstemp(prefix=f'{STAGED_INPUT_FILE_PREFIX}{os.getpid()}-', suffix='.nc', dir=local_directory)
505
+ try:
506
+ with os.fdopen(descriptor, 'w+b') as staged_file:
507
+ _copy_source_to_file(source_path, staged_file)
508
+ return staged_path
509
+ except BaseException:
510
+ try:
511
+ os.unlink(staged_path)
512
+ except FileNotFoundError:
513
+ pass
514
+ raise
515
+
516
+
517
+ def _stage_otmp_source(source_path, local_directory):
518
+ """把单个稳定 NetCDF 源文件复制到 O_TMPFILE 并建立临时路径。
519
+
520
+ :param source_path: 源 NetCDF 路径。
521
+ :type source_path: str | os.PathLike
522
+ :param local_directory: 已验证 O_TMPFILE/linkat 的 SSD 目录。
523
+ :type local_directory: str | os.PathLike
524
+ :return: 必须由调用方关闭的文件对象和临时路径元组。
525
+ :raises OSError: 匿名文件、复制或链接创建失败。
526
+ :raises RuntimeError: 复制期间源文件变化。
527
+ """
528
+ staged_file = os.fdopen(os.open(local_directory, os.O_RDWR | os.O_TMPFILE | getattr(os, 'O_CLOEXEC', 0), 0o600), 'w+b')
529
+ staged_path = _temporary_link_path(local_directory)
530
+ try:
531
+ _copy_source_to_file(source_path, staged_file)
532
+ _link_anonymous_file(staged_file.fileno(), staged_path)
533
+ return staged_file, staged_path
534
+ except BaseException:
535
+ staged_file.close()
536
+ try:
537
+ os.unlink(staged_path)
538
+ except FileNotFoundError:
539
+ pass
540
+ raise
541
+
542
+
543
+ def _remove_staged_path(staged_path):
544
+ """删除本模块创建的具名暂存路径,路径已不存在时保持幂等。
545
+
546
+ :param staged_path: 本模块创建的具名暂存文件路径。
547
+ :type staged_path: str | os.PathLike
548
+ :return: 无返回值。
549
+ :rtype: None
550
+ :raises OSError: 删除存在的暂存文件失败。
551
+ """
552
+ try:
553
+ os.unlink(staged_path)
554
+ except FileNotFoundError:
555
+ pass
556
+
557
+
558
+ def _cleanup_otmp_result(result):
559
+ """删除 O_TMPFILE 临时链接并始终关闭对应文件句柄。
560
+
561
+ :param result: ``(文件对象, 临时链接路径)`` 元组。
562
+ :type result: tuple[typing.BinaryIO, str]
563
+ :return: 无返回值。
564
+ :rtype: None
565
+ :raises OSError: 临时链接存在但删除失败。
566
+ """
567
+ staged_file, staged_path = result
568
+ try:
569
+ _remove_staged_path(staged_path)
570
+ finally:
571
+ staged_file.close()
572
+
573
+
574
+ def _stage_sources(source_paths, worker, workers, cleanup):
575
+ """有界并发执行多文件暂存,失败时清理已完成候选。
576
+
577
+ :param source_paths: 保持输入顺序的源路径元组。
578
+ :type source_paths: tuple[str, ...]
579
+ :param worker: 接收单个源路径并返回暂存结果的可调用对象。
580
+ :type worker: collections.abc.Callable
581
+ :param workers: 同时复制的最大文件数。
582
+ :type workers: int
583
+ :param cleanup: 接收单个暂存结果的清理回调。
584
+ :type cleanup: collections.abc.Callable
585
+ :return: 与源路径同顺序的暂存结果列表。
586
+ :raises BaseException: 任一暂存任务失败时清理后保留首个异常。
587
+ """
588
+ results = [None] * len(source_paths)
589
+ errors = []
590
+ with ThreadPoolExecutor(max_workers=min(workers, len(source_paths)), thread_name_prefix='netcdf-stage') as executor:
591
+ futures = [executor.submit(worker, source_path) for source_path in source_paths]
592
+ for index, future in enumerate(futures):
593
+ try:
594
+ results[index] = future.result()
595
+ except BaseException as error:
596
+ errors.append(error)
597
+ if errors:
598
+ for result in results:
599
+ if result is not None:
600
+ cleanup(result)
601
+ raise errors[0]
602
+ return results
603
+
604
+
605
+ @contextmanager
606
+ def stage_netcdf_inputs(input_files, storage_mode='AUTO', auto_methods=DEFAULT_BATCH_INPUT_AUTO_METHODS, local_directory=None, workers=1, logger=None, context='netcdf-input-batch'):
607
+ """在上下文内把多个 NetCDF 输入保留为可供 xarray 懒读取的路径。
608
+
609
+ :param input_files: 按业务顺序排列的 NetCDF 路径序列。
610
+ :type input_files: collections.abc.Iterable[str | os.PathLike]
611
+ :param storage_mode: ``AUTO``、``LOCAL``、``O_TMPFILE`` 或 ``NAMED_FILE``。
612
+ :type storage_mode: str
613
+ :param auto_methods: AUTO 模式的有序方法列表。
614
+ :type auto_methods: collections.abc.Iterable[str]
615
+ :param local_directory: O_TMPFILE/NAMED_FILE 使用的 SSD 目录。
616
+ :type local_directory: str | os.PathLike | None
617
+ :param workers: 同时复制的最大文件数。
618
+ :type workers: int
619
+ :param logger: 可选标准 Logger。
620
+ :type logger: logging.Logger | None
621
+ :param context: 不含敏感值的日志上下文。
622
+ :type context: str
623
+ :return: 上下文期间有效的路径元组;退出时自动清理 SSD 候选。
624
+ :rtype: contextlib.AbstractContextManager[tuple[str, ...]]
625
+ :raises ValueError: 输入、并发数或方法非法。
626
+ :raises OSError: 暂存无法完成。
627
+ :raises RuntimeError: 源文件变化或 AUTO 全部方式失败。
628
+ """
629
+ source_paths = tuple(os.fspath(path) for path in input_files)
630
+ if not source_paths:
631
+ raise ValueError('input_files 不能为空')
632
+ if not isinstance(workers, int) or isinstance(workers, bool) or workers < 1:
633
+ raise ValueError('workers 必须是正整数')
634
+ active_storage_mode = str(storage_mode).strip().upper()
635
+ active_auto_methods = tuple(str(method).strip().upper() for method in auto_methods)
636
+ requested_methods = active_auto_methods if active_storage_mode == 'AUTO' else (active_storage_mode,)
637
+ if any(method not in SUPPORTED_BATCH_INPUT_METHODS for method in requested_methods):
638
+ raise ValueError(f'批量路径暂存只支持 {sorted(SUPPORTED_BATCH_INPUT_METHODS)}')
639
+ if any(method != 'LOCAL' for method in requested_methods) and local_directory is None:
640
+ raise ValueError('批量 SSD 暂存必须提供 local_directory')
641
+ stack = ExitStack()
642
+
643
+ def local_operation():
644
+ """校验并返回原路径。"""
645
+ [os.stat(path) for path in source_paths]
646
+ return source_paths
647
+
648
+ def named_operation():
649
+ """并发生成具名 SSD 候选并登记退出清理。"""
650
+ active_directory = _ensure_local_directory(local_directory, logger=logger)
651
+ staged_paths = _stage_sources(source_paths, lambda path: _stage_named_source(path, active_directory), workers, _remove_staged_path)
652
+ [stack.callback(_remove_staged_path, path) for path in staged_paths]
653
+ return tuple(staged_paths)
654
+
655
+ def otmp_operation():
656
+ """并发生成 O_TMPFILE 候选并保持句柄到上下文退出。"""
657
+ active_directory = _ensure_local_directory(local_directory, logger=logger)
658
+ _require_otmpfile_link_support(active_directory)
659
+ staged_results = _stage_sources(source_paths, lambda path: _stage_otmp_source(path, active_directory), workers, _cleanup_otmp_result)
660
+ for staged_file, staged_path in staged_results:
661
+ stack.callback(_cleanup_otmp_result, (staged_file, staged_path))
662
+ return tuple(staged_path for _staged_file, staged_path in staged_results)
663
+
664
+ operations = {'LOCAL': local_operation, 'NAMED_FILE': named_operation, 'O_TMPFILE': otmp_operation}
665
+ try:
666
+ staged_paths = run_storage_fallback(active_storage_mode, active_auto_methods, operations, logger=logger, context=f'{context} files={len(source_paths)}')
667
+ yield staged_paths
668
+ finally:
669
+ stack.close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Alaea
3
- Version: 2.6.2
3
+ Version: 2.6.4
4
4
  Summary: Some simple tools by Christmas
5
5
  Author: Christmas
6
6
  Author-email: 273519355@qq.com
@@ -66,7 +66,10 @@ continues uploading the remaining archives.
66
66
  ## Standard atmosphere datasets
67
67
 
68
68
  `xr_atmos_dataset` builds any supported combination of wind, 2 metre air
69
- temperature, per-period precipitation, and mean sea-level pressure. The
69
+ temperature, per-period precipitation, mean sea-level pressure, visibility,
70
+ entire-atmosphere total cloud cover, hourly sunshine duration, 2 metre
71
+ relative humidity, surface wind gust, 2 metre dew point temperature, and
72
+ 2 metre apparent temperature. The
70
73
  single-element `xr_wind_dataset`, `xr_air_temperature_dataset`,
71
74
  `xr_precipitation_dataset`, and `xr_slp_dataset` interfaces use the same
72
75
  metadata and encoding path. Integer packing is enabled by default and can be
@@ -82,6 +85,11 @@ plugin is unavailable.
82
85
  error boundary, so input staging and output publication can share fallback
83
86
  policy without sharing incompatible lifecycle code.
84
87
 
88
+ `open_netcdf_input` adds a shared single-file NetCDF reader for direct, local
89
+ SSD, memory, named-file, and Linux `O_TMPFILE` modes. For `xarray.open_mfdataset`,
90
+ `stage_netcdf_inputs` preserves input order while copying through a bounded
91
+ worker pool and removes only its own staged files when the context exits.
92
+
85
93
  ## Unified spatial interpolation
86
94
 
87
95
  `s2s`, `g2s`, `s2g`, and `g2g` use the same argument order. `s` means
@@ -10,6 +10,7 @@ Alaea/S_DateTime.py
10
10
  Alaea/__init__.py
11
11
  Alaea/commonCode.py
12
12
  Alaea/cprintfs.py
13
+ Alaea/netcdf_input.py
13
14
  Alaea/processBar.py
14
15
  Alaea/read_conf.py
15
16
  Alaea/server_info.py
@@ -1,5 +1,14 @@
1
1
  # Change Log
2
2
 
3
+ ## v2.6.4
4
+
5
+ - `write_netcdf` 的 Zstd 分支现将每个数值变量指定的 `chunksizes` 传递给 NetCDF4/HDF5,避免全球时序数据被自动分为跨多个时次的大块;需要单时次访问的调用方可稳定保留 `time chunk=1`,压缩 filter 与数据内容不变。
6
+
7
+ ## v2.6.3
8
+
9
+ - `xr_atmos_dataset` 新增能见度、整层大气总云量、逐小时日照持续时间、2 米相对湿度、地表阵风、2 米露点温度和 2 米体感温度,并统一写入物理属性与编码;可与原有五个大气字段任意组合,不再要求调用方补字段或伪造占位变量。
10
+ - 新增公共 NetCDF 输入暂存模块:单文件支持直接、具名 SSD、内存、文件描述符及 Linux `O_TMPFILE` 读取;多文件支持按原顺序有限并发暂存到 SSD,供 `xarray.open_mfdataset` 使用,并在成功、失败及上下文退出时清理本模块候选文件。孤儿清理可接收受控旧前缀用于应用迁移,但不会扫描其他 NC;MakeTiles 与下载程序可共用同一实现,不再分别维护复制、降级和孤儿文件清理逻辑。
11
+
3
12
  ## v2.6.2
4
13
 
5
14
  - `run_storage_fallback` 的成功线路明细由 INFO 调整为 DEBUG,减少 `Storage method succeeded` 高频常规日志;实际失败并降级仍保持 WARNING。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Alaea
3
- Version: 2.6.2
3
+ Version: 2.6.4
4
4
  Summary: Some simple tools by Christmas
5
5
  Author: Christmas
6
6
  Author-email: 273519355@qq.com
@@ -66,7 +66,10 @@ continues uploading the remaining archives.
66
66
  ## Standard atmosphere datasets
67
67
 
68
68
  `xr_atmos_dataset` builds any supported combination of wind, 2 metre air
69
- temperature, per-period precipitation, and mean sea-level pressure. The
69
+ temperature, per-period precipitation, mean sea-level pressure, visibility,
70
+ entire-atmosphere total cloud cover, hourly sunshine duration, 2 metre
71
+ relative humidity, surface wind gust, 2 metre dew point temperature, and
72
+ 2 metre apparent temperature. The
70
73
  single-element `xr_wind_dataset`, `xr_air_temperature_dataset`,
71
74
  `xr_precipitation_dataset`, and `xr_slp_dataset` interfaces use the same
72
75
  metadata and encoding path. Integer packing is enabled by default and can be
@@ -82,6 +85,11 @@ plugin is unavailable.
82
85
  error boundary, so input staging and output publication can share fallback
83
86
  policy without sharing incompatible lifecycle code.
84
87
 
88
+ `open_netcdf_input` adds a shared single-file NetCDF reader for direct, local
89
+ SSD, memory, named-file, and Linux `O_TMPFILE` modes. For `xarray.open_mfdataset`,
90
+ `stage_netcdf_inputs` preserves input order while copying through a bounded
91
+ worker pool and removes only its own staged files when the context exits.
92
+
85
93
  ## Unified spatial interpolation
86
94
 
87
95
  `s2s`, `g2s`, `s2g`, and `g2g` use the same argument order. `s` means
@@ -29,7 +29,10 @@ continues uploading the remaining archives.
29
29
  ## Standard atmosphere datasets
30
30
 
31
31
  `xr_atmos_dataset` builds any supported combination of wind, 2 metre air
32
- temperature, per-period precipitation, and mean sea-level pressure. The
32
+ temperature, per-period precipitation, mean sea-level pressure, visibility,
33
+ entire-atmosphere total cloud cover, hourly sunshine duration, 2 metre
34
+ relative humidity, surface wind gust, 2 metre dew point temperature, and
35
+ 2 metre apparent temperature. The
33
36
  single-element `xr_wind_dataset`, `xr_air_temperature_dataset`,
34
37
  `xr_precipitation_dataset`, and `xr_slp_dataset` interfaces use the same
35
38
  metadata and encoding path. Integer packing is enabled by default and can be
@@ -45,6 +48,11 @@ plugin is unavailable.
45
48
  error boundary, so input staging and output publication can share fallback
46
49
  policy without sharing incompatible lifecycle code.
47
50
 
51
+ `open_netcdf_input` adds a shared single-file NetCDF reader for direct, local
52
+ SSD, memory, named-file, and Linux `O_TMPFILE` modes. For `xarray.open_mfdataset`,
53
+ `stage_netcdf_inputs` preserves input order while copying through a bounded
54
+ worker pool and removes only its own staged files when the context exits.
55
+
48
56
  ## Unified spatial interpolation
49
57
 
50
58
  `s2s`, `g2s`, `s2g`, and `g2g` use the same argument order. `s` means
@@ -8,7 +8,7 @@ with open("README.md", "r") as fh:
8
8
 
9
9
  setuptools.setup(
10
10
  name="Alaea",
11
- version="2.6.2",
11
+ version="2.6.4",
12
12
  author="Christmas",
13
13
  author_email="273519355@qq.com",
14
14
  description="Some simple tools by Christmas",
@@ -28,6 +28,7 @@ import Alaea.cprintfs as CPRINT
28
28
  import Alaea.pFVCOM.writeFile as FVCOM_WRITE
29
29
  import Alaea.processBar as PROCESS_BAR
30
30
  from Alaea.Standard import calc_dtype, calc_scale_offset, compress_scale_offset, write_netcdf, xr_air_temperature_dataset, xr_atmos_dataset, xr_precipitation_dataset, xr_slp_dataset, xr_wave_dateset, xr_wind_dataset, xr_wind_dateset
31
+ from Alaea.netcdf_input import INPUT_METHOD_OPENERS, STAGED_INPUT_FILE_PREFIX, cleanup_orphaned_input_files, open_netcdf_input, stage_netcdf_inputs
31
32
  from Alaea.storage import run_storage_fallback
32
33
 
33
34
 
@@ -257,7 +258,8 @@ class UtilityRegressionTest(unittest.TestCase):
257
258
  times = pd.date_range('2026-01-01', periods=2, freq='h', tz='UTC')
258
259
  coordinates = np.asarray([0.0], dtype=np.float32)
259
260
  values = xr.DataArray(np.ones((2, 1, 1), dtype=np.float32), dims=('time', 'latitude', 'longitude'))
260
- atmosphere = xr_atmos_dataset(coordinates, coordinates, times, _COMPRESS=False, wind_U10=values, wind_V10=values, air_temperature=values, precipitation=values, slp=values)
261
+ added_variables = {'visibility', 'total_cloud_cover', 'sunshine_duration', 'relative_humidity', 'wind_gust', 'dew_point_temperature', 'apparent_temperature'}
262
+ atmosphere = xr_atmos_dataset(coordinates, coordinates, times, _COMPRESS=False, wind_U10=values, wind_V10=values, air_temperature=values, precipitation=values, slp=values, **{name: values for name in added_variables})
261
263
  datasets = (
262
264
  xr_wind_dataset(coordinates, coordinates, times, wind_U10=values, _COMPRESS=False),
263
265
  xr_air_temperature_dataset(coordinates, coordinates, times, values, _COMPRESS=False),
@@ -265,7 +267,7 @@ class UtilityRegressionTest(unittest.TestCase):
265
267
  xr_slp_dataset(coordinates, coordinates, times, values, _COMPRESS=False),
266
268
  )
267
269
 
268
- self.assertEqual(set(atmosphere.data_vars), {'TIME', 'wind_U10', 'wind_V10', 'air_temperature', 'precipitation', 'slp'})
270
+ self.assertEqual(set(atmosphere.data_vars), {'TIME', 'wind_U10', 'wind_V10', 'air_temperature', 'precipitation', 'slp', *added_variables})
269
271
  self.assertEqual([set(dataset.data_vars) - {'TIME'} for dataset in datasets], [{'wind_U10'}, {'air_temperature'}, {'precipitation'}, {'slp'}])
270
272
  self.assertEqual(atmosphere.time.attrs['standard_name'], 'time')
271
273
  self.assertEqual(atmosphere.time.attrs['units'], 'seconds since 1970-01-01 00:00:00')
@@ -276,6 +278,13 @@ class UtilityRegressionTest(unittest.TestCase):
276
278
  self.assertEqual(atmosphere.air_temperature.attrs['standard_name'], 'air_temperature')
277
279
  self.assertEqual(atmosphere.precipitation.attrs['standard_name'], 'lwe_thickness_of_precipitation_amount')
278
280
  self.assertEqual(atmosphere.slp.attrs['standard_name'], 'air_pressure_at_mean_sea_level')
281
+ self.assertEqual(atmosphere.visibility.attrs['standard_name'], 'visibility_in_air')
282
+ self.assertEqual(atmosphere.total_cloud_cover.attrs['standard_name'], 'cloud_area_fraction')
283
+ self.assertEqual(atmosphere.sunshine_duration.attrs['cell_methods'], 'time: sum')
284
+ self.assertEqual(atmosphere.relative_humidity.attrs['long_name'], '2 metre relative humidity')
285
+ self.assertEqual(atmosphere.wind_gust.attrs['units'], 'm s-1')
286
+ self.assertEqual(atmosphere.dew_point_temperature.attrs['standard_name'], 'dew_point_temperature')
287
+ self.assertNotIn('standard_name', atmosphere.apparent_temperature.attrs)
279
288
  with tempfile.TemporaryDirectory() as directory:
280
289
  output = Path(directory) / 'atmos.nc'
281
290
  atmosphere.to_netcdf(output, engine='h5netcdf')
@@ -303,6 +312,7 @@ class UtilityRegressionTest(unittest.TestCase):
303
312
  coordinates = np.asarray([0.5], dtype=np.float32)
304
313
  values = np.asarray([[[1.0]], [[3.0]]], dtype=np.float32)
305
314
  dataset = xr_atmos_dataset(coordinates, coordinates, times, _TIME_type='str', wind_U10=values)
315
+ dataset['wind_U10'].encoding['chunksizes'] = (1, 1, 1)
306
316
  with tempfile.TemporaryDirectory() as directory:
307
317
  output = Path(directory) / 'zstd.nc'
308
318
  write_netcdf(dataset, output, compression='zstd', complevel=1)
@@ -312,6 +322,7 @@ class UtilityRegressionTest(unittest.TestCase):
312
322
  self.assertTrue(filters['zstd'])
313
323
  self.assertFalse(filters['zlib'])
314
324
  self.assertEqual(filters['complevel'], 1)
325
+ self.assertEqual(netcdf.variables['wind_U10'].chunking(), [1, 1, 1])
315
326
  self.assertEqual(netcdf.alaea_hdf5_filter_pipeline, 'shuffle(2)->zstd(32015,level=1)')
316
327
  np.testing.assert_allclose(netcdf.variables['wind_U10'][:].reshape(-1), [1.0, 3.0], atol=float(netcdf.variables['wind_U10'].scale_factor))
317
328
 
@@ -353,6 +364,87 @@ class UtilityRegressionTest(unittest.TestCase):
353
364
  with self.assertRaisesRegex(OSError, 'cache unavailable'):
354
365
  run_storage_fallback('CACHE', ('CACHE', 'NFS'), operations)
355
366
 
367
+ def test_netcdf_input_single_and_batch_staging_preserve_data_and_cleanup(self):
368
+ """单文件和批量暂存必须保持数据、顺序及自动清理契约。"""
369
+ with tempfile.TemporaryDirectory() as directory:
370
+ root = Path(directory)
371
+ cache = root / 'cache'
372
+ sources = []
373
+ for index in range(2):
374
+ source = root / f'source-{index}.nc'
375
+ xr.Dataset({'value': ('sample', np.asarray([index], dtype=np.float32))}).to_netcdf(source)
376
+ sources.append(source)
377
+ for storage_mode in ('LOCAL', 'NAMED_FILE', 'MEMORY', 'FD_PATH'):
378
+ opened = open_netcdf_input(sources[0], storage_mode=storage_mode, local_directory=cache)
379
+ try:
380
+ self.assertEqual(opened.method, storage_mode)
381
+ np.testing.assert_array_equal(opened.dataset.value.values, [0.0])
382
+ np.testing.assert_array_equal(opened.netcdf.variables['value'][:], [0.0])
383
+ finally:
384
+ opened.close()
385
+ if cache.exists():
386
+ self.assertEqual(list(cache.iterdir()), [])
387
+ for storage_mode in ('LOCAL', 'NAMED_FILE'):
388
+ with stage_netcdf_inputs(sources, storage_mode=storage_mode, local_directory=cache, workers=2) as staged_paths:
389
+ self.assertEqual(len(staged_paths), 2)
390
+ values = []
391
+ for path in staged_paths:
392
+ with xr.open_dataset(path) as dataset:
393
+ values.append(float(dataset.value.values[0]))
394
+ self.assertEqual(values, [0.0, 1.0])
395
+ if storage_mode == 'NAMED_FILE':
396
+ self.assertTrue(all(Path(path).parent == cache for path in staged_paths))
397
+ if cache.exists():
398
+ self.assertEqual(list(cache.iterdir()), [])
399
+
400
+ def test_netcdf_input_auto_order_orphan_cleanup_and_failed_batch_cleanup(self):
401
+ """AUTO 依序降级,孤儿识别和批量失败均只清理本模块候选。"""
402
+ import Alaea.netcdf_input as netcdf_input
403
+
404
+ with tempfile.TemporaryDirectory() as directory:
405
+ root = Path(directory)
406
+ source = root / 'source.nc'
407
+ cache = root / 'cache'
408
+ cache.mkdir()
409
+ xr.Dataset({'value': ('sample', np.asarray([1.0], dtype=np.float32))}).to_netcdf(source)
410
+ sentinel = mock.Mock()
411
+ first = mock.Mock(side_effect=OSError('unsupported'))
412
+ second = mock.Mock(return_value=sentinel)
413
+ with mock.patch.dict(INPUT_METHOD_OPENERS, {'O_TMPFILE': first, 'NAMED_FILE': second}, clear=False):
414
+ self.assertIs(open_netcdf_input(source, storage_mode='AUTO', auto_methods=('O_TMPFILE', 'NAMED_FILE'), local_directory=cache), sentinel)
415
+ first.assert_called_once()
416
+ second.assert_called_once()
417
+
418
+ orphan = cache / f'{STAGED_INPUT_FILE_PREFIX}99999999-orphan.nc'
419
+ legacy_orphan = cache / 'legacy-netcdf-input-99999999-orphan.nc'
420
+ unrelated = cache / 'unrelated.nc'
421
+ orphan.write_bytes(b'orphan')
422
+ legacy_orphan.write_bytes(b'orphan')
423
+ unrelated.write_bytes(b'keep')
424
+ real_exists = os.path.exists
425
+ with mock.patch('Alaea.netcdf_input.os.path.isdir', return_value=True), mock.patch('Alaea.netcdf_input.os.path.exists', side_effect=lambda path: False if path == '/proc/99999999' else real_exists(path)):
426
+ self.assertEqual(cleanup_orphaned_input_files(cache, prefixes=(STAGED_INPUT_FILE_PREFIX, 'legacy-netcdf-input-')), 2)
427
+ self.assertFalse(orphan.exists())
428
+ self.assertFalse(legacy_orphan.exists())
429
+ self.assertEqual(unrelated.read_bytes(), b'keep')
430
+
431
+ calls = 0
432
+ original_stage = netcdf_input._stage_named_source
433
+
434
+ def fail_second(path, local_directory):
435
+ """首个候选成功后模拟第二个源在复制期间失败。"""
436
+ nonlocal calls
437
+ calls += 1
438
+ if calls == 2:
439
+ raise RuntimeError('source changed')
440
+ return original_stage(path, local_directory)
441
+
442
+ with mock.patch('Alaea.netcdf_input._stage_named_source', side_effect=fail_second):
443
+ with self.assertRaisesRegex(RuntimeError, 'source changed'):
444
+ with stage_netcdf_inputs((source, source), storage_mode='NAMED_FILE', local_directory=cache, workers=1):
445
+ pass
446
+ self.assertEqual([path.name for path in cache.iterdir()], ['unrelated.nc'])
447
+
356
448
  @unittest.skipUnless(DASK_AVAILABLE, 'dask is optional')
357
449
  def test_standard_encoding_helpers_keep_dask_dtype_lazy_and_reduce_for_packing(self):
358
450
  """Dask dtype 推断不应读取数据,packing 只需得到正确范围。"""
alaea-2.6.2/.VERSION DELETED
@@ -1 +0,0 @@
1
- v2.6.2
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes