doppy 0.5.9__cp310-abi3-macosx_10_12_x86_64.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.
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from io import BufferedIOBase
5
+ from pathlib import Path
6
+ from typing import Sequence
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+
11
+ import doppy
12
+ from doppy import options
13
+ from doppy.product.stare import PulsesPerRay, RayAccumulationTime, Stare
14
+
15
+
16
+ @dataclass
17
+ class StareDepol:
18
+ """
19
+ Stare product with depolarisation ratio derived from co-polarised and
20
+ cross-polarised stare data.
21
+
22
+ Attributes:
23
+ -----------
24
+ time
25
+ An array of datetime64 objects representing the observation times.
26
+ radial_distance
27
+ An array of radial distances from the observation point, in meters.
28
+ elevation
29
+ An array of elevation angles corresponding to the observation points, in
30
+ degrees.
31
+ beta
32
+ An array of backscatter coefficients for the co-polarised signal, in
33
+ sr-1 m-1.
34
+ beta_cross
35
+ An array of backscatter coefficients for the cross-polarised signal, in
36
+ sr-1 m-1.
37
+ radial_velocity
38
+ An array of radial velocities of the co-polarised signal, in m s-1.
39
+ mask_beta
40
+ A boolean array indicating signal (True) or noise (False) data points.
41
+ mask_radial_velocity
42
+ A boolean array indicating signal (True) or noise (False) data points.
43
+ depolarisation
44
+ An array of depolarisation ratios calculated as the ratio of
45
+ co-polarised to cross-polarised backscatter coefficients.
46
+ wavelength
47
+ The wavelength of the lidar, in meters.
48
+ system_id
49
+ A string identifier for the lidar.
50
+
51
+
52
+ Raises
53
+ ------
54
+ ValueError
55
+ If the input `co` and `cross` products have mismatched wavelengths,
56
+ system IDs, radial distances, or elevation angles, this exception is
57
+ raised.
58
+
59
+
60
+ References
61
+ ----------
62
+ Aerosol particle depolarization ratio at 1565 nm measured with a Halo Doppler lidar
63
+ authors: Ville Vakkari, Holger Baars, Stephanie Bohlmann, Johannes Bühl,
64
+ Mika Komppula, Rodanthi-Elisavet Mamouri, and Ewan James O'Connor
65
+ doi: https://doi.org/10.5194/acp-21-5807-2021
66
+ """
67
+
68
+ time: npt.NDArray[np.datetime64]
69
+ radial_distance: npt.NDArray[np.float64]
70
+ elevation: npt.NDArray[np.float64]
71
+ beta: npt.NDArray[np.float64]
72
+ beta_cross: npt.NDArray[np.float64]
73
+ radial_velocity: npt.NDArray[np.float64]
74
+ mask_beta: npt.NDArray[np.bool_]
75
+ mask_radial_velocity: npt.NDArray[np.bool_]
76
+ depolarisation: npt.NDArray[np.float64]
77
+ polariser_bleed_through: float
78
+ wavelength: float
79
+ system_id: str
80
+ ray_info: RayAccumulationTime | PulsesPerRay
81
+
82
+ def __init__(
83
+ self,
84
+ co: Stare,
85
+ cross: Stare,
86
+ polariser_bleed_through: float = 0.0,
87
+ ):
88
+ """
89
+ Parameters
90
+ ----------
91
+ co: Stare
92
+ The co-polarised data.
93
+ cross: Stare
94
+ The cross-polarised data. The `cross.time` array is expected to be sorted.
95
+ polariser_bleed_through: float, default=0.0
96
+ The amount of bleed-through from the polariser.
97
+ """
98
+
99
+ if co.beta.shape[1] != cross.beta.shape[1]:
100
+ raise doppy.exceptions.ShapeError(
101
+ "Range dimension mismatch in co and cross: "
102
+ f"{co.beta.shape[1]} vs {cross.beta.shape[1]}"
103
+ )
104
+
105
+ if not np.isclose(co.wavelength, cross.wavelength):
106
+ raise ValueError(
107
+ "Different wavelength in co and cross: "
108
+ f"{co.wavelength} vs {cross.wavelength}"
109
+ )
110
+ if co.system_id != cross.system_id:
111
+ raise ValueError(
112
+ "Different system ID in co and cross: "
113
+ f"{co.system_id} vs {cross.system_id}"
114
+ )
115
+ if not np.allclose(co.radial_distance, cross.radial_distance, atol=1):
116
+ raise ValueError("Different radial distance in co and cross")
117
+
118
+ ind = np.searchsorted(cross.time, co.time, side="left")
119
+ pick_ind = ind < len(cross.time)
120
+ time_diff_threshold = 2 * np.median(np.diff(co.time))
121
+ co_cross_timediff_below_threshold = (
122
+ cross.time[ind[pick_ind]] - co.time[pick_ind] < time_diff_threshold
123
+ )
124
+ pick_ind[pick_ind] &= co_cross_timediff_below_threshold
125
+
126
+ if not np.allclose(
127
+ co.elevation[pick_ind], cross.elevation[ind[pick_ind]], atol=1
128
+ ):
129
+ raise ValueError("Different elevation in co and cross")
130
+
131
+ depolarisation = np.full_like(co.beta, np.nan)
132
+ co_beta = co.beta[pick_ind]
133
+ depolarisation[pick_ind] = (
134
+ cross.beta[ind[pick_ind]] - polariser_bleed_through * co_beta
135
+ ) / co_beta
136
+ cross_beta = np.full_like(co.beta, np.nan)
137
+ cross_beta[pick_ind] = cross.beta[ind[pick_ind]]
138
+
139
+ self.time = co.time
140
+ self.radial_distance = co.radial_distance
141
+ self.elevation = co.elevation
142
+ self.beta = co.beta
143
+ self.beta_cross = cross_beta
144
+ self.radial_velocity = co.radial_velocity
145
+ self.mask_beta = co.mask_beta
146
+ self.mask_radial_velocity = co.mask_radial_velocity
147
+ self.depolarisation = depolarisation
148
+ self.polariser_bleed_through = polariser_bleed_through
149
+ self.wavelength = co.wavelength
150
+ self.system_id = co.system_id
151
+ self.ray_info = co.ray_info
152
+
153
+ @property
154
+ def mask_depolarisation(self) -> npt.NDArray[np.bool_]:
155
+ return np.isnan(self.depolarisation)
156
+
157
+ @property
158
+ def mask_beta_cross(self) -> npt.NDArray[np.bool_]:
159
+ return np.isnan(self.beta_cross)
160
+
161
+ @classmethod
162
+ def from_halo_data(
163
+ cls,
164
+ co_data: Sequence[str]
165
+ | Sequence[Path]
166
+ | Sequence[bytes]
167
+ | Sequence[BufferedIOBase],
168
+ co_data_bg: Sequence[str]
169
+ | Sequence[Path]
170
+ | Sequence[tuple[bytes, str]]
171
+ | Sequence[tuple[BufferedIOBase, str]],
172
+ cross_data: Sequence[str]
173
+ | Sequence[Path]
174
+ | Sequence[bytes]
175
+ | Sequence[BufferedIOBase],
176
+ cross_data_bg: Sequence[str]
177
+ | Sequence[Path]
178
+ | Sequence[tuple[bytes, str]]
179
+ | Sequence[tuple[BufferedIOBase, str]],
180
+ bg_correction_method: options.BgCorrectionMethod,
181
+ polariser_bleed_through: float = 0,
182
+ ) -> StareDepol:
183
+ co = Stare.from_halo_data(
184
+ data=co_data, data_bg=co_data_bg, bg_correction_method=bg_correction_method
185
+ )
186
+ cross = Stare.from_halo_data(
187
+ data=cross_data,
188
+ data_bg=cross_data_bg,
189
+ bg_correction_method=bg_correction_method,
190
+ )
191
+ return cls(co, cross, polariser_bleed_through)
192
+
193
+ def write_to_netcdf(self, filename: str | Path) -> None:
194
+ with doppy.netcdf.Dataset(filename) as nc:
195
+ nc.add_dimension("time")
196
+ nc.add_dimension("range")
197
+ nc.add_time(
198
+ name="time",
199
+ dimensions=("time",),
200
+ standard_name="time",
201
+ long_name="Time UTC",
202
+ data=self.time,
203
+ dtype="f8",
204
+ )
205
+ nc.add_variable(
206
+ name="range",
207
+ dimensions=("range",),
208
+ units="m",
209
+ data=self.radial_distance,
210
+ dtype="f4",
211
+ )
212
+ nc.add_variable(
213
+ name="elevation",
214
+ dimensions=("time",),
215
+ units="degrees",
216
+ data=self.elevation,
217
+ dtype="f4",
218
+ long_name="elevation from horizontal",
219
+ )
220
+ nc.add_variable(
221
+ name="beta_raw",
222
+ dimensions=("time", "range"),
223
+ units="sr-1 m-1",
224
+ data=self.beta,
225
+ dtype="f4",
226
+ )
227
+ nc.add_variable(
228
+ name="beta",
229
+ dimensions=("time", "range"),
230
+ units="sr-1 m-1",
231
+ data=self.beta,
232
+ dtype="f4",
233
+ mask=self.mask_beta,
234
+ )
235
+ nc.add_variable(
236
+ name="v",
237
+ dimensions=("time", "range"),
238
+ units="m s-1",
239
+ long_name="Doppler velocity",
240
+ data=self.radial_velocity,
241
+ dtype="f4",
242
+ mask=self.mask_radial_velocity,
243
+ )
244
+ nc.add_scalar_variable(
245
+ name="wavelength",
246
+ units="m",
247
+ standard_name="radiation_wavelength",
248
+ data=self.wavelength,
249
+ dtype="f4",
250
+ )
251
+ nc.add_variable(
252
+ name="depolarisation_raw",
253
+ dimensions=("time", "range"),
254
+ units="1",
255
+ data=self.depolarisation,
256
+ dtype="f4",
257
+ mask=self.mask_depolarisation,
258
+ )
259
+ nc.add_variable(
260
+ name="depolarisation",
261
+ dimensions=("time", "range"),
262
+ units="1",
263
+ data=self.depolarisation,
264
+ dtype="f4",
265
+ mask=self.mask_beta | self.mask_depolarisation,
266
+ )
267
+ nc.add_variable(
268
+ name="beta_cross_raw",
269
+ dimensions=("time", "range"),
270
+ units="sr-1 m-1",
271
+ data=self.beta_cross,
272
+ mask=self.mask_beta_cross,
273
+ dtype="f4",
274
+ )
275
+ nc.add_variable(
276
+ name="beta_cross",
277
+ dimensions=("time", "range"),
278
+ units="sr-1 m-1",
279
+ data=self.beta_cross,
280
+ mask=self.mask_beta | self.mask_beta_cross,
281
+ dtype="f4",
282
+ )
283
+ nc.add_scalar_variable(
284
+ name="polariser_bleed_through",
285
+ units="1",
286
+ long_name="Polariser bleed-through",
287
+ data=self.polariser_bleed_through,
288
+ dtype="f4",
289
+ )
290
+ match self.ray_info:
291
+ case RayAccumulationTime(value):
292
+ nc.add_scalar_variable(
293
+ name="ray_accumulation_time",
294
+ units="s",
295
+ long_name="ray accumulation time",
296
+ data=value,
297
+ dtype="f4",
298
+ )
299
+ case PulsesPerRay(value):
300
+ nc.add_scalar_variable(
301
+ name="pulses_per_ray",
302
+ units="1",
303
+ long_name="pulses per ray",
304
+ data=value,
305
+ dtype="u4",
306
+ )
307
+ nc.add_attribute("serial_number", self.system_id)
308
+ nc.add_attribute("doppy_version", doppy.__version__)
@@ -0,0 +1,264 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ import numpy.typing as npt
7
+ from scipy.interpolate import RegularGridInterpolator
8
+
9
+
10
+ @dataclass
11
+ class HorizontalWind:
12
+ time: npt.NDArray[np.datetime64]
13
+ height: npt.NDArray[np.float64] # Height in meters from reference
14
+ V: npt.NDArray[np.float64] # Horizontal wind speed in m/s
15
+
16
+
17
+ @dataclass
18
+ class VerticalWind:
19
+ time: npt.NDArray[np.datetime64]
20
+ height: npt.NDArray[np.float64] # Height in meters from reference
21
+ w: npt.NDArray[np.float64] # Vertical wind speed in m/s
22
+ mask: npt.NDArray[np.bool_] # mask[t,h] = True iff w[t,h] should be masked
23
+
24
+
25
+ @dataclass
26
+ class Options:
27
+ ray_accumulation_time: float # in seconds
28
+ period: float = 600 # period for computing the variance in seconds
29
+ beam_divergence: float = 33e-6 # radians
30
+
31
+
32
+ @dataclass
33
+ class Turbulence:
34
+ time: npt.NDArray[np.datetime64]
35
+ height: npt.NDArray[np.float64]
36
+ turbulent_kinetic_energy_dissipation_rate: npt.NDArray[np.float64]
37
+ mask: npt.NDArray[np.bool_]
38
+
39
+ @classmethod
40
+ def from_winds(
41
+ cls, vert: VerticalWind, hori: HorizontalWind, options: Options
42
+ ) -> Turbulence:
43
+ V = _preprocess_horiontal_wind(vert, hori, options)
44
+ ls_low = _length_scale_low(V, vert.height, options)
45
+ res = _compute_variance(vert, options.period)
46
+ sampling_time = _sampling_time_in_seconds(res)
47
+ ls_up = V * sampling_time
48
+ dissipation_rate = _compute_dissipation_rate(res.variance, ls_low, ls_up)
49
+ mask = np.isnan(dissipation_rate) | vert.mask
50
+ return cls(
51
+ time=vert.time.copy(),
52
+ height=vert.height.copy(),
53
+ turbulent_kinetic_energy_dissipation_rate=dissipation_rate,
54
+ mask=mask,
55
+ )
56
+
57
+
58
+ def _sampling_time_in_seconds(r: VarResult) -> npt.NDArray[np.float64]:
59
+ if not all(
60
+ (
61
+ t == np.dtype("datetime64[us]")
62
+ for t in (r.period_start.dtype, r.period_stop.dtype)
63
+ )
64
+ ):
65
+ raise ValueError("period times must be on datetime64[us]")
66
+ td = r.period_stop - r.period_start
67
+ td_in_seconds = td / np.timedelta64(1, "s")
68
+ return np.array(td_in_seconds, dtype=np.float64)
69
+
70
+
71
+ @dataclass
72
+ class VarResult:
73
+ variance: npt.NDArray[np.float64]
74
+ period_start: npt.NDArray[np.datetime64]
75
+ period_stop: npt.NDArray[np.datetime64]
76
+ nsamples: npt.NDArray[np.int64]
77
+
78
+
79
+ def _compute_variance(vert: VerticalWind, period: float) -> VarResult:
80
+ # NOTE: numerically unstable
81
+
82
+ # To compute actual time window
83
+ next_valid = _next_valid_from_mask(vert.mask)
84
+ prev_valid = _prev_valid_from_mask(vert.mask)
85
+
86
+ X = vert.w.copy()
87
+ X[vert.mask] = 0
88
+ X2 = X**2
89
+ X_cumsum = X.cumsum(axis=0)
90
+ X2_cumsum = X2.cumsum(axis=0)
91
+
92
+ N_i = (~vert.mask).astype(int)
93
+ N_cumsum = N_i.cumsum(axis=0)
94
+
95
+ def N_func(i: int, j: int) -> npt.NDArray[np.float64]:
96
+ return np.array(N_cumsum[j] - N_cumsum[i] + N_i[i], dtype=np.float64)
97
+
98
+ def S(i: int, j: int) -> npt.NDArray[np.float64]:
99
+ return np.array(X_cumsum[j] - X_cumsum[i] + X[i], dtype=np.float64)
100
+
101
+ def S2(i: int, j: int) -> npt.NDArray[np.float64]:
102
+ return np.array(X2_cumsum[j] - X2_cumsum[i] + X2[i], dtype=np.float64)
103
+
104
+ def var_ij(i: int, j: int) -> npt.NDArray[np.float64]:
105
+ N = N_func(i, j)
106
+ with np.errstate(invalid="ignore"):
107
+ return np.array((S2(i, j) - S(i, j) ** 2 / N) / N, dtype=np.float64)
108
+
109
+ half_period = np.timedelta64(int(1e6 * period / 2), "us")
110
+ period_start = np.full(vert.w.shape, np.datetime64("NaT", "us"))
111
+ period_stop = np.full(vert.w.shape, np.datetime64("NaT", "us"))
112
+ var = np.full(vert.w.shape, np.nan, dtype=np.float64)
113
+ nsamples = np.zeros_like(vert.w, dtype=np.int64)
114
+ i = 0
115
+ j = 0
116
+ n = len(vert.time)
117
+ for k, t in enumerate(vert.time):
118
+ while i + 1 < n and t - vert.time[i + 1] >= half_period:
119
+ i += 1
120
+ while j + 1 < n and vert.time[j] - t < half_period:
121
+ j += 1
122
+ i_valid = next_valid[i]
123
+ i_inbound = (0 <= i_valid) & (i_valid < n)
124
+ j_valid = prev_valid[j]
125
+ j_inbound = (0 <= j_valid) & (j_valid < n)
126
+ period_start[k][i_inbound] = vert.time[i_valid[i_inbound]]
127
+ period_stop[k][j_inbound] = vert.time[j_valid[j_inbound]]
128
+ var[k] = var_ij(i, j)
129
+ nsamples[k] = N_func(i, j)
130
+ return VarResult(
131
+ variance=var,
132
+ period_start=period_start,
133
+ period_stop=period_stop,
134
+ nsamples=nsamples,
135
+ )
136
+
137
+
138
+ def _length_scale_low(
139
+ V: npt.NDArray[np.float64], height: npt.NDArray[np.float64], opts: Options
140
+ ) -> npt.NDArray[np.float64]:
141
+ integration_time = opts.ray_accumulation_time
142
+ from_beam = 2 * height * np.sin(opts.beam_divergence / 2)
143
+ from_wind = V * integration_time
144
+ return np.array(from_wind + from_beam[np.newaxis, :], dtype=np.float64)
145
+
146
+
147
+ def _preprocess_horiontal_wind(
148
+ vert: VerticalWind, hori: HorizontalWind, options: Options
149
+ ) -> npt.NDArray[np.float64]:
150
+ if np.isnan(hori.V).any():
151
+ raise ValueError("horizontal wind speed cannot contains NaNs")
152
+ trg_points = np.meshgrid(vert.time, vert.height, indexing="ij")
153
+ src_points = (hori.time, hori.height)
154
+ src_vals = hori.V
155
+
156
+ interp_nearest = RegularGridInterpolator(
157
+ src_points,
158
+ src_vals,
159
+ method="nearest",
160
+ bounds_error=False,
161
+ fill_value=None,
162
+ )
163
+ interp_linear = RegularGridInterpolator(
164
+ src_points, src_vals, method="linear", bounds_error=False
165
+ )
166
+ V_nearest = interp_nearest(trg_points)
167
+ V_linear = interp_linear(trg_points)
168
+ V = V_linear
169
+ V[np.isnan(V)] = V_nearest[np.isnan(V)]
170
+ if np.isnan(V).any():
171
+ raise ValueError("Unexpected NaNs")
172
+ V_rmean = _rolling_mean_over_time(vert.time, V, options.period)
173
+ return V_rmean
174
+
175
+
176
+ def _rolling_mean_over_time(
177
+ time: npt.NDArray[np.datetime64], arr: npt.NDArray[np.float64], period: float
178
+ ) -> npt.NDArray[np.float64]:
179
+ if arr.ndim != 2:
180
+ raise ValueError("number of dims on arr should be 2")
181
+ if time.ndim != 1 or time.shape[0] != arr.shape[0]:
182
+ raise ValueError("time and arr dimensions do not match")
183
+ if time.dtype != np.dtype("datetime64[us]"):
184
+ raise TypeError(f"Invalid time type: {time.dtype}")
185
+
186
+ S = arr.cumsum(axis=0)
187
+
188
+ def rolling_mean(i: int, j: int) -> npt.NDArray[np.float64]:
189
+ return np.array((S[j] - S[i] + arr[i]) / (j - i + 1), dtype=np.float64)
190
+
191
+ half_period = np.timedelta64(int(period * 0.5e6), "us")
192
+ rol_mean = np.full(arr.shape, np.nan, dtype=np.float64)
193
+
194
+ i = 0
195
+ j = 0
196
+ n = len(time)
197
+ for k, t in enumerate(time):
198
+ while i + 1 < n and t - time[i + 1] >= half_period:
199
+ i += 1
200
+ while j + 1 < n and time[j] - t < half_period:
201
+ j += 1
202
+ rol_mean[k] = rolling_mean(i, j)
203
+ return rol_mean
204
+
205
+
206
+ def _compute_dissipation_rate(
207
+ variance: npt.NDArray[np.float64],
208
+ length_scale_lower: npt.NDArray[np.float64],
209
+ length_scale_upper: npt.NDArray[np.float64],
210
+ ) -> npt.NDArray[np.float64]:
211
+ """
212
+ Parameters
213
+ ----------
214
+ variance, length_scale_lower, and length_scale_upper
215
+ dimensions: (time,range)
216
+ """
217
+ kolmogorov_constant = 0.55
218
+ with np.errstate(invalid="ignore"):
219
+ dr = (
220
+ 2
221
+ * np.pi
222
+ * (2 / (3 * kolmogorov_constant)) ** (3 / 2)
223
+ * variance ** (3 / 2)
224
+ * (length_scale_upper ** (2 / 3) - length_scale_lower ** (2 / 3))
225
+ ** (-3 / 2)
226
+ )
227
+ return np.array(dr, dtype=np.float64)
228
+
229
+
230
+ def _next_valid_from_mask(mask: npt.NDArray[np.bool_]) -> npt.NDArray[np.int64]:
231
+ """
232
+ mask[t,v] (time,value)
233
+
234
+ returns N[t,v] = i where i = min { j | j >= t and mask[j,v] == False}
235
+ if the set is non empty and N[t,v] = len(mask) otherwise
236
+ """
237
+ n = len(mask)
238
+ N = np.full(mask.shape, n)
239
+ if mask.size == 0:
240
+ return N
241
+ N[-1][~mask[-1]] = n - 1
242
+
243
+ for t in reversed(range(n - 1)):
244
+ N[t][~mask[t]] = t
245
+ N[t][mask[t]] = N[t + 1][mask[t]]
246
+ return np.array(N, dtype=np.int64)
247
+
248
+
249
+ def _prev_valid_from_mask(mask: npt.NDArray[np.bool_]) -> npt.NDArray[np.int64]:
250
+ """
251
+ mask[t,v] (time,value)
252
+
253
+ returns N[t,v] = i where i = max { j | j <= t and mask[j,v] == False}
254
+ if the set is non empty and N[t,v] = -1 otherwise
255
+ """
256
+ n = len(mask)
257
+ N = np.full(mask.shape, -1)
258
+ if mask.size == 0:
259
+ return N
260
+ N[0][~mask[0]] = 0
261
+ for t in range(1, n):
262
+ N[t][~mask[t]] = t
263
+ N[t][mask[t]] = N[t - 1][mask[t]]
264
+ return np.array(N, dtype=np.int64)
doppy/product/utils.py ADDED
@@ -0,0 +1,12 @@
1
+ from collections import Counter
2
+
3
+ import numpy as np
4
+ import numpy.typing as npt
5
+
6
+
7
+ def arr_to_rounded_set(arr: npt.NDArray[np.float64]) -> set[int]:
8
+ return set(int(x) for x in np.round(arr))
9
+
10
+
11
+ def count_rounded(arr: npt.NDArray[np.float64]) -> Counter[int]:
12
+ return Counter(int(x) for x in np.round(arr))