oceanarray 0.1.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.
- oceanarray/__init__.py +20 -0
- oceanarray/_version.py +1 -0
- oceanarray/cli.py +1343 -0
- oceanarray/clock_offset.py +675 -0
- oceanarray/config/OS1_sensor_attrs.yaml +49 -0
- oceanarray/config/OS1_var_names.yaml +5 -0
- oceanarray/config/OS1_vocab_attrs.yaml +84 -0
- oceanarray/config/legacy/project_RAPID.yaml +69 -0
- oceanarray/config/legacy/rodb_keys.txt +206 -0
- oceanarray/config/legacy/rodb_keys.yaml +1012 -0
- oceanarray/config/logging.yaml +28 -0
- oceanarray/legacy/__init__.py +47 -0
- oceanarray/legacy/caldip.py +753 -0
- oceanarray/legacy/convertOS.py +527 -0
- oceanarray/legacy/find_deployment.py +489 -0
- oceanarray/legacy/mooring_rodb.py +429 -0
- oceanarray/legacy/process_rodb.py +369 -0
- oceanarray/legacy/rodb.py +330 -0
- oceanarray/logger.py +192 -0
- oceanarray/logsheet/__init__.py +60 -0
- oceanarray/logsheet/_columns.py +97 -0
- oceanarray/logsheet/_config.py +245 -0
- oceanarray/logsheet/_firmware.py +43 -0
- oceanarray/logsheet/_latex.py +128 -0
- oceanarray/logsheet/_render.py +126 -0
- oceanarray/logsheet/builders/__init__.py +13 -0
- oceanarray/logsheet/builders/caldip_download.py +195 -0
- oceanarray/logsheet/builders/caldip_setup.py +188 -0
- oceanarray/logsheet/builders/deployment_setup.py +218 -0
- oceanarray/logsheet/builders/mooring_download.py +243 -0
- oceanarray/logsheet/builders/recovery.py +157 -0
- oceanarray/logsheet/data/column_defs.yaml +686 -0
- oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
- oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
- oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
- oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
- oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
- oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
- oceanarray/mooring_level.py +1368 -0
- oceanarray/oceanarray.mplstyle +18 -0
- oceanarray/parameters.py +270 -0
- oceanarray/plotter.py +942 -0
- oceanarray/plotters/__init__.py +97 -0
- oceanarray/plotters/_animation.py +261 -0
- oceanarray/plotters/_current.py +705 -0
- oceanarray/plotters/_diagnostic.py +667 -0
- oceanarray/plotters/_helpers.py +57 -0
- oceanarray/plotters/_primitives.py +101 -0
- oceanarray/plotters/_section.py +16 -0
- oceanarray/plotters/_timeseries.py +11 -0
- oceanarray/rapid_interp.py +739 -0
- oceanarray/read_rbr_hex.py +541 -0
- oceanarray/readers.py +303 -0
- oceanarray/report/__init__.py +5 -0
- oceanarray/report/_array.py +386 -0
- oceanarray/report/_grid.py +539 -0
- oceanarray/report/_html_helpers.py +1004 -0
- oceanarray/report/_instrument.py +853 -0
- oceanarray/report/_mooring.py +1385 -0
- oceanarray/report/_plots.py +4284 -0
- oceanarray/report/_stack.py +941 -0
- oceanarray/stage1.py +1959 -0
- oceanarray/stage2.py +1160 -0
- oceanarray/stage3.py +2494 -0
- oceanarray/time_gridding.py +958 -0
- oceanarray/tools.py +400 -0
- oceanarray/transports.py +1 -0
- oceanarray/utilities.py +538 -0
- oceanarray/validation.py +389 -0
- oceanarray/writers.py +99 -0
- oceanarray-0.1.0.dist-info/METADATA +503 -0
- oceanarray-0.1.0.dist-info/RECORD +76 -0
- oceanarray-0.1.0.dist-info/WHEEL +5 -0
- oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
- oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- oceanarray-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import xarray as xr
|
|
4
|
+
from scipy.interpolate import interp1d
|
|
5
|
+
from scipy.signal import butter, filtfilt
|
|
6
|
+
|
|
7
|
+
from .. import utilities
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_time_vars(ds_list, time_key="TIME"):
|
|
11
|
+
"""Return all variable names that have (time_key,) dimensions in any dataset."""
|
|
12
|
+
time_vars = set()
|
|
13
|
+
for ds in ds_list:
|
|
14
|
+
time_vars.update([var for var in ds.data_vars if ds[var].dims == (time_key,)])
|
|
15
|
+
return sorted(time_vars)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_common_attributes(ds_list):
|
|
19
|
+
"""Return attributes that are common across all datasets with the same value."""
|
|
20
|
+
common = {}
|
|
21
|
+
shared_keys = set.intersection(*(set(ds.attrs) for ds in ds_list))
|
|
22
|
+
for key in shared_keys:
|
|
23
|
+
values = [ds.attrs[key] for ds in ds_list]
|
|
24
|
+
if all(val == values[0] for val in values):
|
|
25
|
+
common[key] = values[0]
|
|
26
|
+
return common
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def stack_instruments(ds_list, time_key="TIME"):
|
|
30
|
+
ds_list = sorted(ds_list, key=lambda ds: ds.InstrDepth.values)
|
|
31
|
+
time = ds_list[0][time_key]
|
|
32
|
+
n_levels = len(ds_list)
|
|
33
|
+
n_time = len(time)
|
|
34
|
+
|
|
35
|
+
# Ensure all datasets have the same TIME dimension & arrays are exactly equal
|
|
36
|
+
for ds in ds_list:
|
|
37
|
+
if not np.array_equal(ds[time_key], time):
|
|
38
|
+
raise ValueError("Not all datasets share the same TIME dimension.")
|
|
39
|
+
|
|
40
|
+
# Find all time variables across datasets
|
|
41
|
+
time_vars = find_time_vars(ds_list, time_key=time_key)
|
|
42
|
+
|
|
43
|
+
# Stack the time variables across datasets
|
|
44
|
+
data_vars = {}
|
|
45
|
+
for var in time_vars:
|
|
46
|
+
stacked = np.full((n_levels, n_time), np.nan)
|
|
47
|
+
for i, ds in enumerate(ds_list):
|
|
48
|
+
if var in ds:
|
|
49
|
+
stacked[i, :] = ds[var].values
|
|
50
|
+
data_vars[var] = (("N_LEVELS", time_key), stacked)
|
|
51
|
+
|
|
52
|
+
# Create a list of instrument depths
|
|
53
|
+
instr_depths = np.array([ds.InstrDepth.values for ds in ds_list])
|
|
54
|
+
|
|
55
|
+
# Verify that all datasets have the same latitude and longitude
|
|
56
|
+
lats = np.array([ds.Latitude.values for ds in ds_list])
|
|
57
|
+
lons = np.array([ds.Longitude.values for ds in ds_list])
|
|
58
|
+
if not np.allclose(lats, lats[0]):
|
|
59
|
+
raise ValueError("Latitude values differ between datasets.")
|
|
60
|
+
if not np.allclose(lons, lons[0]):
|
|
61
|
+
raise ValueError("Longitude values differ between datasets.")
|
|
62
|
+
|
|
63
|
+
# Collect serial numbers and common attributes
|
|
64
|
+
serial_numbers = [ds.attrs.get("serial_number", "unknown") for ds in ds_list]
|
|
65
|
+
common_attrs = find_common_attributes(ds_list)
|
|
66
|
+
|
|
67
|
+
ds_out = xr.Dataset(
|
|
68
|
+
data_vars=data_vars,
|
|
69
|
+
coords={
|
|
70
|
+
time_key: time,
|
|
71
|
+
"N_LEVELS": np.arange(n_levels),
|
|
72
|
+
"InstrDepth": ("N_LEVELS", instr_depths),
|
|
73
|
+
},
|
|
74
|
+
attrs={
|
|
75
|
+
**common_attrs,
|
|
76
|
+
"Latitude": float(lats[0]),
|
|
77
|
+
"Longitude": float(lons[0]),
|
|
78
|
+
"serial_numbers": serial_numbers,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return ds_out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def add_serial_and_sensor_info(ds_combined, ds_list):
|
|
86
|
+
"""Add serial number/depth mapping and sensor variable info to the combined dataset.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
ds_combined : xarray.Dataset
|
|
91
|
+
The combined mooring-level dataset (with DEPTH coordinate).
|
|
92
|
+
ds_list : list of xarray.Dataset
|
|
93
|
+
List of instrument-level datasets used to create ds_combined.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
xarray.Dataset
|
|
98
|
+
The dataset with added attributes and SENSOR_VARS variable.
|
|
99
|
+
dict
|
|
100
|
+
The updated attributes dictionary.
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
# Get the serial numbers and depths
|
|
104
|
+
serial_numbers = []
|
|
105
|
+
depths = []
|
|
106
|
+
for ds in ds_list:
|
|
107
|
+
serial_number = ds.attrs.get("serial_number", "unknown")
|
|
108
|
+
if serial_number not in serial_numbers:
|
|
109
|
+
serial_numbers.append(serial_number)
|
|
110
|
+
depth_val = (
|
|
111
|
+
float(ds.InstrDepth.values)
|
|
112
|
+
if np.ndim(ds.InstrDepth.values) == 0
|
|
113
|
+
else float(ds.InstrDepth.values[0])
|
|
114
|
+
)
|
|
115
|
+
depths.append(depth_val)
|
|
116
|
+
serial_depth_dict = dict(zip(serial_numbers, depths))
|
|
117
|
+
attrs = ds_combined.attrs.copy()
|
|
118
|
+
attrs["serial_depth_dict"] = str(serial_depth_dict)
|
|
119
|
+
|
|
120
|
+
# Collect all variable names that match "SENSOR_" prefix
|
|
121
|
+
sensor_vars = [var for var in ds_combined.data_vars if var.startswith("SENSOR_")]
|
|
122
|
+
sensor_depths = []
|
|
123
|
+
for var in sensor_vars:
|
|
124
|
+
vals = ds_combined[var].values
|
|
125
|
+
if vals.ndim == 1:
|
|
126
|
+
idx = np.where(~np.isnan(vals))[0]
|
|
127
|
+
depth = (
|
|
128
|
+
float(ds_combined["DEPTH"].values[idx[0]]) if idx.size > 0 else np.nan
|
|
129
|
+
)
|
|
130
|
+
elif vals.ndim > 1:
|
|
131
|
+
idx = np.where(~np.isnan(vals.any(axis=tuple(range(1, vals.ndim)))))
|
|
132
|
+
depth = (
|
|
133
|
+
float(ds_combined["DEPTH"].values[idx[0][0]])
|
|
134
|
+
if idx[0].size > 0
|
|
135
|
+
else np.nan
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
depth = np.nan
|
|
139
|
+
sensor_depths.append(depth)
|
|
140
|
+
sensor_vars = np.array(sensor_vars)
|
|
141
|
+
sensor_depths = np.array(sensor_depths)
|
|
142
|
+
sensor_vars_at_depth = []
|
|
143
|
+
for depth in ds_combined["DEPTH"].values:
|
|
144
|
+
matching_vars = sensor_vars[sensor_depths == depth]
|
|
145
|
+
if matching_vars.size > 0:
|
|
146
|
+
sensor_vars_at_depth.append(", ".join(matching_vars))
|
|
147
|
+
else:
|
|
148
|
+
sensor_vars_at_depth.append("")
|
|
149
|
+
sensor_vars_arr = np.array(sensor_vars_at_depth, dtype="object")
|
|
150
|
+
ds_combined["SENSOR_VARS"] = xr.DataArray(
|
|
151
|
+
sensor_vars_arr,
|
|
152
|
+
dims=("DEPTH",),
|
|
153
|
+
coords={"DEPTH": ds_combined["DEPTH"]},
|
|
154
|
+
attrs={"long_name": "Sensor variable names", "units": "string"},
|
|
155
|
+
)
|
|
156
|
+
return ds_combined, attrs
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def combine_mooring_OS(ds_list):
|
|
160
|
+
"""Combine a list of OceanSITES instrument-level datasets into a mooring-level dataset.
|
|
161
|
+
|
|
162
|
+
Parameters
|
|
163
|
+
----------
|
|
164
|
+
ds_list : list of xarray.Dataset
|
|
165
|
+
List of instrument-level datasets to concatenate along DEPTH.
|
|
166
|
+
|
|
167
|
+
Returns
|
|
168
|
+
-------
|
|
169
|
+
xarray.Dataset
|
|
170
|
+
Combined dataset with cleaned and updated global attributes.
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
if not ds_list:
|
|
174
|
+
raise ValueError("Input list is empty. At least one dataset is required.")
|
|
175
|
+
|
|
176
|
+
ds_combined = utilities.concat_with_scalar_vars(ds_list, dim="DEPTH")
|
|
177
|
+
ds_combined = ds_combined.sortby("DEPTH") # Ensure DEPTH is sorted
|
|
178
|
+
|
|
179
|
+
# Start with a copy of attributes from the first dataset
|
|
180
|
+
attrs = ds_combined.attrs.copy()
|
|
181
|
+
|
|
182
|
+
# Remove instrument-specific attribute
|
|
183
|
+
attrs.pop("serial_number", None)
|
|
184
|
+
|
|
185
|
+
# Merge all unique source_file entries
|
|
186
|
+
source_files = []
|
|
187
|
+
for ds in ds_list:
|
|
188
|
+
sf = ds.attrs.get("source_file")
|
|
189
|
+
if isinstance(sf, list):
|
|
190
|
+
source_files.extend(sf)
|
|
191
|
+
elif isinstance(sf, str):
|
|
192
|
+
# Split comma-separated strings if needed
|
|
193
|
+
source_files.extend([s.strip() for s in sf.split(",")])
|
|
194
|
+
unique_sorted = sorted(set(source_files))
|
|
195
|
+
attrs["source_file"] = ", ".join(unique_sorted)
|
|
196
|
+
|
|
197
|
+
# Simplify id and title
|
|
198
|
+
platform_code = attrs.get("platform_code", "unknown")
|
|
199
|
+
deployment_code = attrs.get("deployment_code", "unknown")
|
|
200
|
+
attrs["title"] = f"Time series from {platform_code}_{deployment_code}"
|
|
201
|
+
attrs["id"] = f"OS_{platform_code}_{deployment_code}_P"
|
|
202
|
+
|
|
203
|
+
# Update geospatial vertical bounds from DEPTH coordinate
|
|
204
|
+
if "DEPTH" in ds_combined.coords:
|
|
205
|
+
depths = ds_combined["DEPTH"].values
|
|
206
|
+
attrs["geospatial_vertical_min"] = float(np.nanmin(depths))
|
|
207
|
+
attrs["geospatial_vertical_max"] = float(np.nanmax(depths))
|
|
208
|
+
|
|
209
|
+
# Re-order variables to put any scalar variables at the end, and if they have numeric values, then put them in order of low to high values
|
|
210
|
+
scalar_vars = []
|
|
211
|
+
for var in ds_combined.data_vars:
|
|
212
|
+
if ds_combined[var].ndim == 0:
|
|
213
|
+
scalar_vars.append(var)
|
|
214
|
+
scalar_vars.sort(
|
|
215
|
+
key=lambda v: (
|
|
216
|
+
ds_combined[v].values
|
|
217
|
+
if np.issubdtype(ds_combined[v].dtype, np.number)
|
|
218
|
+
else float("inf")
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
non_scalar_vars = [var for var in ds_combined.data_vars if var not in scalar_vars]
|
|
222
|
+
ds_combined = ds_combined[non_scalar_vars + scalar_vars]
|
|
223
|
+
|
|
224
|
+
# Assign back updated attributes
|
|
225
|
+
ds_combined.attrs = attrs
|
|
226
|
+
|
|
227
|
+
return ds_combined
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def auto_filt(y, sr, co, typ="low", fo=6):
|
|
231
|
+
"""Apply a Butterworth digital filter to a data array.
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
y : array_like
|
|
236
|
+
Input data array (1D).
|
|
237
|
+
sr : float
|
|
238
|
+
Sampling rate (Hz or 1/time units of your data).
|
|
239
|
+
co : float or tuple of float
|
|
240
|
+
Cutoff frequency/frequencies. A scalar for 'low' or 'high', a 2-tuple for 'bandstop'.
|
|
241
|
+
typ : str, optional
|
|
242
|
+
Filter type: 'low', 'high', or 'bandstop'. Default is 'low'.
|
|
243
|
+
fo : int, optional
|
|
244
|
+
Filter order. Default is 6.
|
|
245
|
+
|
|
246
|
+
Returns
|
|
247
|
+
-------
|
|
248
|
+
yf : ndarray
|
|
249
|
+
Filtered data array.
|
|
250
|
+
|
|
251
|
+
"""
|
|
252
|
+
# Normalize cutoff frequency to the Nyquist rate
|
|
253
|
+
nyquist = 0.5 * sr
|
|
254
|
+
if isinstance(co, (list, tuple, np.ndarray)):
|
|
255
|
+
wh = [c / nyquist for c in co]
|
|
256
|
+
else:
|
|
257
|
+
wh = co / nyquist
|
|
258
|
+
|
|
259
|
+
b, a = butter(fo, wh, btype=typ)
|
|
260
|
+
yf = filtfilt(b, a, y)
|
|
261
|
+
return yf
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def filter_all_time_vars(ds, cutoff_days=2, fo=6):
|
|
265
|
+
"""Apply a lowpass Butterworth filter to all data variables that depend on TIME.
|
|
266
|
+
|
|
267
|
+
Parameters
|
|
268
|
+
----------
|
|
269
|
+
ds : xarray.Dataset
|
|
270
|
+
Dataset containing time series variables.
|
|
271
|
+
cutoff_days : float, optional
|
|
272
|
+
Lowpass filter cutoff in days. Default is 2 days.
|
|
273
|
+
fo : int, optional
|
|
274
|
+
Filter order. Default is 6.
|
|
275
|
+
|
|
276
|
+
Returns
|
|
277
|
+
-------
|
|
278
|
+
xarray.Dataset
|
|
279
|
+
Dataset with filtered variables.
|
|
280
|
+
|
|
281
|
+
"""
|
|
282
|
+
time = ds["TIME"]
|
|
283
|
+
dt = (time.isel(TIME=1) - time.isel(TIME=0)) / np.timedelta64(1, "s") # seconds
|
|
284
|
+
sr = 1.0 / dt # Hz
|
|
285
|
+
co = 1.0 / (cutoff_days * 24 * 3600) # Hz
|
|
286
|
+
|
|
287
|
+
ds_filt = ds.copy(deep=True)
|
|
288
|
+
padlen = 3 * fo
|
|
289
|
+
|
|
290
|
+
for var in ds.data_vars:
|
|
291
|
+
da = ds[var]
|
|
292
|
+
if "TIME" in da.dims and da.sizes["TIME"] == ds["TIME"].size:
|
|
293
|
+
y = da.values
|
|
294
|
+
|
|
295
|
+
if y.shape[0] <= padlen:
|
|
296
|
+
continue # skip too-short series
|
|
297
|
+
|
|
298
|
+
# Reshape for filtering along TIME
|
|
299
|
+
y_flat = y.reshape((y.shape[0], -1)) # (TIME, N)
|
|
300
|
+
y_filt = np.empty_like(y_flat)
|
|
301
|
+
|
|
302
|
+
for i in range(y_flat.shape[1]):
|
|
303
|
+
y1d = y_flat[:, i]
|
|
304
|
+
if np.isnan(y1d).all():
|
|
305
|
+
y_filt[:, i] = np.nan
|
|
306
|
+
else:
|
|
307
|
+
try:
|
|
308
|
+
y_filt[:, i] = auto_filt(y1d, sr, co, typ="low", fo=fo)
|
|
309
|
+
except ValueError:
|
|
310
|
+
y_filt[:, i] = np.nan # fallback for rare failures
|
|
311
|
+
|
|
312
|
+
# Reshape back to original
|
|
313
|
+
ds_filt[var].values = y_filt.reshape(y.shape)
|
|
314
|
+
|
|
315
|
+
return ds_filt
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def get_12hourly_time_grid(
|
|
319
|
+
time_or_ds,
|
|
320
|
+
freq="12h",
|
|
321
|
+
start_offset=pd.Timedelta(days=1),
|
|
322
|
+
end_offset=pd.Timedelta(0),
|
|
323
|
+
time_var="TIME",
|
|
324
|
+
):
|
|
325
|
+
"""Given a pandas.DatetimeIndex, array of datetimes, or xarray.Dataset,
|
|
326
|
+
return a regular time grid (default: 12-hourly) from the first full day after the start
|
|
327
|
+
to the last full day before the end.
|
|
328
|
+
|
|
329
|
+
Parameters
|
|
330
|
+
----------
|
|
331
|
+
time_or_ds : array-like of datetime64, pandas.DatetimeIndex, or xarray.Dataset
|
|
332
|
+
Input time array or dataset containing a time variable.
|
|
333
|
+
freq : str, optional
|
|
334
|
+
Frequency string for the output grid (default '12h').
|
|
335
|
+
start_offset : pd.Timedelta, optional
|
|
336
|
+
Offset to add to the start time before normalizing (default 1 day).
|
|
337
|
+
end_offset : pd.Timedelta, optional
|
|
338
|
+
Offset to subtract from the end time after normalizing (default 0).
|
|
339
|
+
time_var : str, optional
|
|
340
|
+
Name of the time variable in the dataset (default 'TIME').
|
|
341
|
+
|
|
342
|
+
Returns
|
|
343
|
+
-------
|
|
344
|
+
pandas.DatetimeIndex
|
|
345
|
+
Regular time grid at the specified frequency.
|
|
346
|
+
|
|
347
|
+
"""
|
|
348
|
+
if hasattr(time_or_ds, "dims") and time_var in time_or_ds:
|
|
349
|
+
# Assume xarray.Dataset or DataArray
|
|
350
|
+
time = pd.to_datetime(time_or_ds[time_var].values)
|
|
351
|
+
else:
|
|
352
|
+
time = pd.to_datetime(time_or_ds)
|
|
353
|
+
start = time[0]
|
|
354
|
+
stop = time[-1]
|
|
355
|
+
start_day = (
|
|
356
|
+
(start + start_offset).normalize()
|
|
357
|
+
if start.time() != pd.Timestamp("00:00").time()
|
|
358
|
+
else start.normalize()
|
|
359
|
+
)
|
|
360
|
+
stop_day = (stop - end_offset).normalize()
|
|
361
|
+
jd_grid = pd.date_range(start=start_day, end=stop_day, freq=freq)
|
|
362
|
+
return jd_grid
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def interp_to_12hour_grid(ds1):
|
|
366
|
+
"""Interpolate all variables with a TIME dimension to a regular 12-hour grid.
|
|
367
|
+
|
|
368
|
+
Handles both 1D and multidimensional variables with TIME as the first dimension.
|
|
369
|
+
"""
|
|
370
|
+
jd_grid = get_12hourly_time_grid(ds1)
|
|
371
|
+
time1 = ds1["TIME"].values
|
|
372
|
+
time1_float = time1.astype("datetime64[s]").astype(float)
|
|
373
|
+
grid_float = jd_grid.values.astype("datetime64[s]").astype(float)
|
|
374
|
+
|
|
375
|
+
interp_vars = {}
|
|
376
|
+
|
|
377
|
+
for var in ds1.data_vars:
|
|
378
|
+
da = ds1[var]
|
|
379
|
+
if "TIME" not in da.dims:
|
|
380
|
+
continue # skip non-time-dependent variables
|
|
381
|
+
|
|
382
|
+
axis_time = da.get_axis_num("TIME")
|
|
383
|
+
other_dims = [d for d in da.dims if d != "TIME"]
|
|
384
|
+
|
|
385
|
+
# Reshape to (TIME, -1) to interpolate each column
|
|
386
|
+
reshaped = da.transpose("TIME", *other_dims).data.reshape(len(time1), -1)
|
|
387
|
+
|
|
388
|
+
interpolated = np.empty((len(grid_float), reshaped.shape[1]))
|
|
389
|
+
|
|
390
|
+
for i in range(reshaped.shape[1]):
|
|
391
|
+
interp_func = interp1d(
|
|
392
|
+
time1_float,
|
|
393
|
+
reshaped[:, i],
|
|
394
|
+
bounds_error=False,
|
|
395
|
+
fill_value="extrapolate",
|
|
396
|
+
assume_sorted=True,
|
|
397
|
+
)
|
|
398
|
+
interpolated[:, i] = interp_func(grid_float)
|
|
399
|
+
|
|
400
|
+
# Reshape back to (TIME, *other_dims)
|
|
401
|
+
new_shape = (len(grid_float),) + tuple(
|
|
402
|
+
da.transpose("TIME", *other_dims).shape[1:]
|
|
403
|
+
)
|
|
404
|
+
interp_data = interpolated.reshape(new_shape)
|
|
405
|
+
|
|
406
|
+
# Create new DataArray
|
|
407
|
+
coords = {"TIME": jd_grid}
|
|
408
|
+
for dim in other_dims:
|
|
409
|
+
coords[dim] = ds1[dim]
|
|
410
|
+
|
|
411
|
+
interp_vars[var] = xr.DataArray(
|
|
412
|
+
interp_data, dims=("TIME", *other_dims), coords=coords, attrs=da.attrs
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Build new dataset
|
|
416
|
+
ds_interp = xr.Dataset(interp_vars, coords={"TIME": jd_grid})
|
|
417
|
+
ds_interp.attrs = ds1.attrs.copy()
|
|
418
|
+
|
|
419
|
+
# Copy non-time-dependent variables
|
|
420
|
+
for var in ds1.data_vars:
|
|
421
|
+
if "TIME" not in ds1[var].dims:
|
|
422
|
+
ds_interp[var] = ds1[var]
|
|
423
|
+
|
|
424
|
+
# Also copy over coordinates that are not 'TIME'
|
|
425
|
+
for coord in ds1.coords:
|
|
426
|
+
if coord != "TIME" and coord not in ds_interp.coords:
|
|
427
|
+
ds_interp = ds_interp.assign_coords({coord: ds1[coord]})
|
|
428
|
+
|
|
429
|
+
return ds_interp
|