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.
- doppy/__init__.py +6 -0
- doppy/bench.py +13 -0
- doppy/data/__init__.py +0 -0
- doppy/data/api.py +58 -0
- doppy/data/cache.py +43 -0
- doppy/data/exceptions.py +6 -0
- doppy/defaults.py +18 -0
- doppy/exceptions.py +14 -0
- doppy/netcdf.py +134 -0
- doppy/options.py +13 -0
- doppy/product/__init__.py +6 -0
- doppy/product/noise_utils.py +106 -0
- doppy/product/stare.py +807 -0
- doppy/product/stare_depol.py +308 -0
- doppy/product/turbulence.py +264 -0
- doppy/product/utils.py +12 -0
- doppy/product/wind.py +460 -0
- doppy/py.typed +0 -0
- doppy/raw/__init__.py +16 -0
- doppy/raw/halo_bg.py +173 -0
- doppy/raw/halo_hpl.py +480 -0
- doppy/raw/halo_sys_params.py +135 -0
- doppy/raw/utils.py +14 -0
- doppy/raw/windcube.py +477 -0
- doppy/raw/wls70.py +175 -0
- doppy/raw/wls77.py +163 -0
- doppy/rs.abi3.so +0 -0
- doppy/utils.py +24 -0
- doppy-0.5.9.dist-info/METADATA +144 -0
- doppy-0.5.9.dist-info/RECORD +33 -0
- doppy-0.5.9.dist-info/WHEEL +4 -0
- doppy-0.5.9.dist-info/entry_points.txt +2 -0
- doppy-0.5.9.dist-info/licenses/LICENSE +21 -0
doppy/product/stare.py
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from io import BufferedIOBase
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import DefaultDict, Sequence, Tuple, TypeAlias
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import numpy.typing as npt
|
|
11
|
+
import scipy
|
|
12
|
+
from scipy.ndimage import median_filter, uniform_filter
|
|
13
|
+
from sklearn.cluster import KMeans
|
|
14
|
+
|
|
15
|
+
import doppy
|
|
16
|
+
from doppy import defaults, options
|
|
17
|
+
from doppy.product.noise_utils import detect_wind_noise
|
|
18
|
+
|
|
19
|
+
SelectionGroupKeyType: TypeAlias = tuple[int,]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class RayAccumulationTime:
|
|
24
|
+
# in seconds
|
|
25
|
+
value: float
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class PulsesPerRay:
|
|
30
|
+
value: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class Stare:
|
|
35
|
+
time: npt.NDArray[np.datetime64]
|
|
36
|
+
radial_distance: npt.NDArray[np.float64]
|
|
37
|
+
elevation: npt.NDArray[np.float64]
|
|
38
|
+
beta: npt.NDArray[np.float64]
|
|
39
|
+
snr: npt.NDArray[np.float64]
|
|
40
|
+
radial_velocity: npt.NDArray[np.float64]
|
|
41
|
+
mask_beta: npt.NDArray[np.bool_]
|
|
42
|
+
mask_radial_velocity: npt.NDArray[np.bool_]
|
|
43
|
+
wavelength: float
|
|
44
|
+
system_id: str
|
|
45
|
+
ray_info: RayAccumulationTime | PulsesPerRay
|
|
46
|
+
|
|
47
|
+
def __getitem__(
|
|
48
|
+
self,
|
|
49
|
+
index: int
|
|
50
|
+
| slice
|
|
51
|
+
| list[int]
|
|
52
|
+
| npt.NDArray[np.int64]
|
|
53
|
+
| npt.NDArray[np.bool_]
|
|
54
|
+
| tuple[slice, slice],
|
|
55
|
+
) -> Stare:
|
|
56
|
+
if isinstance(index, (int, slice, list, np.ndarray)):
|
|
57
|
+
return Stare(
|
|
58
|
+
time=self.time[index],
|
|
59
|
+
radial_distance=self.radial_distance,
|
|
60
|
+
elevation=self.elevation[index],
|
|
61
|
+
beta=self.beta[index],
|
|
62
|
+
snr=self.snr[index],
|
|
63
|
+
radial_velocity=self.radial_velocity[index],
|
|
64
|
+
mask_beta=self.mask_beta[index],
|
|
65
|
+
mask_radial_velocity=self.mask_radial_velocity[index],
|
|
66
|
+
wavelength=self.wavelength,
|
|
67
|
+
system_id=self.system_id,
|
|
68
|
+
ray_info=self.ray_info,
|
|
69
|
+
)
|
|
70
|
+
raise TypeError
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def mask_nan(cls, x: npt.NDArray[np.float64]) -> npt.NDArray[np.bool_]:
|
|
74
|
+
return np.isnan(x)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_windcube_data(
|
|
78
|
+
cls,
|
|
79
|
+
data: Sequence[str]
|
|
80
|
+
| Sequence[Path]
|
|
81
|
+
| Sequence[bytes]
|
|
82
|
+
| Sequence[BufferedIOBase],
|
|
83
|
+
) -> Stare:
|
|
84
|
+
raws = doppy.raw.WindCubeFixed.from_srcs(data)
|
|
85
|
+
raw = (
|
|
86
|
+
doppy.raw.WindCubeFixed.merge(raws).sorted_by_time().nan_profiles_removed()
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
wavelength = defaults.WindCube.wavelength
|
|
90
|
+
beta = _compute_beta(
|
|
91
|
+
snr=raw.cnr,
|
|
92
|
+
radial_distance=raw.radial_distance,
|
|
93
|
+
wavelength=wavelength,
|
|
94
|
+
beam_energy=defaults.WindCube.beam_energy,
|
|
95
|
+
receiver_bandwidth=defaults.WindCube.receiver_bandwidth,
|
|
96
|
+
focus=defaults.WindCube.focus,
|
|
97
|
+
effective_diameter=defaults.WindCube.effective_diameter,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
mask_beta = _compute_noise_mask_for_windcube(raw)
|
|
101
|
+
mask_radial_velocity = detect_wind_noise(
|
|
102
|
+
raw.radial_velocity, raw.radial_distance, mask_beta
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return cls(
|
|
106
|
+
time=raw.time,
|
|
107
|
+
radial_distance=raw.radial_distance,
|
|
108
|
+
elevation=raw.elevation,
|
|
109
|
+
beta=beta,
|
|
110
|
+
snr=raw.cnr,
|
|
111
|
+
radial_velocity=raw.radial_velocity,
|
|
112
|
+
mask_beta=mask_beta,
|
|
113
|
+
mask_radial_velocity=mask_radial_velocity,
|
|
114
|
+
wavelength=wavelength,
|
|
115
|
+
system_id=raw.system_id,
|
|
116
|
+
ray_info=RayAccumulationTime(raw.ray_accumulation_time.astype(float)),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def from_halo_data(
|
|
121
|
+
cls,
|
|
122
|
+
data: Sequence[str]
|
|
123
|
+
| Sequence[Path]
|
|
124
|
+
| Sequence[bytes]
|
|
125
|
+
| Sequence[BufferedIOBase],
|
|
126
|
+
data_bg: Sequence[str]
|
|
127
|
+
| Sequence[Path]
|
|
128
|
+
| Sequence[tuple[bytes, str]]
|
|
129
|
+
| Sequence[tuple[BufferedIOBase, str]],
|
|
130
|
+
bg_correction_method: options.BgCorrectionMethod,
|
|
131
|
+
) -> Stare:
|
|
132
|
+
raws = doppy.raw.HaloHpl.from_srcs(data)
|
|
133
|
+
|
|
134
|
+
if len(raws) == 0:
|
|
135
|
+
raise doppy.exceptions.NoDataError("HaloHpl data missing")
|
|
136
|
+
|
|
137
|
+
raw = (
|
|
138
|
+
doppy.raw.HaloHpl.merge(_select_raws_for_stare(raws))
|
|
139
|
+
.sorted_by_time()
|
|
140
|
+
.non_strictly_increasing_timesteps_removed()
|
|
141
|
+
.nans_removed()
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
bgs = doppy.raw.HaloBg.from_srcs(data_bg)
|
|
145
|
+
bgs = [bg[:, : raw.header.ngates] for bg in bgs]
|
|
146
|
+
bgs_stare = [bg for bg in bgs if bg.ngates == raw.header.ngates]
|
|
147
|
+
|
|
148
|
+
if len(bgs_stare) == 0:
|
|
149
|
+
raise doppy.exceptions.NoDataError("Background data missing")
|
|
150
|
+
|
|
151
|
+
bg = (
|
|
152
|
+
doppy.raw.HaloBg.merge(bgs_stare)
|
|
153
|
+
.sorted_by_time()
|
|
154
|
+
.non_strictly_increasing_timesteps_removed()
|
|
155
|
+
)
|
|
156
|
+
raw, intensity_bg_corrected = _correct_background(raw, bg, bg_correction_method)
|
|
157
|
+
if len(raw.time) == 0:
|
|
158
|
+
raise doppy.exceptions.NoDataError("No matching data and bg files")
|
|
159
|
+
intensity_noise_bias_corrected = _correct_intensity_noise_bias(
|
|
160
|
+
raw, intensity_bg_corrected
|
|
161
|
+
)
|
|
162
|
+
wavelength = defaults.Halo.wavelength
|
|
163
|
+
|
|
164
|
+
beta = _compute_beta(
|
|
165
|
+
snr=intensity_noise_bias_corrected - 1,
|
|
166
|
+
radial_distance=raw.radial_distance,
|
|
167
|
+
wavelength=wavelength,
|
|
168
|
+
beam_energy=defaults.Halo.beam_energy,
|
|
169
|
+
receiver_bandwidth=defaults.Halo.receiver_bandwidth,
|
|
170
|
+
focus=raw.header.focus_range,
|
|
171
|
+
effective_diameter=defaults.Halo.effective_diameter,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
mask_beta = _compute_noise_mask(
|
|
175
|
+
intensity_noise_bias_corrected, raw.radial_velocity, raw.radial_distance
|
|
176
|
+
)
|
|
177
|
+
mask_radial_velocity = detect_wind_noise(
|
|
178
|
+
raw.radial_velocity, raw.radial_distance, mask_beta
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return cls(
|
|
182
|
+
time=raw.time,
|
|
183
|
+
radial_distance=raw.radial_distance,
|
|
184
|
+
elevation=raw.elevation,
|
|
185
|
+
beta=beta,
|
|
186
|
+
snr=intensity_noise_bias_corrected - 1,
|
|
187
|
+
radial_velocity=raw.radial_velocity,
|
|
188
|
+
mask_beta=mask_beta,
|
|
189
|
+
mask_radial_velocity=mask_radial_velocity,
|
|
190
|
+
wavelength=wavelength,
|
|
191
|
+
system_id=raw.header.system_id,
|
|
192
|
+
ray_info=PulsesPerRay(raw.header.pulses_per_ray),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def write_to_netcdf(self, filename: str | Path) -> None:
|
|
196
|
+
with doppy.netcdf.Dataset(filename) as nc:
|
|
197
|
+
nc.add_dimension("time")
|
|
198
|
+
nc.add_dimension("range")
|
|
199
|
+
nc.add_time(
|
|
200
|
+
name="time",
|
|
201
|
+
dimensions=("time",),
|
|
202
|
+
standard_name="time",
|
|
203
|
+
long_name="Time UTC",
|
|
204
|
+
data=self.time,
|
|
205
|
+
dtype="f8",
|
|
206
|
+
)
|
|
207
|
+
nc.add_variable(
|
|
208
|
+
name="range",
|
|
209
|
+
dimensions=("range",),
|
|
210
|
+
units="m",
|
|
211
|
+
data=self.radial_distance,
|
|
212
|
+
dtype="f4",
|
|
213
|
+
)
|
|
214
|
+
nc.add_variable(
|
|
215
|
+
name="elevation",
|
|
216
|
+
dimensions=("time",),
|
|
217
|
+
units="degrees",
|
|
218
|
+
data=self.elevation,
|
|
219
|
+
dtype="f4",
|
|
220
|
+
long_name="elevation from horizontal",
|
|
221
|
+
)
|
|
222
|
+
nc.add_variable(
|
|
223
|
+
name="beta_raw",
|
|
224
|
+
dimensions=("time", "range"),
|
|
225
|
+
units="sr-1 m-1",
|
|
226
|
+
data=self.beta,
|
|
227
|
+
dtype="f4",
|
|
228
|
+
)
|
|
229
|
+
nc.add_variable(
|
|
230
|
+
name="beta",
|
|
231
|
+
dimensions=("time", "range"),
|
|
232
|
+
units="sr-1 m-1",
|
|
233
|
+
data=self.beta,
|
|
234
|
+
dtype="f4",
|
|
235
|
+
mask=self.mask_beta,
|
|
236
|
+
)
|
|
237
|
+
nc.add_variable(
|
|
238
|
+
name="v",
|
|
239
|
+
dimensions=("time", "range"),
|
|
240
|
+
units="m s-1",
|
|
241
|
+
long_name="Doppler velocity",
|
|
242
|
+
data=self.radial_velocity,
|
|
243
|
+
dtype="f4",
|
|
244
|
+
mask=self.mask_radial_velocity,
|
|
245
|
+
)
|
|
246
|
+
nc.add_scalar_variable(
|
|
247
|
+
name="wavelength",
|
|
248
|
+
units="m",
|
|
249
|
+
standard_name="radiation_wavelength",
|
|
250
|
+
data=self.wavelength,
|
|
251
|
+
dtype="f4",
|
|
252
|
+
)
|
|
253
|
+
match self.ray_info:
|
|
254
|
+
case RayAccumulationTime(value):
|
|
255
|
+
nc.add_scalar_variable(
|
|
256
|
+
name="ray_accumulation_time",
|
|
257
|
+
units="s",
|
|
258
|
+
long_name="ray accumulation time",
|
|
259
|
+
data=value,
|
|
260
|
+
dtype="f4",
|
|
261
|
+
)
|
|
262
|
+
case PulsesPerRay(value):
|
|
263
|
+
nc.add_scalar_variable(
|
|
264
|
+
name="pulses_per_ray",
|
|
265
|
+
units="1",
|
|
266
|
+
long_name="pulses per ray",
|
|
267
|
+
data=value,
|
|
268
|
+
dtype="u4",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
nc.add_attribute("serial_number", self.system_id)
|
|
272
|
+
nc.add_attribute("doppy_version", doppy.__version__)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _compute_noise_mask_for_windcube(
|
|
276
|
+
raw: doppy.raw.WindCubeFixed,
|
|
277
|
+
) -> npt.NDArray[np.bool_]:
|
|
278
|
+
if np.any(np.isnan(raw.cnr)) or np.any(np.isnan(raw.radial_velocity)):
|
|
279
|
+
raise ValueError("Unexpected nans in crn or radial_velocity")
|
|
280
|
+
|
|
281
|
+
mask = _mask_with_cnr_norm_dist(raw.cnr) | (np.abs(raw.radial_velocity) > 30)
|
|
282
|
+
|
|
283
|
+
cnr = raw.cnr.copy()
|
|
284
|
+
cnr[mask] = np.finfo(float).eps
|
|
285
|
+
cnr_filt = np.array(median_filter(cnr, size=(3, 3)), dtype=np.float64)
|
|
286
|
+
rel_diff = np.abs(cnr - cnr_filt) / np.abs(cnr)
|
|
287
|
+
diff_mask = rel_diff > 0.25
|
|
288
|
+
|
|
289
|
+
mask = mask | diff_mask
|
|
290
|
+
|
|
291
|
+
return np.array(mask, dtype=np.bool_)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _mask_with_cnr_norm_dist(cnr: npt.NDArray[np.float64]) -> npt.NDArray[np.bool_]:
|
|
295
|
+
th_trunc = -5.5
|
|
296
|
+
std_factor = 2
|
|
297
|
+
log_cnr = np.log(cnr)
|
|
298
|
+
log_cnr_trunc = log_cnr[log_cnr < th_trunc]
|
|
299
|
+
th_trunc_fit = np.percentile(log_cnr_trunc, 90)
|
|
300
|
+
log_cnr_for_fit = log_cnr_trunc[log_cnr_trunc < th_trunc_fit]
|
|
301
|
+
mean, std = scipy.stats.norm.fit(log_cnr_for_fit)
|
|
302
|
+
return np.array(np.log(cnr) < (mean + std_factor * std), dtype=np.bool_)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _compute_noise_mask(
|
|
306
|
+
intensity: npt.NDArray[np.float64],
|
|
307
|
+
radial_velocity: npt.NDArray[np.float64],
|
|
308
|
+
radial_distance: npt.NDArray[np.float64],
|
|
309
|
+
) -> npt.NDArray[np.bool_]:
|
|
310
|
+
intensity_mean_mask = uniform_filter(intensity, size=(21, 3)) < 1.0025
|
|
311
|
+
velocity_abs_mean_mask = uniform_filter(np.abs(radial_velocity), size=(21, 3)) > 2
|
|
312
|
+
THREE_PULSES_LENGTH = 90
|
|
313
|
+
near_instrument_noise_mask = np.zeros_like(intensity, dtype=np.bool_)
|
|
314
|
+
near_instrument_noise_mask[:, radial_distance < THREE_PULSES_LENGTH] = True
|
|
315
|
+
low_intensity_mask = intensity < 1
|
|
316
|
+
return np.array(
|
|
317
|
+
(intensity_mean_mask & velocity_abs_mean_mask)
|
|
318
|
+
| near_instrument_noise_mask
|
|
319
|
+
| low_intensity_mask,
|
|
320
|
+
dtype=np.bool_,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _compute_beta(
|
|
325
|
+
snr: npt.NDArray[np.float64],
|
|
326
|
+
radial_distance: npt.NDArray[np.float64],
|
|
327
|
+
wavelength: float,
|
|
328
|
+
beam_energy: float,
|
|
329
|
+
receiver_bandwidth: float,
|
|
330
|
+
focus: float,
|
|
331
|
+
effective_diameter: float,
|
|
332
|
+
) -> npt.NDArray[np.float64]:
|
|
333
|
+
"""
|
|
334
|
+
Parameters
|
|
335
|
+
----------
|
|
336
|
+
snr
|
|
337
|
+
for halo: intensity - 1
|
|
338
|
+
radial_distance
|
|
339
|
+
distance from the instrument
|
|
340
|
+
focus
|
|
341
|
+
focal length of the telescope for the transmitter and receiver
|
|
342
|
+
wavelength
|
|
343
|
+
laser wavelength
|
|
344
|
+
|
|
345
|
+
Local variables
|
|
346
|
+
---------------
|
|
347
|
+
eta
|
|
348
|
+
detector quantum efficiency
|
|
349
|
+
E
|
|
350
|
+
beam energy
|
|
351
|
+
nu
|
|
352
|
+
optical frequency
|
|
353
|
+
h
|
|
354
|
+
planc's constant
|
|
355
|
+
c
|
|
356
|
+
speed of light
|
|
357
|
+
B
|
|
358
|
+
receiver bandwidth
|
|
359
|
+
|
|
360
|
+
References
|
|
361
|
+
----------
|
|
362
|
+
Methodology for deriving the telescope focus function and
|
|
363
|
+
its uncertainty for a heterodyne pulsed Doppler lidar
|
|
364
|
+
authors: Pyry Pentikäinen, Ewan James O'Connor,
|
|
365
|
+
Antti Juhani Manninen, and Pablo Ortiz-Amezcua
|
|
366
|
+
doi: https://doi.org/10.5194/amt-13-2849-2020
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
h = scipy.constants.Planck
|
|
370
|
+
c = scipy.constants.speed_of_light
|
|
371
|
+
eta = 1
|
|
372
|
+
E = beam_energy
|
|
373
|
+
B = receiver_bandwidth
|
|
374
|
+
nu = c / wavelength
|
|
375
|
+
A_e = _compute_effective_receiver_energy(
|
|
376
|
+
radial_distance, wavelength, focus, effective_diameter
|
|
377
|
+
)
|
|
378
|
+
beta = 2 * h * nu * B * radial_distance**2 * snr / (eta * c * E * A_e)
|
|
379
|
+
return np.array(beta, dtype=np.float64)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _compute_effective_receiver_energy(
|
|
383
|
+
radial_distance: npt.NDArray[np.float64],
|
|
384
|
+
wavelength: float,
|
|
385
|
+
focus: float,
|
|
386
|
+
effective_diameter: float,
|
|
387
|
+
) -> npt.NDArray[np.float64]:
|
|
388
|
+
"""
|
|
389
|
+
NOTE
|
|
390
|
+
----
|
|
391
|
+
Using uncalibrated values from https://doi.org/10.5194/amt-13-2849-2020
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
Parameters
|
|
395
|
+
----------
|
|
396
|
+
radial_distance
|
|
397
|
+
distance from the instrument
|
|
398
|
+
focus
|
|
399
|
+
effective focal length of the telescope for the transmitter and receiver
|
|
400
|
+
wavelength
|
|
401
|
+
laser wavelength
|
|
402
|
+
"""
|
|
403
|
+
D = effective_diameter
|
|
404
|
+
return np.array(
|
|
405
|
+
np.pi
|
|
406
|
+
* D**2
|
|
407
|
+
/ (
|
|
408
|
+
4
|
|
409
|
+
* (
|
|
410
|
+
1
|
|
411
|
+
+ (np.pi * D**2 / (4 * wavelength * radial_distance)) ** 2
|
|
412
|
+
* (1 - radial_distance / focus) ** 2
|
|
413
|
+
)
|
|
414
|
+
),
|
|
415
|
+
dtype=np.float64,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _correct_intensity_noise_bias(
|
|
420
|
+
raw: doppy.raw.HaloHpl, intensity: npt.NDArray[np.float64]
|
|
421
|
+
) -> npt.NDArray[np.float64]:
|
|
422
|
+
"""
|
|
423
|
+
Parameters
|
|
424
|
+
----------
|
|
425
|
+
intensity:
|
|
426
|
+
intensity after background correction
|
|
427
|
+
"""
|
|
428
|
+
noise_mask = _locate_noise(intensity)
|
|
429
|
+
# Ignore lower gates
|
|
430
|
+
noise_mask[:, raw.radial_distance <= 90] = False
|
|
431
|
+
|
|
432
|
+
A_ = np.concatenate(
|
|
433
|
+
(
|
|
434
|
+
raw.radial_distance[:, np.newaxis],
|
|
435
|
+
np.ones((len(raw.radial_distance), 1)),
|
|
436
|
+
),
|
|
437
|
+
axis=1,
|
|
438
|
+
)[np.newaxis, :, :]
|
|
439
|
+
A = np.tile(
|
|
440
|
+
A_,
|
|
441
|
+
(len(intensity), 1, 1),
|
|
442
|
+
)
|
|
443
|
+
A_noise = np.tile(noise_mask[:, :, np.newaxis], (1, 1, 2))
|
|
444
|
+
A[~A_noise] = 0
|
|
445
|
+
intensity_ = intensity.copy()
|
|
446
|
+
intensity_[~noise_mask] = 0
|
|
447
|
+
|
|
448
|
+
A_pinv = np.linalg.pinv(A)
|
|
449
|
+
x = A_pinv @ intensity_[:, :, np.newaxis]
|
|
450
|
+
noise_fit = (A_ @ x).squeeze(axis=2)
|
|
451
|
+
return np.array(intensity / noise_fit, dtype=np.float64)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _locate_noise(intensity: npt.NDArray[np.float64]) -> npt.NDArray[np.bool_]:
|
|
455
|
+
"""
|
|
456
|
+
Returns
|
|
457
|
+
-------
|
|
458
|
+
boolean array M
|
|
459
|
+
where M[i,j] = True if intensity[i,j] contains only noise
|
|
460
|
+
and False otherwise
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
INTENSITY_THRESHOLD = 1.008
|
|
464
|
+
MEDIAN_KERNEL_THRESHOLD = 1.002
|
|
465
|
+
GAUSSIAN_THRESHOLD = 0.02
|
|
466
|
+
|
|
467
|
+
intensity_normalised = intensity / np.median(intensity, axis=1)[:, np.newaxis]
|
|
468
|
+
intensity_mask = intensity_normalised > INTENSITY_THRESHOLD
|
|
469
|
+
|
|
470
|
+
median_mask = (
|
|
471
|
+
scipy.signal.medfilt2d(intensity_normalised, kernel_size=5)
|
|
472
|
+
> MEDIAN_KERNEL_THRESHOLD
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
gaussian = scipy.ndimage.gaussian_filter(
|
|
476
|
+
(intensity_mask | median_mask).astype(np.float64), sigma=8, radius=16
|
|
477
|
+
)
|
|
478
|
+
gaussian_mask = gaussian > GAUSSIAN_THRESHOLD
|
|
479
|
+
|
|
480
|
+
return np.array(~(intensity_mask | median_mask | gaussian_mask), dtype=np.bool_)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _correct_background(
|
|
484
|
+
raw: doppy.raw.HaloHpl,
|
|
485
|
+
bg: doppy.raw.HaloBg,
|
|
486
|
+
method: options.BgCorrectionMethod,
|
|
487
|
+
) -> Tuple[doppy.raw.HaloHpl, npt.NDArray[np.float64]]:
|
|
488
|
+
"""
|
|
489
|
+
Returns
|
|
490
|
+
-------
|
|
491
|
+
raw_with_bg:
|
|
492
|
+
Same as input raw: HaloHpl, but the profiles that does not corresponding
|
|
493
|
+
background measurement have been removed.
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
intensity_bg_corrected:
|
|
497
|
+
intensity = SNR + 1 = (A_0 * P_0(z)) / (A_bg * P_bg(z)), z = radial_distance
|
|
498
|
+
The measured background signal P_bg contains usually lots of noise that shows as
|
|
499
|
+
vertical stripes in intensity plots. In bg corrected intensity, P_bg is replaced
|
|
500
|
+
with corrected background profile that should represent the noise floor
|
|
501
|
+
more accurately
|
|
502
|
+
"""
|
|
503
|
+
bg_relevant = _select_relevant_background_profiles(bg, raw.time)
|
|
504
|
+
match method:
|
|
505
|
+
case options.BgCorrectionMethod.FIT:
|
|
506
|
+
bg_signal_corrected = _correct_background_by_fitting(
|
|
507
|
+
bg_relevant, raw.radial_distance, fit_method=None
|
|
508
|
+
)
|
|
509
|
+
case options.BgCorrectionMethod.MEAN:
|
|
510
|
+
raise NotImplementedError
|
|
511
|
+
case options.BgCorrectionMethod.PRE_COMPUTED:
|
|
512
|
+
raise NotImplementedError
|
|
513
|
+
|
|
514
|
+
raw2bg = np.searchsorted(bg_relevant.time, raw.time, side="right") - 1
|
|
515
|
+
raw_with_bg = raw[raw2bg >= 0]
|
|
516
|
+
raw2bg = raw2bg[raw2bg >= 0]
|
|
517
|
+
raw_bg_original = bg_relevant.signal[raw2bg]
|
|
518
|
+
raw_bg_corrected = bg_signal_corrected[raw2bg]
|
|
519
|
+
|
|
520
|
+
intensity_bg_corrected = raw_with_bg.intensity * raw_bg_original / raw_bg_corrected
|
|
521
|
+
return raw_with_bg, intensity_bg_corrected
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _correct_background_by_fitting(
|
|
525
|
+
bg: doppy.raw.HaloBg,
|
|
526
|
+
radial_distance: npt.NDArray[np.float64],
|
|
527
|
+
fit_method: options.BgFitMethod | None,
|
|
528
|
+
) -> npt.NDArray[np.float64]:
|
|
529
|
+
clusters = _cluster_background_profiles(bg.signal, radial_distance)
|
|
530
|
+
signal_correcred = np.zeros_like(bg.signal)
|
|
531
|
+
for cluster in set(clusters):
|
|
532
|
+
signal_correcred[clusters == cluster] = _fit_background(
|
|
533
|
+
bg[clusters == cluster], radial_distance, fit_method
|
|
534
|
+
)
|
|
535
|
+
return signal_correcred
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _fit_background(
|
|
539
|
+
bg: doppy.raw.HaloBg,
|
|
540
|
+
radial_distance: npt.NDArray[np.float64],
|
|
541
|
+
fit_method: options.BgFitMethod | None,
|
|
542
|
+
) -> npt.NDArray[np.float64]:
|
|
543
|
+
if fit_method is None:
|
|
544
|
+
fit_method = _infer_fit_type(bg.signal, radial_distance)
|
|
545
|
+
match fit_method:
|
|
546
|
+
case options.BgFitMethod.LIN:
|
|
547
|
+
return _linear_fit(bg.signal, radial_distance)
|
|
548
|
+
case options.BgFitMethod.EXP:
|
|
549
|
+
return _exponential_fit(bg.signal, radial_distance)
|
|
550
|
+
case options.BgFitMethod.EXPLIN:
|
|
551
|
+
return _exponential_linear_fit(bg.signal, radial_distance)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _lin_func(
|
|
555
|
+
x: npt.NDArray[np.float64], radial_distance: npt.NDArray[np.float64]
|
|
556
|
+
) -> npt.NDArray[np.float64]:
|
|
557
|
+
return np.array(x[0] * radial_distance + x[1], dtype=np.float64)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _exp_func(
|
|
561
|
+
x: npt.NDArray[np.float64], radial_distance: npt.NDArray[np.float64]
|
|
562
|
+
) -> npt.NDArray[np.float64]:
|
|
563
|
+
return np.array(x[0] * np.exp(x[1] * radial_distance ** x[2]), dtype=np.float64)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _explin_func(
|
|
567
|
+
x: npt.NDArray[np.float64], radial_distance: npt.NDArray[np.float64]
|
|
568
|
+
) -> npt.NDArray[np.float64]:
|
|
569
|
+
return np.array(
|
|
570
|
+
_exp_func(x[:3], radial_distance) + _lin_func(x[3:], radial_distance),
|
|
571
|
+
dtype=np.float64,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _infer_fit_type(
|
|
576
|
+
bg_signal: npt.NDArray[np.float64], radial_distance: npt.NDArray[np.float64]
|
|
577
|
+
) -> options.BgFitMethod:
|
|
578
|
+
peaks = _detect_peaks(bg_signal, radial_distance)
|
|
579
|
+
dist_mask = (90 < radial_distance) & (radial_distance < 8000)
|
|
580
|
+
mask = dist_mask & ~peaks
|
|
581
|
+
|
|
582
|
+
scale = np.median(bg_signal, axis=1)[:, np.newaxis]
|
|
583
|
+
|
|
584
|
+
rdist = radial_distance[np.newaxis][:, mask]
|
|
585
|
+
|
|
586
|
+
signal = (bg_signal / scale)[:, mask]
|
|
587
|
+
|
|
588
|
+
def lin_func_rss(x: npt.NDArray[np.float64]) -> np.float64:
|
|
589
|
+
return np.float64(((signal - _lin_func(x, rdist)) ** 2).sum())
|
|
590
|
+
|
|
591
|
+
def exp_func_rss(x: npt.NDArray[np.float64]) -> np.float64:
|
|
592
|
+
return np.float64(((signal - _exp_func(x, rdist)) ** 2).sum())
|
|
593
|
+
|
|
594
|
+
def explin_func_rss(x: npt.NDArray[np.float64]) -> np.float64:
|
|
595
|
+
return np.float64(((signal - _explin_func(x, rdist)) ** 2).sum())
|
|
596
|
+
|
|
597
|
+
method = "Nelder-Mead"
|
|
598
|
+
res_lin = scipy.optimize.minimize(
|
|
599
|
+
lin_func_rss, [1e-5, 1], method=method, options={"maxiter": 2 * 600}
|
|
600
|
+
)
|
|
601
|
+
res_exp = scipy.optimize.minimize(
|
|
602
|
+
exp_func_rss, [1, -1, -1], method=method, options={"maxiter": 3 * 600}
|
|
603
|
+
)
|
|
604
|
+
res_explin = scipy.optimize.minimize(
|
|
605
|
+
explin_func_rss, [1, -1, -1, 0, 0], method=method, options={"maxiter": 5 * 600}
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
fit_lin = _lin_func(res_lin.x, rdist)
|
|
609
|
+
fit_exp = _exp_func(res_exp.x, rdist)
|
|
610
|
+
fit_explin = _explin_func(res_explin.x, rdist)
|
|
611
|
+
|
|
612
|
+
lin_rss = ((signal - fit_lin) ** 2).sum()
|
|
613
|
+
exp_rss = ((signal - fit_exp) ** 2).sum()
|
|
614
|
+
explin_rss = ((signal - fit_explin) ** 2).sum()
|
|
615
|
+
|
|
616
|
+
#
|
|
617
|
+
if exp_rss / lin_rss < 0.95 or explin_rss / lin_rss < 0.95:
|
|
618
|
+
if (exp_rss - explin_rss) / lin_rss > 0.05:
|
|
619
|
+
return options.BgFitMethod.EXPLIN
|
|
620
|
+
else:
|
|
621
|
+
return options.BgFitMethod.EXP
|
|
622
|
+
else:
|
|
623
|
+
return options.BgFitMethod.LIN
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _detect_peaks(
|
|
627
|
+
background_signal: npt.NDArray[np.float64], radial_distance: npt.NDArray[np.float64]
|
|
628
|
+
) -> npt.NDArray[np.bool_]:
|
|
629
|
+
"""
|
|
630
|
+
background_signal: dim = (time,range)
|
|
631
|
+
radial_distance: dim = (range,)
|
|
632
|
+
|
|
633
|
+
Returns a boolean mask, dim = (range, ), where True denotes locations of peaks
|
|
634
|
+
that should be ignored in fitting
|
|
635
|
+
"""
|
|
636
|
+
scale = np.median(background_signal, axis=1)[:, np.newaxis]
|
|
637
|
+
bg = background_signal / scale
|
|
638
|
+
return _set_adjacent_true(
|
|
639
|
+
np.concatenate(
|
|
640
|
+
(
|
|
641
|
+
np.array([False]),
|
|
642
|
+
np.diff(np.diff(bg.mean(axis=0))) < -0.01,
|
|
643
|
+
np.array([False]),
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _set_adjacent_true(arr: npt.NDArray[np.bool_]) -> npt.NDArray[np.bool_]:
|
|
650
|
+
temp = np.pad(arr, (1, 1), mode="constant")
|
|
651
|
+
temp[:-2] |= arr
|
|
652
|
+
temp[2:] |= arr
|
|
653
|
+
return temp[1:-1]
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _linear_fit(
|
|
657
|
+
bg_signal: npt.NDArray[np.float64], radial_distance: npt.NDArray[np.float64]
|
|
658
|
+
) -> npt.NDArray[np.float64]:
|
|
659
|
+
dist_mask = 90 < radial_distance
|
|
660
|
+
peaks = _detect_peaks(bg_signal, radial_distance)
|
|
661
|
+
mask = dist_mask & ~peaks
|
|
662
|
+
|
|
663
|
+
scale = np.median(bg_signal, axis=1)[:, np.newaxis]
|
|
664
|
+
rdist_fit = radial_distance[np.newaxis][:, mask]
|
|
665
|
+
signal_fit = (bg_signal / scale)[:, mask]
|
|
666
|
+
|
|
667
|
+
A = np.tile(
|
|
668
|
+
np.concatenate((rdist_fit, np.ones_like(rdist_fit))).T, (signal_fit.shape[0], 1)
|
|
669
|
+
)
|
|
670
|
+
x = np.linalg.pinv(A) @ signal_fit.reshape(-1, 1)
|
|
671
|
+
fit = (
|
|
672
|
+
np.concatenate(
|
|
673
|
+
(radial_distance[:, np.newaxis], np.ones((radial_distance.shape[0], 1))),
|
|
674
|
+
axis=1,
|
|
675
|
+
)
|
|
676
|
+
@ x
|
|
677
|
+
).T
|
|
678
|
+
return np.array(fit * scale, dtype=np.float64)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _exponential_fit(
|
|
682
|
+
bg_signal: npt.NDArray[np.float64], radial_distance: npt.NDArray[np.float64]
|
|
683
|
+
) -> npt.NDArray[np.float64]:
|
|
684
|
+
dist_mask = 90 < radial_distance
|
|
685
|
+
peaks = _detect_peaks(bg_signal, radial_distance)
|
|
686
|
+
mask = dist_mask & ~peaks
|
|
687
|
+
scale = np.median(bg_signal, axis=1)[:, np.newaxis]
|
|
688
|
+
rdist_fit = radial_distance[np.newaxis][:, mask]
|
|
689
|
+
signal_fit = (bg_signal / scale)[:, mask]
|
|
690
|
+
|
|
691
|
+
def exp_func_rss(x: npt.NDArray[np.float64]) -> np.float64:
|
|
692
|
+
return np.float64(((signal_fit - _exp_func(x, rdist_fit)) ** 2).sum())
|
|
693
|
+
|
|
694
|
+
result = scipy.optimize.minimize(
|
|
695
|
+
exp_func_rss, [1, -1, -1], method="Nelder-Mead", options={"maxiter": 3 * 600}
|
|
696
|
+
)
|
|
697
|
+
fit = _exp_func(result.x, radial_distance)[np.newaxis, :]
|
|
698
|
+
return np.array(fit * scale, dtype=np.float64)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _exponential_linear_fit(
|
|
702
|
+
bg_signal: npt.NDArray[np.float64], radial_distance: npt.NDArray[np.float64]
|
|
703
|
+
) -> npt.NDArray[np.float64]:
|
|
704
|
+
dist_mask = 90 < radial_distance
|
|
705
|
+
peaks = _detect_peaks(bg_signal, radial_distance)
|
|
706
|
+
mask = dist_mask & ~peaks
|
|
707
|
+
scale = np.median(bg_signal, axis=1)[:, np.newaxis]
|
|
708
|
+
rdist_fit = radial_distance[np.newaxis][:, mask]
|
|
709
|
+
signal_fit = (bg_signal / scale)[:, mask]
|
|
710
|
+
|
|
711
|
+
def explin_func_rss(x: npt.NDArray[np.float64]) -> np.float64:
|
|
712
|
+
return np.float64(((signal_fit - _explin_func(x, rdist_fit)) ** 2).sum())
|
|
713
|
+
|
|
714
|
+
result = scipy.optimize.minimize(
|
|
715
|
+
explin_func_rss,
|
|
716
|
+
[1, -1, -1, 0, 0],
|
|
717
|
+
method="Nelder-Mead",
|
|
718
|
+
options={"maxiter": 5 * 600},
|
|
719
|
+
)
|
|
720
|
+
fit = _explin_func(result.x, radial_distance)[np.newaxis, :]
|
|
721
|
+
return np.array(fit * scale, dtype=np.float64)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _select_raws_for_stare(
|
|
725
|
+
raws: Sequence[doppy.raw.HaloHpl],
|
|
726
|
+
) -> Sequence[doppy.raw.HaloHpl]:
|
|
727
|
+
if len(raws) == 0:
|
|
728
|
+
raise doppy.exceptions.NoDataError("Expected at least one raw file")
|
|
729
|
+
counter_dd: DefaultDict[tuple[int, int, int], int] = defaultdict(int)
|
|
730
|
+
for raw in raws:
|
|
731
|
+
els, counts = np.unique(np.rint(raw.elevation).astype(int), return_counts=True)
|
|
732
|
+
ngates = len(raw.radial_distance)
|
|
733
|
+
for el, count in zip(els, counts):
|
|
734
|
+
counter_dd[(ngates, el, raw.header.mergeable_hash())] += count
|
|
735
|
+
counter = dict(counter_dd)
|
|
736
|
+
elevation_angle_lb = 75
|
|
737
|
+
elevation_angle_ub = 90
|
|
738
|
+
counter_allowed = {
|
|
739
|
+
(ngates, el, mhash): count
|
|
740
|
+
for (ngates, el, mhash), count in counter.items()
|
|
741
|
+
if (elevation_angle_lb <= el) and (el <= elevation_angle_ub)
|
|
742
|
+
}
|
|
743
|
+
if not counter_allowed:
|
|
744
|
+
raise doppy.exceptions.NoDataError("No raw data suitable for stare product")
|
|
745
|
+
|
|
746
|
+
(ngates, elevation, mhash), count = max(counter_allowed.items(), key=lambda x: x[1])
|
|
747
|
+
raws_selected = []
|
|
748
|
+
for raw in raws:
|
|
749
|
+
if len(raw.radial_distance) == ngates and (
|
|
750
|
+
raw.header.mergeable_hash() == mhash
|
|
751
|
+
):
|
|
752
|
+
select_profiles = np.isclose(raw.elevation, elevation, atol=1)
|
|
753
|
+
raw_selected = raw[select_profiles]
|
|
754
|
+
if raw_selected.time.size != 0:
|
|
755
|
+
raws_selected.append(raw_selected)
|
|
756
|
+
return raws_selected
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _time2bg_time(
|
|
760
|
+
time: npt.NDArray[np.datetime64], bg_time: npt.NDArray[np.datetime64]
|
|
761
|
+
) -> npt.NDArray[np.int64]:
|
|
762
|
+
return np.searchsorted(bg_time, time, side="right") - 1
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _select_relevant_background_profiles(
|
|
766
|
+
bg: doppy.raw.HaloBg, time: npt.NDArray[np.datetime64]
|
|
767
|
+
) -> doppy.raw.HaloBg:
|
|
768
|
+
"""
|
|
769
|
+
expects bg.time to be sorted
|
|
770
|
+
"""
|
|
771
|
+
time2bg_time = _time2bg_time(time, bg.time)
|
|
772
|
+
|
|
773
|
+
relevant_indices = list(set(time2bg_time[time2bg_time >= 0]))
|
|
774
|
+
bg_ind = np.arange(bg.time.size)
|
|
775
|
+
is_relevant = np.isin(bg_ind, relevant_indices)
|
|
776
|
+
return bg[is_relevant]
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _cluster_background_profiles(
|
|
780
|
+
background_signal: npt.NDArray[np.float64], radial_distance: npt.NDArray[np.float64]
|
|
781
|
+
) -> npt.NDArray[np.int64]:
|
|
782
|
+
default_labels = np.zeros(len(background_signal), dtype=int)
|
|
783
|
+
if len(background_signal) < 2:
|
|
784
|
+
return default_labels
|
|
785
|
+
radial_distance_mask = (90 < radial_distance) & (radial_distance < 1500)
|
|
786
|
+
|
|
787
|
+
normalised_background_signal = background_signal / np.median(
|
|
788
|
+
background_signal, axis=1, keepdims=True
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
profile_median = np.median(
|
|
792
|
+
normalised_background_signal[:, radial_distance_mask], axis=1
|
|
793
|
+
)
|
|
794
|
+
kmeans = KMeans(n_clusters=2, n_init="auto").fit(profile_median[:, np.newaxis])
|
|
795
|
+
cluster_width = np.array([None, None])
|
|
796
|
+
for label in [0, 1]:
|
|
797
|
+
cluster = profile_median[kmeans.labels_ == label]
|
|
798
|
+
cluster_width[label] = np.max(cluster) - np.min(cluster)
|
|
799
|
+
cluster_distance = np.abs(
|
|
800
|
+
kmeans.cluster_centers_[0, 0] - kmeans.cluster_centers_[1, 0]
|
|
801
|
+
)
|
|
802
|
+
max_cluster_width = np.float64(np.max(cluster_width))
|
|
803
|
+
if np.isclose(max_cluster_width, 0):
|
|
804
|
+
return default_labels
|
|
805
|
+
if cluster_distance / max_cluster_width > 3:
|
|
806
|
+
return np.array(kmeans.labels_, dtype=np.int64)
|
|
807
|
+
return default_labels
|