sct-eos04-reader 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: Aresys S.r.l. <info@aresys.it>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """SCT-Plugin: EOS-04 product format reader."""
5
+
6
+ __version__ = "1.0.0"
@@ -0,0 +1,36 @@
1
+ # SPDX-FileCopyrightText: Aresys S.r.l. <info@aresys.it>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """EOS-4 SCT plugin interface."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Callable
10
+
11
+ from sct_eos04_reader import __version__
12
+
13
+ if TYPE_CHECKING:
14
+ from sct.io.extended_protocols import ALECorrectionFunctionType, SCTInputProduct
15
+
16
+
17
+ class EOS04ProductPlugin:
18
+ """Plugin for EOS-04 product format"""
19
+
20
+ version = __version__
21
+
22
+ @classmethod
23
+ def get_manager(cls) -> type[SCTInputProduct]:
24
+ from sct_eos04_reader.protocol_implementation import EOS04ProductManager
25
+
26
+ return EOS04ProductManager
27
+
28
+ @classmethod
29
+ def get_detector(cls) -> Callable[[str | Path], bool]:
30
+ from eo_products.eos04.utilities import is_eos04_product
31
+
32
+ return is_eos04_product
33
+
34
+ @classmethod
35
+ def get_ale_corrector(cls) -> ALECorrectionFunctionType:
36
+ return None
@@ -0,0 +1,831 @@
1
+ # SPDX-FileCopyrightText: Aresys S.r.l. <info@aresys.it>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """EOS-04 format reader protocol-compliant wrapper for PERSEO-quality."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from itertools import product
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ from eo_products.common.utilities import DopplerEvaluator
13
+ from eo_products.eos04.reader import open_product, read_channel_data, read_product_metadata
14
+ from eo_products.eos04.utilities import EOS04ChannelMetadata
15
+ from numpy.typing import ArrayLike
16
+ from perseo_core.geometry import compute_ground_velocity, compute_incidence_angles, compute_look_angles
17
+ from perseo_core.geometry.geocoding import inverse_geocoding_monostatic
18
+ from perseo_core.geometry.navigation import Trajectory
19
+ from perseo_core.timing import PreciseDateTime
20
+ from perseo_quality.core.custom_errors import (
21
+ CoordinatesOutOfBounds,
22
+ )
23
+ from perseo_quality.core.generic_dataclasses import (
24
+ LocationData,
25
+ SARAcquisitionMode,
26
+ SARImageType,
27
+ SAROrbitDirection,
28
+ SARPolarization,
29
+ SARProjection,
30
+ SARRadiometricQuantity,
31
+ SARSamplingFrequencies,
32
+ SARSideLooking,
33
+ )
34
+ from perseo_quality.core.signal_processing import radiometric_correction
35
+ from perseo_quality.io.protocol_utilities import roi_validation
36
+ from scipy.constants import speed_of_light
37
+ from shapely import Polygon
38
+
39
+
40
+ class EOS04DopplerPolynomial:
41
+ """PERSEO-quality Doppler Function protocol compliant EOS04 doppler polynomial wrapper"""
42
+
43
+ def __init__(self, evaluator: DopplerEvaluator) -> None:
44
+ self._evaluator = evaluator
45
+
46
+ def evaluate(self, azimuth_time: PreciseDateTime, range_time: float) -> float:
47
+ """Evaluate the Doppler Polynomial at given azimuth and range times.
48
+
49
+ Parameters
50
+ ----------
51
+ azimuth_time : PreciseDateTime
52
+ azimuth time at which evaluate the polynomial
53
+ range_time : float
54
+ range time at which evaluate the polynomial
55
+
56
+ Returns
57
+ -------
58
+ float
59
+ doppler centroid at that time
60
+ """
61
+ return self._evaluator.evaluate(azimuth_time, range_time)
62
+
63
+
64
+ class EOS04ProductManager:
65
+ """SCTInputProduct protocol compliant EOS04 wrapper"""
66
+
67
+ def __init__(self, path: str | Path, **kwargs) -> None:
68
+ self._path = Path(path)
69
+ self._name = self._path.name
70
+ self._product = open_product(path)
71
+ self._metadata = read_product_metadata(
72
+ xml_path=self._product.metadata_file, channels=self._product.channels_list
73
+ )
74
+ region_corners = list(product(self._product.footprint[:2], self._product.footprint[2:]))
75
+ self._footprint = Polygon(region_corners).convex_hull
76
+
77
+ @property
78
+ def path(self) -> Path:
79
+ """Get product path"""
80
+ return self._path
81
+
82
+ @property
83
+ def name(self) -> str:
84
+ """Get product name"""
85
+ return self._name
86
+
87
+ @property
88
+ def footprint(self) -> Polygon:
89
+ """Get product footprint"""
90
+ return self._footprint
91
+
92
+ @property
93
+ def channels_list(self) -> list[str]:
94
+ """Get list of available channels for this product"""
95
+ return self._product.channels_list
96
+
97
+ def get_channel_data(self, channel_id: str) -> EOS04ChannelManager:
98
+ """Gathering all the information that are channel dependent and storing them in a protocol compliant object.
99
+
100
+ Parameters
101
+ ----------
102
+ channel_id : int
103
+ selected channel identifier
104
+
105
+ Returns
106
+ -------
107
+ EOS04ChannelManager
108
+ ChannelData-compliant object containing data corresponding to the selected channel
109
+ """
110
+ return EOS04ChannelManager(
111
+ channel_metadata=self._metadata[channel_id],
112
+ channel_raster_path=self._product.get_raster_file_from_channel_name(channel_id),
113
+ channel_name=channel_id,
114
+ )
115
+
116
+
117
+ class EOS04ChannelManager:
118
+ """PERSEO-quality ChannelData protocol compliant EOS04 channel wrapper"""
119
+
120
+ def __init__(
121
+ self,
122
+ channel_metadata: EOS04ChannelMetadata,
123
+ channel_raster_path: Path,
124
+ channel_name: str,
125
+ ) -> None:
126
+ """Creating a ChannelManager object compliant with the ChannelData protocol.
127
+
128
+ Parameters
129
+ ----------
130
+ channel_metadata : EOS04ChannelMetadata
131
+ channel metadata dataclass for the current channel
132
+ channel_raster_path : int
133
+ Path to the channel raster file
134
+ channel_name : int
135
+ name of current channel
136
+ """
137
+
138
+ self._channel_id = channel_name
139
+ self._raster_file = channel_raster_path
140
+ self._channel = channel_metadata
141
+ self._sensor_name = (
142
+ "" if self._channel.dataset_info.sensor_name is None else self._channel.dataset_info.sensor_name
143
+ )
144
+
145
+ self._radiometric_quantity = SARRadiometricQuantity[self._channel.image_radiometric_quantity.name]
146
+ self._polarization = SARPolarization(self._channel.general_info.polarization.value)
147
+ self._projection = SARProjection(self._channel.general_info.projection.value)
148
+ self._orbit_direction = SAROrbitDirection[self._channel.general_info.orbit_direction.name]
149
+ self._acquisition_mode = SARAcquisitionMode[self._channel.general_info.acquisition_mode_std.name]
150
+ self._looking_side = SARSideLooking(self._channel.dataset_info.side_looking)
151
+
152
+ self._range_step_m = self._compute_range_step_m()
153
+ self._image_type = self._channel.general_info.product_type
154
+
155
+ # compute axes
156
+ self._azimuth_axis = self._compute_azimuth_axis()
157
+ self._az_time_half_swath = self._azimuth_axis[self._azimuth_axis.size // 2]
158
+ self._range_axis = (
159
+ np.arange(0, self._channel.raster_info.samples.length, 1) * self._channel.raster_info.samples.step
160
+ + self._channel.raster_info.samples.start
161
+ )
162
+ self._slant_range_axis = self._compute_slant_range_axis()
163
+ rng_time_half_swath = (
164
+ self._channel.raster_info.samples.start
165
+ + (self._channel.raster_info.samples.length - 1) * self._channel.raster_info.samples.step / 2
166
+ )
167
+ if self._projection == SARProjection.GROUND_RANGE:
168
+ rng_time_half_swath = self._channel.coordinate_conversions.evaluate_ground_to_slant(
169
+ azimuth_time=self._az_time_half_swath, ground_range=np.floor(rng_time_half_swath)
170
+ )
171
+ self._rng_time_half_swath = rng_time_half_swath
172
+
173
+ # lines per burst array
174
+ if self._channel.burst_info.num > 0:
175
+ self._lines_per_burst_array = np.repeat(
176
+ self._channel.burst_info.lines_per_burst, self._channel.burst_info.num
177
+ )
178
+ else:
179
+ # should be a 1D array
180
+ self._lines_per_burst_array = np.repeat(self._channel.raster_info.lines.length, 1)
181
+
182
+ # lines per burst array
183
+ if self._channel.burst_info.num > 0:
184
+ self._lines_per_burst_array = np.repeat(
185
+ self._channel.burst_info.lines_per_burst, self._channel.burst_info.num
186
+ )
187
+ else:
188
+ # should be a 1D array
189
+ self._lines_per_burst_array = np.repeat(self._channel.raster_info.lines.length, 1)
190
+
191
+ # prf
192
+ self._prf = self._channel.swath_info.prf
193
+
194
+ # steering rate
195
+ self._steering_rate_poly_coeff = self._channel.swath_info.azimuth_steering_rate_poly
196
+
197
+ # trajectory
198
+ self._trajectory_rx = self._channel.orbit
199
+ self._trajectory_tx = None
200
+
201
+ # generating doppler centroid wrappers
202
+ self._doppler_centroid_poly = EOS04DopplerPolynomial(evaluator=self._channel.doppler_centroid_poly)
203
+
204
+ # get burst boundaries
205
+ self._burst_az_boundaries, self._burst_rng_boundaries = self._get_raster_layout()
206
+
207
+ def _compute_range_step_m(self) -> float:
208
+ """Computing step along range direction, in meters"""
209
+ if self._projection == SARProjection.GROUND_RANGE:
210
+ return self._channel.raster_info.samples.step
211
+
212
+ return self._channel.raster_info.samples.step * speed_of_light / 2
213
+
214
+ def _compute_slant_range_axis(self) -> np.ndarray:
215
+ """Computing slant range full axis.
216
+
217
+ Returns
218
+ -------
219
+ np.ndarray
220
+ slant range axis
221
+ """
222
+ slant_rng_axis = self._range_axis
223
+ if self._projection == SARProjection.GROUND_RANGE:
224
+ slant_rng_axis = self._channel.coordinate_conversions.evaluate_ground_to_slant(
225
+ azimuth_time=self._az_time_half_swath, ground_range=self._range_axis
226
+ )
227
+
228
+ return slant_rng_axis
229
+
230
+ def _compute_azimuth_axis(self) -> np.ndarray:
231
+ """Compute azimuth full axis.
232
+
233
+ Returns
234
+ -------
235
+ np.ndarray
236
+ azimuth axis
237
+ """
238
+ az_axis = (
239
+ np.arange(0, self._channel.raster_info.lines.length, 1) * self._channel.raster_info.lines.step
240
+ + self._channel.raster_info.lines.start
241
+ )
242
+ if self._channel.burst_info.num > 0:
243
+ az_axis = []
244
+ for brst in range(self._channel.burst_info.num):
245
+ # TODO: check if the valid/invalid samples/lines should be taken into account here
246
+ az_axis.append(
247
+ self._channel.burst_info.azimuth_start_times[brst]
248
+ + np.arange(0, self._channel.burst_info.lines_per_burst, 1) * self._channel.raster_info.lines.step
249
+ )
250
+ az_axis = np.concatenate(az_axis)
251
+ return az_axis
252
+
253
+ def _get_raster_layout(self) -> tuple[list[PreciseDateTime], list[float]]:
254
+ """Evaluating raster boundaries taking into account the bursts, if needed.
255
+
256
+ Returns
257
+ -------
258
+ tuple[list[list[PreciseDateTime, PreciseDateTime]], list[list[float, float]]]
259
+ azimuth raster boundaries (azimuth start, azimuth stop),
260
+ range raster boundaries (range start, range stop)
261
+ """
262
+
263
+ if self._channel.burst_info.num > 0:
264
+ az_times = self._channel.burst_info.azimuth_start_times
265
+ rng_times = np.repeat(self._channel.raster_info.samples.start, az_times.size)
266
+ burst_az_boundaries = []
267
+ for az_time in az_times:
268
+ burst_az_boundaries.append(
269
+ [az_time, az_time + self._channel.burst_info.lines_per_burst * self._channel.raster_info.lines.step]
270
+ )
271
+ burst_rng_boundaries = []
272
+ for rng_time in rng_times:
273
+ burst_rng_boundaries.append(
274
+ [
275
+ rng_time,
276
+ rng_time + self._channel.raster_info.samples.length * self._channel.raster_info.samples.step,
277
+ ]
278
+ )
279
+ else:
280
+ burst_az_boundaries = [
281
+ [
282
+ self._channel.raster_info.lines.start,
283
+ self._channel.raster_info.lines.start
284
+ + self._channel.raster_info.lines.length * self._channel.raster_info.lines.step,
285
+ ]
286
+ ]
287
+ burst_rng_boundaries = [
288
+ [
289
+ self._channel.raster_info.samples.start,
290
+ self._channel.raster_info.samples.start
291
+ + self._channel.raster_info.samples.length * self._channel.raster_info.samples.step,
292
+ ]
293
+ ]
294
+
295
+ return burst_az_boundaries, burst_rng_boundaries
296
+
297
+ @property
298
+ def sensor_name(self) -> str:
299
+ """Name of the sensor"""
300
+ return self._sensor_name
301
+
302
+ @property
303
+ def swath_name(self) -> str:
304
+ """Name of the swath being analyzed"""
305
+ return self._channel.general_info.swath
306
+
307
+ @property
308
+ def channel_id(self) -> str:
309
+ """Identifier of current channel data"""
310
+ return self._channel_id
311
+
312
+ @property
313
+ def prf(self) -> float:
314
+ """Sensor Pulse Repetition Frequency (PRF)"""
315
+ return self._prf
316
+
317
+ @property
318
+ def range_step_m(self) -> float:
319
+ """Step along range direction, in meters"""
320
+ return self._range_step_m
321
+
322
+ @property
323
+ def azimuth_step_s(self) -> float:
324
+ """Step along azimuth direction, in seconds"""
325
+ return self._channel.raster_info.lines.step
326
+
327
+ @property
328
+ def projection(self) -> SARProjection:
329
+ """Channel data projection"""
330
+ return self._projection
331
+
332
+ @property
333
+ def polarization(self) -> SARPolarization:
334
+ """Channel data polarization"""
335
+ return self._polarization
336
+
337
+ @property
338
+ def acquisition_mode(self) -> SARAcquisitionMode:
339
+ """Channel data acquisition mode"""
340
+ return self._acquisition_mode
341
+
342
+ @property
343
+ def orbit_direction(self) -> SAROrbitDirection:
344
+ """Channel data orbit direction"""
345
+ return self._orbit_direction
346
+
347
+ @property
348
+ def image_type(self) -> SARImageType:
349
+ """Channel raster image type"""
350
+ return self._image_type
351
+
352
+ @property
353
+ def sampling_constants(self) -> SARSamplingFrequencies:
354
+ """Channel data signal sampling frequencies"""
355
+ return self._channel.sampling_constants
356
+
357
+ @property
358
+ def looking_side(self) -> SARSideLooking:
359
+ """Sensor look direction for this channel"""
360
+ return self._looking_side
361
+
362
+ @property
363
+ def carrier_frequency(self) -> float:
364
+ """Signal carrier frequency"""
365
+ return self._channel.dataset_info.fc_hz
366
+
367
+ @property
368
+ def mid_azimuth_time(self) -> PreciseDateTime:
369
+ """Azimuth time at half swath"""
370
+ return self._az_time_half_swath
371
+
372
+ @property
373
+ def trajectory(self) -> Trajectory:
374
+ """Channel trajectory rx 3D curve"""
375
+ return self._trajectory_rx
376
+
377
+ @property
378
+ def attitude(self) -> None:
379
+ """Channel attitude defined in ECEF Reference Frame"""
380
+ return None
381
+
382
+ @property
383
+ def doppler_centroid(self) -> EOS04DopplerPolynomial:
384
+ """Channel doppler centroid polynomial wrapper"""
385
+ return self._doppler_centroid_poly
386
+
387
+ @property
388
+ def doppler_rate(self) -> None:
389
+ """Channel doppler rate polynomial wrapper"""
390
+ return None
391
+
392
+ @property
393
+ def mid_range_time(self) -> float:
394
+ """Range time at half swath"""
395
+ return self._rng_time_half_swath
396
+
397
+ @property
398
+ def range_axis(self) -> np.ndarray:
399
+ """Range axis"""
400
+ return self._range_axis
401
+
402
+ @property
403
+ def slant_range_axis(self) -> np.ndarray:
404
+ """Range axis"""
405
+ return self._slant_range_axis
406
+
407
+ @property
408
+ def azimuth_axis(self) -> np.ndarray:
409
+ """Azimuth axis, PreciseDateTime format"""
410
+ return self._azimuth_axis
411
+
412
+ @property
413
+ def lines_per_burst(self) -> np.ndarray:
414
+ """Lines per burst, for each burst in the swath"""
415
+ return self._lines_per_burst_array
416
+
417
+ @property
418
+ def radiometric_quantity(self) -> np.ndarray:
419
+ """Product radiometric quantity"""
420
+ return self._radiometric_quantity
421
+
422
+ def get_mid_burst_times(self, burst: int) -> tuple[PreciseDateTime, float]:
423
+ """Compute mid azimuth and range times for a given burst.
424
+
425
+ Returns
426
+ -------
427
+ PreciseDateTime
428
+ azimuth mid burst time
429
+ float
430
+ range mid burst time
431
+ """
432
+ az_mid_burst = self.mid_azimuth_time
433
+ rng_mid_burst = self.mid_range_time
434
+ if self._channel.burst_info.num > 0:
435
+ az_time_boundaries, rng_time_boundaries = self._get_raster_layout()
436
+ az_mid_burst = (az_time_boundaries[burst][1] - az_time_boundaries[burst][0]) / 2 + az_time_boundaries[
437
+ burst
438
+ ][0]
439
+ rng_mid_burst = (rng_time_boundaries[burst][1] - rng_time_boundaries[burst][0]) / 2 + rng_time_boundaries[
440
+ burst
441
+ ][0]
442
+
443
+ return az_mid_burst, rng_mid_burst
444
+
445
+ def get_steering_rate(self, azimuth_time: PreciseDateTime, burst: int) -> float:
446
+ """Compute steering rate at a given azimuth time and for a given burst.
447
+
448
+ Parameters
449
+ ----------
450
+ azimuth_time : PreciseDateTime
451
+ azimuth time
452
+ burst : int
453
+ burst corresponding to the input time
454
+
455
+ Returns
456
+ -------
457
+ float
458
+ azimuth steering rate
459
+ """
460
+ if self._channel.burst_info.num > 0 and burst is not None:
461
+ time_rel = azimuth_time - self._channel.burst_info.azimuth_start_times[burst]
462
+ else:
463
+ time_rel = azimuth_time - self._channel.raster_info.lines.start
464
+ return (
465
+ self._steering_rate_poly_coeff[0]
466
+ + self._steering_rate_poly_coeff[1] * time_rel
467
+ + self._steering_rate_poly_coeff[2] * time_rel**2
468
+ )
469
+
470
+ def get_location_data(self, azimuth_time: PreciseDateTime, range_time: float) -> LocationData:
471
+ """Generating a LocationData object containing data and info derived from the current NovaSAR1ChannelManager
472
+ and declined to the specific azimuth and range times selected.
473
+
474
+ Parameters
475
+ ----------
476
+ azimuth_time : PreciseDateTime
477
+ selected azimuth time
478
+ range_time : float
479
+ selected range time
480
+
481
+ Returns
482
+ -------
483
+ LocationData
484
+ LocationData instance related to the selected location
485
+ """
486
+
487
+ incidence_angle = compute_incidence_angles(
488
+ trajectory=self.trajectory,
489
+ azimuth_time=azimuth_time,
490
+ range_times=range_time,
491
+ look_direction=self.looking_side.value,
492
+ )
493
+ look_angle = compute_look_angles(
494
+ trajectory=self.trajectory,
495
+ azimuth_time=azimuth_time,
496
+ range_times=self.mid_range_time,
497
+ look_direction=self.looking_side.value,
498
+ )
499
+ v_ground = compute_ground_velocity(
500
+ trajectory=self.trajectory, azimuth_time=azimuth_time, look_angles_rad=look_angle
501
+ )
502
+ azimuth_step_m = self.azimuth_step_s * v_ground
503
+
504
+ if self.projection == SARProjection.SLANT_RANGE:
505
+ ground_range_step_m: float = self.range_step_m / np.sin(incidence_angle)
506
+ range_step_m = self.range_step_m
507
+ elif self.projection == SARProjection.GROUND_RANGE:
508
+ ground_range_step_m: float = self.range_step_m
509
+ range_step_m = self.range_step_m * np.sin(incidence_angle)
510
+
511
+ return LocationData(
512
+ abs_azimuth_time=azimuth_time,
513
+ abs_range_time=range_time,
514
+ incidence_angle=incidence_angle,
515
+ look_angle=look_angle,
516
+ ground_velocity=v_ground,
517
+ azimuth_step_m=azimuth_step_m,
518
+ range_step_m=range_step_m,
519
+ ground_range_step_m=ground_range_step_m,
520
+ )
521
+
522
+ def pixel_to_times_conversion(
523
+ self, azimuth_index: float, range_index: float, burst: int = None
524
+ ) -> tuple[PreciseDateTime, float]:
525
+ """Converting input raster pixel coordinates (azimuth_index and range index) to corresponding absolute times,
526
+ azimuth and range.
527
+
528
+ Parameters
529
+ ----------
530
+ azimuth_index : float
531
+ azimuth pixel index, subpixel precision
532
+ range_index : float
533
+ range pixel index, subpixel precision
534
+ burst : int, optional
535
+ burst index, by default None
536
+
537
+ Returns
538
+ -------
539
+ PreciseDateTime
540
+ azimuth time
541
+ float
542
+ range time
543
+ """
544
+
545
+ start_time_rng = self._channel.raster_info.samples.start
546
+ if self._channel.burst_info.num > 0 and burst is not None:
547
+ start_time_az = self._channel.burst_info.azimuth_start_times[burst]
548
+ az_time = (
549
+ azimuth_index - self._channel.burst_info.lines_per_burst * burst
550
+ ) * self._channel.raster_info.lines.step + start_time_az
551
+ else:
552
+ start_time_az = self._channel.raster_info.lines.start
553
+ az_time = azimuth_index * self._channel.raster_info.lines.step + start_time_az
554
+
555
+ rng_time = range_index * self._channel.raster_info.samples.step + start_time_rng
556
+
557
+ if self.projection == SARProjection.GROUND_RANGE:
558
+ rng_time = self._channel.coordinate_conversions.evaluate_ground_to_slant(
559
+ azimuth_time=self.mid_azimuth_time, ground_range=rng_time
560
+ )
561
+
562
+ return az_time, rng_time
563
+
564
+ def times_to_pixel_conversion(
565
+ self, azimuth_time: PreciseDateTime, range_time: float, burst: int = None
566
+ ) -> tuple[float, float]:
567
+ """Converting azimuth and range times to raster image pixels indexes with subpixel precision.
568
+
569
+ Parameters
570
+ ----------
571
+ azimuth_time : PreciseDateTime
572
+ azimuth time
573
+ range_time : float
574
+ range time
575
+ burst : int
576
+ burst number corresponding to these times
577
+
578
+ Returns
579
+ -------
580
+ float
581
+ pixel corresponding to azimuth time
582
+ float
583
+ pixel corresponding to range time
584
+ """
585
+
586
+ rng_value = range_time
587
+ if self.projection == SARProjection.GROUND_RANGE:
588
+ # if projection is GROUND RANGE, range info are expressed in meters, so it must be converted
589
+ rng_value = self._channel.coordinate_conversions.evaluate_slant_to_ground(
590
+ azimuth_time=azimuth_time, slant_range=range_time
591
+ )
592
+
593
+ rng_idx = (rng_value - self._channel.raster_info.samples.start) / self._channel.raster_info.samples.step
594
+ if self._channel.burst_info.num > 0:
595
+ if burst is None:
596
+ burst = self.times_to_burst_association([azimuth_time])[0]
597
+ azmth_idx = (
598
+ azimuth_time - self._channel.burst_info.azimuth_start_times[burst]
599
+ ) / self._channel.raster_info.lines.step + self._channel.burst_info.lines_per_burst * burst
600
+ else:
601
+ azmth_idx = (azimuth_time - self._channel.raster_info.lines.start) / self._channel.raster_info.lines.step
602
+
603
+ return azmth_idx, rng_idx
604
+
605
+ def ground_points_to_burst_association(self, coordinates: ArrayLike) -> list[list[int] | None]:
606
+ """Determining the burst (or bursts) where the input coordinates lie. If no association can be found (i.e. the
607
+ point is not visible in the scene), None is returned.
608
+
609
+ Parameters
610
+ ----------
611
+ coordinates : ArrayLike
612
+ array of coordinates, in the form (N, 3)
613
+
614
+ Returns
615
+ -------
616
+ list[list[int] | None]
617
+ list containing the burst association for each input point, None if no association was found
618
+ """
619
+
620
+ coordinates = np.atleast_2d(coordinates)
621
+
622
+ t_azmth, t_rng = [], []
623
+ for coord in coordinates:
624
+ try:
625
+ t_azmth_i, t_rng_i = inverse_geocoding_monostatic(
626
+ trajectory=self.trajectory,
627
+ ground_points=coord,
628
+ doppler_frequencies=0,
629
+ wavelength=1,
630
+ az_initial_time_guesses=self.mid_azimuth_time,
631
+ )
632
+ t_azmth.append(t_azmth_i)
633
+ t_rng.append(t_rng_i)
634
+ except Exception:
635
+ t_azmth.append(np.nan)
636
+ t_rng.append(np.nan)
637
+
638
+ t_azmth = np.asarray(t_azmth)
639
+ t_rng = np.asarray(t_rng)
640
+
641
+ az_check = [
642
+ (
643
+ [(t < az[1] and t > az[0]) for az in self._burst_az_boundaries]
644
+ if isinstance(t, PreciseDateTime)
645
+ else [False]
646
+ )
647
+ for t in t_azmth
648
+ ]
649
+ rng_check = [
650
+ [(t < rng[1] and t > rng[0]) for rng in self._burst_rng_boundaries] if ~np.isnan(t) else [False]
651
+ for t in t_rng
652
+ ]
653
+ check = [np.logical_and(az_check[c], rng_check[c]) for c in range(len(az_check))]
654
+
655
+ bursts = [list(np.where(c)[0]) if c.any() else None for c in check]
656
+
657
+ return bursts
658
+
659
+ def times_to_burst_association(self, azimuth_times: ArrayLike) -> list[int]:
660
+ """Associate the right burst to a given input time point. This function returns 1 association for each
661
+ input time.
662
+ Associating time only to the first burst containing it.
663
+
664
+ Parameters
665
+ ----------
666
+ azimuth_times : ArrayLike
667
+ azimuth time array in PreciseDateTime format
668
+
669
+ Returns
670
+ -------
671
+ list[int]
672
+ burst associated with a given time
673
+
674
+ Raises
675
+ ------
676
+ CoordinatesOutOfBounds
677
+ if input time exceeds tme boundaries of the swath
678
+ """
679
+ if self._channel.burst_info is None:
680
+ return [0] * len(azimuth_times)
681
+
682
+ bursts_start_times = self._channel.burst_info.azimuth_start_times
683
+ last_time = (
684
+ bursts_start_times[0]
685
+ + self._channel.burst_info.num
686
+ * self._channel.burst_info.lines_per_burst
687
+ * self._channel.raster_info.lines.step
688
+ )
689
+
690
+ bursts = []
691
+ for time in azimuth_times:
692
+ if time < bursts_start_times[0] or time > last_time:
693
+ raise CoordinatesOutOfBounds(f"{time} is out of the recorded timeline")
694
+
695
+ time_diff = time - bursts_start_times
696
+ time_mask = np.ma.masked_less(time_diff.astype("float64"), 0)
697
+ # associating time only to the first burst containing it
698
+ bursts.append(time_mask.argmin())
699
+
700
+ return bursts
701
+
702
+ def pixel_to_burst_association(self, azimuth_px_indexes: ArrayLike) -> list[int]:
703
+ """Associate the azimuth pixel value to the right burst. This function returns 1 association for each
704
+ input time.
705
+
706
+ Parameters
707
+ ----------
708
+ azimuth_px_indexes : ArrayLike
709
+ azimuth pixel indexes array
710
+
711
+ Returns
712
+ -------
713
+ list[int]
714
+ burst associated with a given pixel index
715
+
716
+ Raises
717
+ ------
718
+ CoordinatesOutOfBounds
719
+ if input time exceeds tme boundaries of the swath
720
+ """
721
+ if self._channel.burst_info is None:
722
+ return [0] * len(azimuth_px_indexes)
723
+
724
+ bursts_lines = np.repeat(self._channel.burst_info.lines_per_burst, self._channel.burst_info.num)
725
+ burst_boundaries = np.array([0] + [sum(bursts_lines[: t + 1]) for t, _ in enumerate(bursts_lines)])
726
+
727
+ bursts = []
728
+ for coord in azimuth_px_indexes:
729
+ if coord > burst_boundaries[-1]:
730
+ raise CoordinatesOutOfBounds(f"{coord} pixel exceeds swath's bounds")
731
+
732
+ px_diff = coord - burst_boundaries
733
+ px_mask = np.ma.masked_less(px_diff, 0)
734
+
735
+ bursts.append(px_mask.argmin())
736
+
737
+ return bursts
738
+
739
+ def read_data(
740
+ self,
741
+ azimuth_index: float,
742
+ range_index: float,
743
+ cropping_size: tuple[int, int] = (150, 150),
744
+ output_radiometric_quantity: SARRadiometricQuantity = SARRadiometricQuantity.BETA_NOUGHT,
745
+ burst: int | None = None,
746
+ ) -> np.ndarray:
747
+ """Extracting the swath portion centered to the provided target position and of size cropping_size by
748
+ cropping_size. Target position is provided via its azimuth and range indexes in the swath array.
749
+
750
+ Parameters
751
+ ----------
752
+ azimuth_index : float
753
+ index of azimuth time in swath array
754
+ range_index : float
755
+ index of range time in swath array
756
+ cropping_size : tuple[int, int], optional
757
+ size in pixel of the swath portion to be read (number of samples, number of lines), by default (150, 150)
758
+ output_radiometric_quantity : SARRadiometricQuantity, optional
759
+ selected output radiometric quantity to convert the read data to, if needed,
760
+ by default SARRadiometricQuantity.BETA_NOUGHT
761
+ burst : int, optional
762
+ if burst is provided, the roi extraction gives error if the boundaries exceed the burst boundaries,
763
+ by default None
764
+
765
+ Returns
766
+ -------
767
+ np.ndarray
768
+ cropped swath array centered to the input target coordinates, output array is (samples, lines)
769
+ by default the output radiometric quantity is BETA_NOUGHT, unless specified otherwise
770
+
771
+ Raises
772
+ ------
773
+ AzimuthExceedsBoundariesError
774
+ azimuth index exceeds swath boundaries
775
+ RangeExceedsBoundariesError
776
+ range index exceeds swath boundaries
777
+ """
778
+
779
+ # creating the target block identifier for partial swath reading
780
+ # [start line, start sample, number of lines, number of samples]
781
+ target_block = [
782
+ azimuth_index - np.floor(cropping_size[1] / 2).astype(int),
783
+ range_index - np.floor(cropping_size[0] / 2).astype(int),
784
+ cropping_size[1],
785
+ cropping_size[0],
786
+ ]
787
+
788
+ # full raster boundaries and burst boundaries, if applicable
789
+ raster_boundaries = [0, 0, self._channel.raster_info.lines.length, self._channel.raster_info.samples.length]
790
+ burst_boundaries = None
791
+ # if burst is provided, it means that the ROI to be read must be inside of this burst, otherwise the extracted
792
+ # data are not meaningful with respect to times, acquisition consistency and IRF
793
+ if burst is not None:
794
+ burst_boundaries = [
795
+ sum(self.lines_per_burst[:burst]),
796
+ 0,
797
+ self.lines_per_burst[burst],
798
+ self._channel.raster_info.samples.length,
799
+ ]
800
+
801
+ # validating target block extraction with respect to raster boundaries and burst boundaries
802
+ roi_validation(
803
+ roi=target_block,
804
+ raster_boundaries=raster_boundaries,
805
+ burst_boundaries=burst_boundaries,
806
+ )
807
+
808
+ # reading data portion and switching to convention (samples, lines) with transpose
809
+ data = read_channel_data(
810
+ raster_file=self._raster_file,
811
+ block_to_read=target_block,
812
+ scaling_conversion=self._channel.image_calibration_factor,
813
+ ).T
814
+
815
+ # converting to beta nought if radiometric quantity is different
816
+ if self._radiometric_quantity != output_radiometric_quantity:
817
+ azimuth_time, _ = self.pixel_to_times_conversion(azimuth_index=azimuth_index, range_index=range_index)
818
+ incidence_angles_rad = compute_incidence_angles(
819
+ trajectory=self.trajectory,
820
+ azimuth_time=azimuth_time,
821
+ range_times=self.slant_range_axis[target_block[1] : target_block[1] + target_block[3]],
822
+ look_direction=self.looking_side.value,
823
+ )
824
+ data = radiometric_correction(
825
+ data=data,
826
+ incidence_angle=incidence_angles_rad,
827
+ input_quantity=self._radiometric_quantity,
828
+ output_quantity=output_radiometric_quantity,
829
+ )
830
+
831
+ return data
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: sct_eos04_reader
3
+ Version: 1.0.0
4
+ Summary: SCT (SAR Calibration Toolbox) plugin for reading EOS-04 Level 1 product format.
5
+ Project-URL: Homepage, https://github.com/aresys-srl/sct_plugins
6
+ Project-URL: Repository, https://github.com/aresys-srl/sct_plugins
7
+ Project-URL: Documentation, https://aresys-srl.github.io/sct_plugins
8
+ Author-email: "Aresys S.R.L." <info@aresys.it>
9
+ License: MIT License
10
+
11
+ Copyright (C) Aresys S.r.l. <info@aresys.it>
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE.txt
31
+ Classifier: Development Status :: 5 - Production/Stable
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: Intended Audience :: Education
34
+ Classifier: Intended Audience :: Science/Research
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Natural Language :: English
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Programming Language :: Python :: 3.14
43
+ Classifier: Topic :: Scientific/Engineering
44
+ Classifier: Topic :: Software Development :: Libraries
45
+ Classifier: Topic :: Utilities
46
+ Classifier: Typing :: Typed
47
+ Requires-Python: >=3.11
48
+ Requires-Dist: eo-products==1.0.0
49
+ Requires-Dist: numpy>2
50
+ Requires-Dist: perseo-core==1.0.0
51
+ Requires-Dist: perseo-quality==1.0.0
52
+ Requires-Dist: scipy
53
+ Requires-Dist: sct
54
+ Requires-Dist: shapely>=2.1.0
55
+ Provides-Extra: dev
56
+ Requires-Dist: nox; extra == 'dev'
57
+ Requires-Dist: pylint; extra == 'dev'
58
+ Requires-Dist: ruff; extra == 'dev'
59
+ Provides-Extra: docs
60
+ Requires-Dist: mkdocstrings-python; extra == 'docs'
61
+ Requires-Dist: zensical; extra == 'docs'
62
+ Provides-Extra: test
63
+ Requires-Dist: pytest; extra == 'test'
64
+ Requires-Dist: pytest-cov; extra == 'test'
65
+ Requires-Dist: sct[graphs]; extra == 'test'
66
+ Description-Content-Type: text/markdown
67
+
68
+ # SCT Plugin: EOS-04 format reader
69
+
70
+ [![PyPI version](https://img.shields.io/pypi/v/sct-eos04-reader)](https://pypi.org/project/sct-eos04-reader/)
71
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://python.org)
72
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt)
73
+
74
+ [![CI](https://github.com/aresys-srl/sct_plugins/actions/workflows/eos04.yml/badge.svg)](https://github.com/aresys-srl/sct_plugins/actions/workflows/eos04.yml)
75
+
76
+ [SCT (SAR Calibration Toolbox)](https://github.com/aresys-srl/sct) plugin for reading EOS-04
77
+ Level 1 products, both L1A (SLC) and L1B (GRD). This package integrates with SCT through its
78
+ input products plugin system, enabling all SCT analyses on EOS-04 data.
79
+
80
+ ## Supported Acquisition Modes
81
+
82
+ - **Scansar**
83
+
84
+ ## Installation
85
+
86
+ ```bash
87
+ pip install sct-eos04-reader
88
+ ```
89
+
90
+ SCT is automatically installed as a dependency.
91
+
92
+ ## Compatibility
93
+
94
+ This plugin must be installed in the same Python environment as SCT. Once installed,
95
+ the plugin is automatically discovered and registered by SCT through its entry-point
96
+ based plugin system; no additional configuration is required.
97
+
98
+ ## Documentation
99
+
100
+ - [SCT documentation](https://aresys-srl.github.io/sct/)
101
+ - [Plugins documentation](https://aresys-srl.github.io/sct_plugins)
102
+
103
+ ## License
104
+
105
+ This project is licensed under the MIT License. See the [LICENSE.txt](LICENSE.txt) file for details.
106
+
107
+ Copyright &copy; 2026-present Aresys S.r.l. <info@aresys.it>
@@ -0,0 +1,8 @@
1
+ sct_eos04_reader/__init__.py,sha256=_EBlCezaQ2zFDbyns494eM-FgTaaJQL3h13FGW5TIwg,160
2
+ sct_eos04_reader/interface.py,sha256=CDHJ4LFqWAKV59NvCbsx1Q90fZ9GthXM4UTAlqvy_uE,932
3
+ sct_eos04_reader/protocol_implementation.py,sha256=3gzGZrik-0VcTOAdi4ih7MkXTFknWHvidDLx7yjKJ14,30764
4
+ sct_eos04_reader-1.0.0.dist-info/METADATA,sha256=8mz8tfLBaRLmP-2X4GTngLbIehm8DVipe4VLZ2VXTWQ,4563
5
+ sct_eos04_reader-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ sct_eos04_reader-1.0.0.dist-info/entry_points.txt,sha256=zp_B8B-ghGk6D7IRtVE4rkfeiRKVx0Ek5BLtVS7U5KM,75
7
+ sct_eos04_reader-1.0.0.dist-info/licenses/LICENSE.txt,sha256=5-eKUp948IoydappLufHc2oAqasNGKhXpIB3qqQvXlY,1082
8
+ sct_eos04_reader-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [sct.input_products]
2
+ eos04 = sct_eos04_reader.interface:EOS04ProductPlugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (C) Aresys S.r.l. <info@aresys.it>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.