s1-denoise 0.0.0.dev0__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.
@@ -0,0 +1,753 @@
1
+ # SPDX-FileCopyrightText: Aresys S.r.l. <info@aresys.it>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ Module implementing the ThermalNoise class for physics-based simulation of
6
+ Sentinel-1 thermal noise profiles.
7
+
8
+ Encapsulates the full noise modelling chain: noise power (from the L0N product or
9
+ the annotation), elevation antenna pattern, range spreading loss, PG correction,
10
+ processing gain, and noise normalization factor, the latter reconstructed from the
11
+ azimuth antenna pattern on the azimuth FFT grid. Supports both SLC and GRD processing
12
+ for TopSAR IW and EW modes.
13
+ """
14
+
15
+ import numpy as np
16
+ import math
17
+ import sys
18
+ from numpy.typing import NDArray
19
+ from scipy.constants import speed_of_light
20
+
21
+ from s1_denoise import R_REF
22
+ from s1_denoise.utils import db_to_linear, remove_outliers
23
+ from s1_denoise.logger import denoise_logger
24
+ from s1_denoise.auxiliary_files import AuxiliaryFiles
25
+ from s1_denoise.xml_reader import (
26
+ read_pg_correction_list,
27
+ read_noise_power_correction_from_annotation,
28
+ read_antenna_patterns,
29
+ closest_antenna_pattern_index,
30
+ extract_FR_info,
31
+ extract_AR_info,
32
+ build_orbit
33
+ )
34
+ from s1_denoise.noise_norm_factor import (
35
+ find_fft_size,
36
+ hamming,
37
+ calculate_window_norm_factor,
38
+ update_window_noise_norm,
39
+ get_start_indices,
40
+ get_look_overlap_samples
41
+ )
42
+
43
+ class ThermalNoise:
44
+ """
45
+ Thermal noise model of a single subswath and polarization of a Sentinel-1 L1 product.
46
+
47
+ Reads once all the burst-independent quantities (annotation parameters, auxiliary file values, antenna
48
+ patterns, PG corrections, orbit, noise power) and provides the methods to compute the simulated noise
49
+ range vector at a given azimuth time.
50
+
51
+ Before calling `compute_simulated_noise`, the caller must set the slant range axis with `set_range_values`,
52
+ set `rel_burst` and compute the shrink factor with `get_shrink_factor`.
53
+
54
+ Parameters
55
+ ----------
56
+ metadata_dict : dict
57
+ Product annotation parsed into a dictionary (see `xml_reader.read_metadata_from_safe_annotation`).
58
+ channel : S1Channel
59
+ Channel (polarization and swath) of the SAFE product, providing the parsed annotation XML tree.
60
+ aux_info : AuxiliaryFiles
61
+ Auxiliary files (AUX_PP1, AUX_CAL, AUX_INS) used for processing.
62
+ product_id : str
63
+ Product identifier used to select the AUX_PP1 parameters (e.g. ``"IW_SLC__1"``).
64
+ noise_power_from_l0 : dict, optional
65
+ Noise power extracted from the L0N product, mapping ``(swath, polarization)`` to
66
+ ``[azimuth_times, noise_powers]``. If None (default), the noise power is read from the noise list of
67
+ the product annotation.
68
+ subswath : str, optional
69
+ Subswath identifier (e.g. ``"IW1"``) for GRD products, where the annotation covers all subswaths.
70
+ If None (default), the product is treated as SLC and the swath of `channel` is used.
71
+
72
+ Attributes
73
+ ----------
74
+ is_grd : bool
75
+ True for GRD products, i.e. when `subswath` is given.
76
+ swath_id : str
77
+ Identifier of the processed subswath.
78
+ polarization : str
79
+ Polarization of the channel.
80
+ lines_per_burst : int
81
+ Number of lines per burst (1300 if annotated as 0).
82
+ samples_per_burst : int
83
+ Number of samples per burst.
84
+ look_bandwidth, processing_bandwidth : float
85
+ Azimuth look and processing bandwidths [Hz].
86
+ number_of_looks : int
87
+ Number of azimuth looks.
88
+ azi_window_coefficient : float
89
+ Azimuth Hamming window coefficient.
90
+ rg_processing_bandwidth : float
91
+ Range processing bandwidth [Hz].
92
+ radar_frequency : float
93
+ Radar carrier frequency [Hz].
94
+ velocities : list of float
95
+ Norm of the annotated orbit state vector velocities [m/s].
96
+ antenna_patterns : dict
97
+ Antenna pattern records of the subswath (see `xml_reader.read_antenna_patterns`).
98
+ pg_correction_factors : numpy.ndarray
99
+ Annotated PG product amplitudes.
100
+ pg_az_t : numpy.ndarray
101
+ Azimuth times of the PG correction factors, as PreciseDateTime.
102
+ noise_az_t : numpy.ndarray
103
+ Azimuth times of the retained noise power values.
104
+ noise_power_correction_factors : numpy.ndarray
105
+ Noise power values retained after the noise pulse discard.
106
+ outlier_detected : list
107
+ Discarded noise pulses, as ``[index, azimuth_time, noise_power]``.
108
+ range_over_sampling_factor : float
109
+ Ratio between the range sampling frequency after decimation and the range total bandwidth.
110
+ rel_burst : int or None
111
+ Index of the current burst (SLC) or antenna pattern block (GRD).
112
+ range_t : numpy.ndarray or None
113
+ Slant range times of the current noise vector samples [s].
114
+ range_distances : numpy.ndarray or None
115
+ Slant range distances of the current noise vector samples [m].
116
+ range_spread_loss : numpy.ndarray or None
117
+ Range spreading loss correction $(R_{ref} / R)^4$.
118
+ shrink_factor : float
119
+ TopSAR azimuth shrink factor, set by `get_shrink_factor`.
120
+ noise_norm_factor : float
121
+ Noise normalization factor, set by `reconstruct_noise_norm_factor`.
122
+ """
123
+
124
+ def __init__(self, metadata_dict, channel, aux_info: AuxiliaryFiles, product_id, noise_power_from_l0 = None, subswath = None):
125
+
126
+ self.metadata_dict = metadata_dict
127
+ self.channel = channel
128
+ self.annotation = channel.annotation
129
+ self.product_id = product_id
130
+
131
+ if subswath is None:
132
+ self.is_grd = False
133
+ self.swath_id = channel.swath_id # SLC case
134
+ else:
135
+ self.is_grd = True
136
+ self.swath_id = subswath # GRD case
137
+ self.polarization = channel.pol
138
+
139
+ # load and parse L1 annotation file
140
+ # product info
141
+ product = self.metadata_dict["product"]
142
+ self.mode = product['adsHeader']['mode']
143
+ self.product_type = product['adsHeader']['productType']
144
+ self.lines_per_burst = int(product["swathTiming"]["linesPerBurst"])
145
+ if self.lines_per_burst == 0:
146
+ self.lines_per_burst = 1300
147
+ self.samples_per_burst = int(product["swathTiming"]["samplesPerBurst"])
148
+ self.sentinel_type = product['adsHeader']['missionId']
149
+
150
+ # image annotation
151
+ image_annotation = product["imageAnnotation"]
152
+ self.az_dim = int(image_annotation["imageInformation"]["numberOfLines"])
153
+ self.apply_azi_ant_pattern = str(image_annotation["processingInformation"]["antennaAzimuthPatternApplied"]).strip().lower() == "true"
154
+ self.input_azi_spacing = 1 / float(image_annotation["imageInformation"]["azimuthFrequency"])
155
+
156
+ # azimuth processing info
157
+ params = image_annotation["processingInformation"]["swathProcParamsList"]["swathProcParams"]
158
+ # normalize to list
159
+ if isinstance(params, dict):
160
+ params = [params]
161
+ elif not isinstance(params, list):
162
+ denoise_logger.error(
163
+ f"Unexpected swath processing parameters type: {type(params)}"
164
+ )
165
+ sys.exit(128)
166
+
167
+ self.swath_params = [item for item in params if item.get("swath") == self.swath_id]
168
+
169
+ azimuth_processing = self.get_azimuth_processing(self.swath_id)
170
+ self.look_bandwidth = float(azimuth_processing["lookBandwidth"])
171
+ self.processing_bandwidth = float(azimuth_processing["processingBandwidth"])
172
+ self.number_of_looks = int(azimuth_processing["numberOfLooks"])
173
+ self.look_overlap = float(azimuth_processing["lookOverlap"])
174
+ self.azi_window_coefficient = float(azimuth_processing["windowCoefficient"])
175
+
176
+ range_processing = self.get_range_processing(self.swath_id)
177
+ self.rg_processing_bandwidth = float(range_processing["processingBandwidth"])
178
+
179
+ # general annotation
180
+ self.radar_frequency = float(product["generalAnnotation"]["productInformation"]["radarFrequency"])
181
+ self.velocities = []
182
+ self.orbits = product["generalAnnotation"]["orbitList"]["orbit"]
183
+
184
+ for orbit in self.orbits:
185
+ velocity = orbit["velocity"]
186
+ vx = float(velocity["x"])
187
+ vy = float(velocity["y"])
188
+ vz = float(velocity["z"])
189
+
190
+ norm = math.sqrt(vx ** 2 + vy ** 2 + vz ** 2)
191
+ self.velocities.append(norm)
192
+
193
+ # info from auxiliary files
194
+ self.read_aux_info(aux_info)
195
+
196
+ # annotation info that does not change between bursts/blocks: read only once
197
+ self.antenna_patterns = read_antenna_patterns(self.metadata_dict, self.swath_id)
198
+ self.pg_correction_factors, self.pg_az_t = read_pg_correction_list(self.annotation, self.swath_id)
199
+ self.orbit = build_orbit(self.metadata_dict)
200
+
201
+ # other info
202
+ self.get_noise_power_correction(noise_power_from_l0)
203
+ self.range_over_sampling_factor = self.sampling_freq_afterdecimation() / self.rg_bandwidth()
204
+ self.rel_burst = None # only for TOPSAR SLC
205
+ self.range_t = None
206
+ self.range_distances = None
207
+ self.range_spread_loss = None
208
+
209
+
210
+ def get_azimuth_processing(self, swath_name):
211
+ """
212
+ Return the annotated azimuth processing parameters of a swath.
213
+
214
+ Parameters
215
+ ----------
216
+ swath_name : str
217
+ Swath identifier.
218
+
219
+ Returns
220
+ -------
221
+ dict
222
+ `azimuthProcessing` section of the swath processing parameters.
223
+
224
+ Raises
225
+ ------
226
+ KeyError
227
+ If the swath is not found among the swath processing parameters.
228
+ """
229
+ for sp in self.swath_params:
230
+ if sp["swath"] == swath_name:
231
+ return sp["azimuthProcessing"]
232
+ raise KeyError(f"Swath {swath_name} not found")
233
+
234
+ def get_range_processing(self, swath_name):
235
+ """
236
+ Return the annotated range processing parameters of a swath.
237
+
238
+ Parameters
239
+ ----------
240
+ swath_name : str
241
+ Swath identifier.
242
+
243
+ Returns
244
+ -------
245
+ dict
246
+ `rangeProcessing` section of the swath processing parameters.
247
+
248
+ Raises
249
+ ------
250
+ KeyError
251
+ If the swath is not found among the swath processing parameters.
252
+ """
253
+ for sp in self.swath_params:
254
+ if sp["swath"] == swath_name:
255
+ return sp["rangeProcessing"]
256
+ raise KeyError(f"Swath {swath_name} not found")
257
+
258
+ def read_aux_info(self, aux_info: AuxiliaryFiles):
259
+ """
260
+ Read the subswath and polarization parameters from the auxiliary files and store them as attributes.
261
+
262
+ Reads the processing gain and window coefficients from AUX_PP1; the azimuth antenna pattern (gains in dB
263
+ and angle increment in degrees), absolute calibration constant and noise calibration factor from AUX_CAL;
264
+ the PG reference, expected noise value and azimuth steering rate [rad/s] from AUX_INS.
265
+
266
+ Parameters
267
+ ----------
268
+ aux_info : AuxiliaryFiles
269
+ Auxiliary files used for processing.
270
+ """
271
+
272
+ # from PP1
273
+ self.aux_pp1_calib = aux_info.get_gain_from_pp1(self.product_id, self.swath_id, self.polarization)
274
+ self.alpha_hamming, self.slc_alpha_hamming = aux_info.get_alpha_from_pp1(self.product_id, self.swath_id)
275
+
276
+ # from CAL
277
+ self.aap_gain = aux_info.get_aap_gain(self.swath_id, self.polarization)
278
+ self.aap_gain_count = aux_info.get_aap_gain_count(self.swath_id, self.polarization)
279
+ self.azi_angle_incr = aux_info.get_azi_angle_incr(self.swath_id, self.polarization)
280
+ self.absolute_calibration_constant = aux_info.get_abs_cal(self.swath_id, self.polarization)
281
+ self.noise_calibration_factor = aux_info.get_noise_cal(self.swath_id, self.polarization)
282
+
283
+ # from INS
284
+ self.pg_reference = aux_info.get_pg_reference(self.swath_id, self.polarization)
285
+ self.expected_noise_value = aux_info.get_expected_noise_value(self.swath_id, self.polarization)
286
+ self.azimuth_steering_rate = aux_info.get_azimuth_steering_rate(self.swath_id)
287
+
288
+ denoise_logger.debug(f"Absolute calibration constant: {self.absolute_calibration_constant}")
289
+
290
+ def rg_dim(self):
291
+ """
292
+ Return the number of range samples of the image.
293
+
294
+ Returns
295
+ -------
296
+ int
297
+ Annotated `numberOfSamples`.
298
+ """
299
+ return int(self.metadata_dict["product"]["imageAnnotation"]["imageInformation"]["numberOfSamples"])
300
+
301
+ def rg_bandwidth(self):
302
+ """
303
+ Return the range total bandwidth of the subswath.
304
+
305
+ Returns
306
+ -------
307
+ float
308
+ Annotated range `totalBandwidth` [Hz].
309
+
310
+ Raises
311
+ ------
312
+ ValueError
313
+ If the range processing parameters of the subswath are not found in the annotation.
314
+ """
315
+ data = self.annotation.findall(
316
+ "imageAnnotation/processingInformation/swathProcParamsList/swathProcParams/swath"
317
+ )
318
+ for ind, element in enumerate(data):
319
+ if element.text == self.swath_id:
320
+ rg_bandwidth = float(
321
+ self.annotation.find(
322
+ "imageAnnotation/processingInformation/swathProcParamsList/swathProcParams[{}]/rangeProcessing/totalBandwidth".format(
323
+ ind + 1
324
+ )
325
+ ).text
326
+ )
327
+ return rg_bandwidth
328
+
329
+ raise ValueError(f"Range processing parameters for swath {self.swath_id} not found in annotation")
330
+
331
+ def sampling_freq_afterdecimation(self):
332
+ """
333
+ Return the range sampling frequency after decimation of the subswath.
334
+
335
+ Returns
336
+ -------
337
+ float
338
+ Annotated `samplingFrequencyAfterDecimation` [Hz].
339
+
340
+ Raises
341
+ ------
342
+ ValueError
343
+ If the downlink information of the subswath is not found in the annotation.
344
+ """
345
+ data = self.annotation.findall(
346
+ "generalAnnotation/downlinkInformationList/downlinkInformation/swath"
347
+ )
348
+ for ind, element in enumerate(data):
349
+ if element.text == self.swath_id:
350
+ sampling_freq_afterdecimation = float(
351
+ self.annotation.find(
352
+ "generalAnnotation/downlinkInformationList/downlinkInformation[{}]/downlinkValues/rangeDecimation/samplingFrequencyAfterDecimation".format(
353
+ ind + 1
354
+ )
355
+ ).text
356
+ )
357
+ return sampling_freq_afterdecimation
358
+
359
+ raise ValueError(f"Downlink information for swath {self.swath_id} not found in annotation")
360
+
361
+ def pg_correction_computation(self, azimuth_T):
362
+ """
363
+ Interpolate the annotated PG correction factors at a given azimuth time.
364
+
365
+ Parameters
366
+ ----------
367
+ azimuth_T : PreciseDateTime
368
+ Azimuth time of interpolation.
369
+
370
+ Returns
371
+ -------
372
+ float
373
+ PG product amplitude linearly interpolated at `azimuth_T`.
374
+ """
375
+
376
+ pg_az_t = self.pg_az_t
377
+
378
+ pg_corr_az_central_t = np.interp(azimuth_T - pg_az_t[0], (pg_az_t - pg_az_t[0]).astype(float), self.pg_correction_factors)
379
+
380
+ return pg_corr_az_central_t
381
+
382
+ def get_noise_power_correction(self, noise_power_from_l0):
383
+ """
384
+ Load the noise power values of the subswath and discard the outlier noise pulses.
385
+
386
+ The noise power is taken from the L0N product if provided, otherwise from the noise list of the product
387
+ annotation. Pulses outside the tolerance interval around the AUX_INS expected noise value are discarded
388
+ (a warning is logged for each one); if all of them are discarded, the expected noise value is used at all
389
+ the original azimuth times. Results are stored in `noise_az_t`, `noise_power_correction_factors` and
390
+ `outlier_detected`.
391
+
392
+ Parameters
393
+ ----------
394
+ noise_power_from_l0 : dict or None
395
+ Noise power extracted from the L0N product, mapping ``(swath, polarization)`` to
396
+ ``[azimuth_times, noise_powers]``, or None to use the annotated noise list.
397
+ """
398
+
399
+ if noise_power_from_l0 is None:
400
+ noise_power_correction_factors, noise_az_t = read_noise_power_correction_from_annotation(self.metadata_dict, self.swath_id)
401
+ else:
402
+ noise_az_t = noise_power_from_l0.get((self.swath_id,self.polarization))[0]
403
+ noise_power_correction_factors = noise_power_from_l0.get((self.swath_id,self.polarization))[1]
404
+
405
+ # discard noise pulses
406
+ self.noise_az_t, self.noise_power_correction_factors, self.outlier_detected = remove_outliers(noise_az_t, noise_power_correction_factors, self.expected_noise_value)
407
+
408
+ for ind in range(len(self.outlier_detected)):
409
+ formatted = self.outlier_detected[ind][1].isoformat(timespec="microseconds").rstrip("Z")
410
+ denoise_logger.warning(f"Noise pulse at azimuth time {formatted} discarded")
411
+
412
+ # all noise pulses discarded: fall back to the nominal expected noise from AUX_INS
413
+ if self.noise_power_correction_factors.size == 0 and len(noise_az_t) > 0:
414
+ denoise_logger.warning(f"All noise pulses discarded for {self.swath_id} {self.polarization}: using nominal noise value from AUX_INS")
415
+ self.noise_az_t = np.array(noise_az_t)
416
+ self.noise_power_correction_factors = np.full(self.noise_az_t.size, self.expected_noise_value)
417
+
418
+ def noise_power_computation(self, azimuth_T):
419
+ """
420
+ Interpolate the noise power at a given azimuth time.
421
+
422
+ Parameters
423
+ ----------
424
+ azimuth_T : PreciseDateTime
425
+ Azimuth time of interpolation.
426
+
427
+ Returns
428
+ -------
429
+ float
430
+ Noise power $P_n$ linearly interpolated at `azimuth_T` (held constant outside the noise pulse
431
+ time span).
432
+ """
433
+
434
+ # convert reference times into seconds relative to the first noise azimuth time
435
+ azimuth_T_sec = (azimuth_T - self.noise_az_t[0])
436
+ noise_az_t_sec = np.array([(t - self.noise_az_t[0]) for t in self.noise_az_t])
437
+
438
+ # perform interpolation in float domain
439
+ noise_power_correction_az_central_t = np.interp(
440
+ azimuth_T_sec,
441
+ noise_az_t_sec,
442
+ self.noise_power_correction_factors,
443
+ )
444
+
445
+ denoise_logger.debug(f"Noise power correction: {noise_power_correction_az_central_t:.4}")
446
+
447
+ return noise_power_correction_az_central_t
448
+
449
+ def elevation_pattern(self, azimuth_T):
450
+ """
451
+ Return the elevation antenna pattern gain on the current slant range axis.
452
+
453
+ Uses the antenna pattern record closest in azimuth time to `azimuth_T` and interpolates it on `range_t`.
454
+
455
+ Parameters
456
+ ----------
457
+ azimuth_T : PreciseDateTime
458
+ Azimuth time used to select the antenna pattern record.
459
+
460
+ Returns
461
+ -------
462
+ numpy.ndarray
463
+ Elevation antenna pattern gain (modulus of the annotated complex pattern, linear) for each sample
464
+ of `range_t`.
465
+ """
466
+
467
+ # elevation pattern used to compensate
468
+ idx = closest_antenna_pattern_index(self.antenna_patterns, azimuth_T)
469
+
470
+ final_elevation_pattern_gain = np.interp(
471
+ self.range_t,
472
+ self.antenna_patterns["slant_range_t"][idx],
473
+ self.antenna_patterns["elevation_pattern_gain"][idx]
474
+ )
475
+
476
+ return final_elevation_pattern_gain
477
+
478
+ def incidence_angles(self, azimuth_T):
479
+ """
480
+ Return the incidence angles on the current slant range axis.
481
+
482
+ Uses the antenna pattern record closest in azimuth time to `azimuth_T` and interpolates it on `range_t`.
483
+
484
+ Parameters
485
+ ----------
486
+ azimuth_T : PreciseDateTime
487
+ Azimuth time used to select the antenna pattern record.
488
+
489
+ Returns
490
+ -------
491
+ numpy.ndarray
492
+ Incidence angles [deg] for each sample of `range_t`.
493
+ """
494
+
495
+ # incidence angles used to compensate
496
+ idx = closest_antenna_pattern_index(self.antenna_patterns, azimuth_T)
497
+
498
+ final_incidence_angles = np.interp(
499
+ self.range_t,
500
+ self.antenna_patterns["slant_range_t"][idx],
501
+ self.antenna_patterns["incidence_angles"][idx]
502
+ )
503
+ return final_incidence_angles
504
+
505
+ def compute_k_proc(self):
506
+ """
507
+ Compute the processing gain $k_{proc}$.
508
+
509
+ Returns
510
+ -------
511
+ float
512
+ Squared AUX_PP1 gain divided by the range oversampling factor.
513
+ """
514
+
515
+ k_proc = ( self.aux_pp1_calib / np.sqrt(self.range_over_sampling_factor) ) ** 2
516
+
517
+ return k_proc
518
+
519
+ def compute_G_tot(self, azimuth_T):
520
+ """
521
+ Compute the total gain applied to the noise power along the current slant range axis.
522
+
523
+ Parameters
524
+ ----------
525
+ azimuth_T : PreciseDateTime
526
+ Azimuth time at which the PG correction and the elevation antenna pattern are evaluated.
527
+
528
+ Returns
529
+ -------
530
+ numpy.ndarray
531
+ Total gain for each sample of `range_t`.
532
+
533
+ Notes
534
+ -----
535
+ The total gain is
536
+
537
+ $$
538
+ G_{tot} = \\frac{(G_{pg} \\cdot PG_{ref})^2 \\cdot k_{noise} \\cdot k_{proc}}{G_{eap} \\cdot G_{rsl}}
539
+ $$
540
+
541
+ where $G_{pg}$ is the interpolated PG correction, $PG_{ref}$ the AUX_INS PG reference,
542
+ $k_{noise}$ the AUX_CAL noise calibration factor, $G_{eap}$ the elevation antenna pattern gain
543
+ and $G_{rsl}$ the range spreading loss correction.
544
+ """
545
+
546
+ pg = self.pg_correction_computation(azimuth_T) * self.pg_reference
547
+ k_N = self.noise_calibration_factor
548
+ k_proc = self.compute_k_proc()
549
+ elev_pattern = self.elevation_pattern(azimuth_T)
550
+ rsl = self.range_spread_loss
551
+
552
+ G_tot = pg**2 * k_N * k_proc / (elev_pattern * rsl)
553
+
554
+ return G_tot
555
+
556
+ def compute_simulated_noise(self, azimuth_T) -> NDArray[np.float64]:
557
+ """
558
+ Compute the simulated noise range vector at a given azimuth time, in slant range.
559
+
560
+ Also reconstructs the noise normalization factor (see `reconstruct_noise_norm_factor`), so
561
+ `set_range_values` and `get_shrink_factor` must have been called beforehand.
562
+
563
+ Parameters
564
+ ----------
565
+ azimuth_T : PreciseDateTime
566
+ Azimuth time of the noise vector (burst center time for SLC, antenna pattern record time for GRD).
567
+
568
+ Returns
569
+ -------
570
+ simulated_noise : numpy.ndarray
571
+ Simulated noise $\\sin(\\theta_i) \\cdot P_n \\cdot G_{tot} \\cdot k_{norm}$ (linear power) for each
572
+ sample of `range_t`.
573
+ sin_incidence : numpy.ndarray
574
+ Sine of the incidence angle $\\theta_i$ for each sample of `range_t`.
575
+ """
576
+
577
+ inc_angles = np.deg2rad(self.incidence_angles(azimuth_T))
578
+ noise_pwr = self.noise_power_computation(azimuth_T)
579
+ G_tot = self.compute_G_tot(azimuth_T)
580
+
581
+ self.reconstruct_noise_norm_factor()
582
+
583
+ simulated_noise = np.sin(inc_angles) * noise_pwr * G_tot * self.noise_norm_factor
584
+
585
+ return np.array(simulated_noise, dtype=np.float64), np.sin(inc_angles)
586
+
587
+ def get_shrink_factor(self):
588
+ """
589
+ Compute the TopSAR azimuth shrink factor of the current burst and store it in `shrink_factor`.
590
+
591
+ The shrink factor is $1 - k_{DC} / k_{FM}$, where $k_{FM}$ is the azimuth FM rate of the burst
592
+ (`rel_burst`) and $k_{DC}$ the Doppler centroid rate derived from the AUX_INS azimuth steering rate,
593
+ both evaluated at mid-range as in the IPF. It requires `range_t` and `rel_burst` to be set.
594
+ """
595
+
596
+ range_filter = int(self.rel_burst is not None)
597
+ range_times = self.range_t[::range_filter]
598
+
599
+ # deramping matrix
600
+ FM_rate, az_mid_time = extract_FR_info(self.metadata_dict, range_times, self.rel_burst, self.is_grd, self.swath_id)
601
+ FM_rate_mid = FM_rate[range_times.size // 2]
602
+
603
+ AR_vec = extract_AR_info(self.metadata_dict, az_mid_time, range_times, self.azimuth_steering_rate, orbit=self.orbit)
604
+
605
+ # give back FM rate and DC rate evaluated at mid-range like IPF
606
+ doppler_centroid_rate = AR_vec[range_times.size // 2]
607
+
608
+ self.shrink_factor = 1 - doppler_centroid_rate / FM_rate_mid
609
+
610
+ def set_range_values(self, range_t):
611
+ """
612
+ Set the slant range axis of the noise vector and derive the range-dependent quantities.
613
+
614
+ Stores the slant range times in `range_t`, the slant range distances in `range_distances` and the range
615
+ spreading loss correction $(R_{ref} / R)^4$ in `range_spread_loss`.
616
+
617
+ Parameters
618
+ ----------
619
+ range_t : numpy.ndarray
620
+ Slant range times of the noise vector samples [s].
621
+ """
622
+
623
+ self.range_t = range_t # [s]
624
+ self.range_distances = self.range_t * speed_of_light / 2 # [m]
625
+ self.range_spread_loss = (R_REF / self.range_distances) ** 4
626
+
627
+ def reconstruct_noise_norm_factor(self):
628
+ """
629
+ Reconstruct the IPF noise normalization factor and store it in `noise_norm_factor`.
630
+
631
+ Reverse-engineered from the IPF. The azimuth contribution is obtained by interpolating the AUX_CAL azimuth
632
+ antenna pattern on the azimuth FFT grid (angles converted to frequencies using the `shrink_factor`),
633
+ extracting the overlapping looks with a Hamming window, and averaging over the looks the ratio between the
634
+ noise processing gain and the window normalization factor. The range contribution accounts for the rounding
635
+ of the range processing bandwidth to an integer number of samples (flat spectrum). The stored value is the
636
+ product of the two.
637
+
638
+ Raises
639
+ ------
640
+ ValueError
641
+ If the azimuth or range processing bandwidth corresponds to zero samples, or if a window
642
+ normalization factor is zero.
643
+
644
+ Notes
645
+ -----
646
+ $$
647
+ k_{norm} = k_{norm,az} \\cdot k_{norm,rg}, \\qquad
648
+ k_{norm,az} = \\frac{1}{L} \\sum_{l=1}^{L} \\frac{G_l}{W_l}, \\qquad
649
+ k_{norm,rg} = \\frac{N_{bw,rg}}{\\mathrm{round}(N_{bw,rg})}
650
+ $$
651
+
652
+ See the "Noise normalization factor" page of the algorithm documentation for details.
653
+ """
654
+
655
+ # 1. Constants and basic frequency parameters
656
+
657
+ LAMBDA = speed_of_light / self.radar_frequency
658
+
659
+ fft_length = find_fft_size(self.lines_per_burst)
660
+ sampling_freq_az = 1.0 / self.input_azi_spacing
661
+ delta_freq = sampling_freq_az / fft_length
662
+
663
+ bw_in_samples = int(np.round(self.processing_bandwidth / delta_freq))
664
+ if bw_in_samples == 0:
665
+ raise ValueError("Zero samples in bandwith")
666
+
667
+ # window length is given by the look bandwidth, as in IPF
668
+ window_len = int(np.round(self.look_bandwidth / delta_freq))
669
+ window = hamming(window_len, alpha=self.azi_window_coefficient)
670
+
671
+ look_overlap = get_look_overlap_samples(
672
+ window_len=window_len,
673
+ bw_in_samples=bw_in_samples,
674
+ number_of_looks=self.number_of_looks
675
+ )
676
+
677
+ look_start_indices = get_start_indices(
678
+ look_overlap=look_overlap,
679
+ window_len=window_len,
680
+ in_line_len=fft_length,
681
+ number_of_looks=self.number_of_looks
682
+ )
683
+
684
+ # 2. Angle computation
685
+
686
+ angle_incr_rad = LAMBDA / (2 * np.mean(self.velocities))
687
+ angle_incr_rad *= delta_freq
688
+ angle_incr_rad *= self.shrink_factor
689
+
690
+ denoise_logger.debug(f"Shrink factor: {self.shrink_factor:.2f}")
691
+
692
+ angle_incr_deg = np.rad2deg(angle_incr_rad)
693
+
694
+ output_start_angle = -fft_length / 2 * angle_incr_deg
695
+ input_start_angle = -self.azi_angle_incr * (self.aap_gain_count - 1) / 2
696
+
697
+ output_az_axis = np.arange(fft_length) * angle_incr_deg + output_start_angle
698
+ input_az_axis = (
699
+ np.arange(self.aap_gain_count) * self.azi_angle_incr + input_start_angle
700
+ )
701
+
702
+ # 3. Antenna pattern interpolation and correction
703
+
704
+ aap_data = np.interp(x=output_az_axis, xp=input_az_axis, fp=self.aap_gain)
705
+ aap_data = np.fft.fftshift(aap_data)
706
+
707
+ data_spectra = db_to_linear(aap_data, is_amplitude=True)
708
+
709
+ # 4. Window normalization
710
+
711
+ window_norm_factor = calculate_window_norm_factor(
712
+ data_spectra=data_spectra,
713
+ in_line_len=fft_length,
714
+ window=window,
715
+ bw_in_samples=bw_in_samples,
716
+ look_start_indices=look_start_indices,
717
+ correct_spectra=self.apply_azi_ant_pattern
718
+ )
719
+
720
+ sum_window_sq_noise = update_window_noise_norm(
721
+ data_spectra=data_spectra,
722
+ window=window,
723
+ look_start_indices=look_start_indices,
724
+ in_line_len=fft_length,
725
+ correct_spectra=self.apply_azi_ant_pattern
726
+ )
727
+
728
+ # 5. Noise normalization factor computation
729
+
730
+ if np.any(window_norm_factor == 0.0):
731
+ raise ValueError("Zero window normalization factor")
732
+
733
+ noise_proc_gain = sum_window_sq_noise / bw_in_samples
734
+ noise_norm_factor_all_looks = noise_proc_gain / window_norm_factor
735
+
736
+ noise_azi_norm_factor = np.mean(noise_norm_factor_all_looks)
737
+
738
+ # 6. Range noise normalization factor (flat spectrum in range, as in IPF):
739
+ # window norm uses the exact bandwidth in samples, noise gain the rounded one
740
+
741
+ rg_fft_length = find_fft_size(self.samples_per_burst if self.samples_per_burst > 0 else self.rg_dim())
742
+ rg_delta_freq = self.sampling_freq_afterdecimation() / rg_fft_length
743
+ rg_bw_samples = self.rg_processing_bandwidth / rg_delta_freq
744
+ if int(np.round(rg_bw_samples)) == 0:
745
+ raise ValueError("Zero samples in range bandwith")
746
+
747
+ noise_rng_norm_factor = rg_bw_samples / int(np.round(rg_bw_samples))
748
+
749
+ denoise_logger.debug(f"Noise azimuth norm factor: {noise_azi_norm_factor}, range norm factor: {noise_rng_norm_factor}")
750
+
751
+ self.noise_norm_factor = noise_azi_norm_factor * noise_rng_norm_factor
752
+
753
+ denoise_logger.debug(f"Noise norm factor: {self.noise_norm_factor}")