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/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