sarpyx 0.1.5__py3-none-any.whl → 0.1.6__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.
Files changed (48) hide show
  1. docs/examples/advanced/batch_processing.py +1 -1
  2. docs/examples/advanced/custom_processing_chains.py +1 -1
  3. docs/examples/advanced/performance_optimization.py +1 -1
  4. docs/examples/basic/snap_integration.py +1 -1
  5. docs/examples/intermediate/quality_assessment.py +1 -1
  6. outputs/baseline/20260205-234828/__init__.py +33 -0
  7. outputs/baseline/20260205-234828/main.py +493 -0
  8. outputs/final/20260205-234851/__init__.py +33 -0
  9. outputs/final/20260205-234851/main.py +493 -0
  10. sarpyx/__init__.py +2 -2
  11. sarpyx/algorithms/__init__.py +2 -2
  12. sarpyx/cli/__init__.py +1 -1
  13. sarpyx/cli/focus.py +3 -5
  14. sarpyx/cli/main.py +106 -7
  15. sarpyx/cli/shipdet.py +1 -1
  16. sarpyx/cli/worldsar.py +549 -0
  17. sarpyx/processor/__init__.py +1 -1
  18. sarpyx/processor/core/decode.py +43 -8
  19. sarpyx/processor/core/focus.py +104 -57
  20. sarpyx/science/__init__.py +1 -1
  21. sarpyx/sla/__init__.py +8 -0
  22. sarpyx/sla/metrics.py +101 -0
  23. sarpyx/{snap → snapflow}/__init__.py +1 -1
  24. sarpyx/snapflow/engine.py +6165 -0
  25. sarpyx/{snap → snapflow}/op.py +0 -1
  26. sarpyx/utils/__init__.py +1 -1
  27. sarpyx/utils/geos.py +652 -0
  28. sarpyx/utils/grid.py +285 -0
  29. sarpyx/utils/io.py +77 -9
  30. sarpyx/utils/meta.py +55 -0
  31. sarpyx/utils/nisar_utils.py +652 -0
  32. sarpyx/utils/rfigen.py +108 -0
  33. sarpyx/utils/wkt_utils.py +109 -0
  34. sarpyx/utils/zarr_utils.py +55 -37
  35. {sarpyx-0.1.5.dist-info → sarpyx-0.1.6.dist-info}/METADATA +9 -5
  36. {sarpyx-0.1.5.dist-info → sarpyx-0.1.6.dist-info}/RECORD +41 -32
  37. {sarpyx-0.1.5.dist-info → sarpyx-0.1.6.dist-info}/WHEEL +1 -1
  38. sarpyx-0.1.6.dist-info/licenses/LICENSE +201 -0
  39. sarpyx-0.1.6.dist-info/top_level.txt +4 -0
  40. tests/test_zarr_compat.py +35 -0
  41. sarpyx/processor/core/decode_v0.py +0 -0
  42. sarpyx/processor/core/decode_v1.py +0 -849
  43. sarpyx/processor/core/focus_old.py +0 -1550
  44. sarpyx/processor/core/focus_v1.py +0 -1566
  45. sarpyx/processor/core/focus_v2.py +0 -1625
  46. sarpyx/snap/engine.py +0 -633
  47. sarpyx-0.1.5.dist-info/top_level.txt +0 -2
  48. {sarpyx-0.1.5.dist-info → sarpyx-0.1.6.dist-info}/entry_points.txt +0 -0
sarpyx/utils/grid.py ADDED
@@ -0,0 +1,285 @@
1
+ # FROM MAJOR TOM
2
+ import numpy as np
3
+ import math
4
+ import pandas as pd
5
+ import geopandas as gpd
6
+ from shapely.geometry import LineString, Polygon
7
+ from tqdm import tqdm
8
+ import re
9
+
10
+
11
+
12
+ class Grid():
13
+
14
+ RADIUS_EQUATOR = 6378.137 # km
15
+
16
+ def __init__(self,dist,latitude_range=(-85,85),longitude_range=(-180,180),utm_definition='bottomleft'):
17
+ self.dist = dist
18
+ self.latitude_range = latitude_range
19
+ self.longitude_range = longitude_range
20
+ self.utm_definition = utm_definition
21
+ self.rows,self.lats = self.get_rows()
22
+ self.points, self.points_by_row = self.get_points()
23
+
24
+ def get_rows(self):
25
+
26
+ # Define set of latitudes to use, based on the grid distance
27
+ arc_pole_to_pole = math.pi * self.RADIUS_EQUATOR
28
+ num_divisions_in_hemisphere = math.ceil(arc_pole_to_pole / self.dist)
29
+
30
+ latitudes = np.linspace(-90, 90, num_divisions_in_hemisphere+1)[:-1]
31
+ latitudes = np.mod(latitudes, 180) - 90
32
+
33
+ # order should be from south to north
34
+ latitudes = np.sort(latitudes)
35
+
36
+ zeroth_row = np.searchsorted(latitudes,0)
37
+
38
+ # From 0U-NU and 1D-ND
39
+ rows = [None] * len(latitudes)
40
+ rows[zeroth_row:] = [f'{i}U' for i in range(len(latitudes)-zeroth_row)]
41
+ rows[:zeroth_row] = [f'{abs(i-zeroth_row)}D' for i in range(zeroth_row)]
42
+
43
+ # bound to range
44
+ idxs = (latitudes>=self.latitude_range[0]) * (latitudes<=self.latitude_range[1])
45
+ rows,latitudes = np.array(rows), np.array(latitudes)
46
+ rows,latitudes = rows[idxs],latitudes[idxs]
47
+
48
+ return rows,latitudes
49
+
50
+ def get_circumference_at_latitude(self,lat):
51
+
52
+ # Circumference of the cross-section of a sphere at a given latitude
53
+
54
+ radius_at_lat = self.RADIUS_EQUATOR * math.cos(lat * math.pi / 180)
55
+ circumference = 2 * math.pi * radius_at_lat
56
+
57
+ return circumference
58
+
59
+ def subdivide_circumference(self,lat,return_cols=False):
60
+ # Provide a list of longitudes that subdivide the circumference of the earth at a given latitude
61
+ # into equal parts as close as possible to dist
62
+
63
+ circumference = self.get_circumference_at_latitude(lat)
64
+ num_divisions = math.ceil(circumference / self.dist)
65
+ longitudes = np.linspace(-180,180, num_divisions+1)[:-1]
66
+ longitudes = np.mod(longitudes, 360) - 180
67
+ longitudes = np.sort(longitudes)
68
+
69
+
70
+ if return_cols:
71
+ cols = [None] * len(longitudes)
72
+ zeroth_idx = np.where(longitudes==0)[0][0]
73
+ cols[zeroth_idx:] = [f'{i}R' for i in range(len(longitudes)-zeroth_idx)]
74
+ cols[:zeroth_idx] = [f'{abs(i-zeroth_idx)}L' for i in range(zeroth_idx)]
75
+ return np.array(cols),np.array(longitudes)
76
+
77
+ return np.array(longitudes)
78
+
79
+ def get_points(self):
80
+
81
+ r_idx = 0
82
+ points_by_row = [None]*len(self.rows)
83
+ for r,lat in zip(self.rows,self.lats):
84
+ point_names,grid_row_names,grid_col_names,grid_row_idx,grid_col_idx,grid_lats,grid_lons,utm_zones,epsgs = [],[],[],[],[],[],[],[],[]
85
+ cols,lons = self.subdivide_circumference(lat,return_cols=True)
86
+
87
+ cols,lons = self.filter_longitude(cols,lons)
88
+ c_idx = 0
89
+ for c,lon in zip(cols,lons):
90
+ point_names.append(f'{r}_{c}')
91
+ grid_row_names.append(r)
92
+ grid_col_names.append(c)
93
+ grid_row_idx.append(r_idx)
94
+ grid_col_idx.append(c_idx)
95
+ grid_lats.append(lat)
96
+ grid_lons.append(lon)
97
+ if self.utm_definition == 'bottomleft':
98
+ utm_zones.append(get_utm_zone_from_latlng([lat,lon]))
99
+ elif self.utm_definition == 'center':
100
+ center_lat = lat + (1000*self.dist/2)/111_120
101
+ center_lon = lon + (1000*self.dist/2)/(111_120*math.cos(center_lat*math.pi/180))
102
+ utm_zones.append(get_utm_zone_from_latlng([center_lat,center_lon]))
103
+ else:
104
+ raise ValueError(f'Invalid utm_definition {self.utm_definition}')
105
+ epsgs.append(f'EPSG:{utm_zones[-1]}')
106
+
107
+ c_idx += 1
108
+ points_by_row[r_idx] = gpd.GeoDataFrame({
109
+ 'name':point_names,
110
+ 'row':grid_row_names,
111
+ 'col':grid_col_names,
112
+ 'row_idx':grid_row_idx,
113
+ 'col_idx':grid_col_idx,
114
+ 'utm_zone':utm_zones,
115
+ 'epsg':epsgs
116
+ },geometry=gpd.points_from_xy(grid_lons,grid_lats))
117
+ r_idx += 1
118
+ points = gpd.GeoDataFrame(pd.concat(points_by_row))
119
+ # points.reset_index(inplace=True,drop=True)
120
+ return points, points_by_row
121
+
122
+ def group_points_by_row(self):
123
+ # Make list of different gdfs for each row
124
+ points_by_row = [None]*len(self.rows)
125
+ for i,row in enumerate(self.rows):
126
+ points_by_row[i] = self.points[self.points.row==row]
127
+ return points_by_row
128
+
129
+ def filter_longitude(self,cols,lons):
130
+ idxs = (lons>=self.longitude_range[0]) * (lons<=self.longitude_range[1])
131
+ cols,lons = cols[idxs],lons[idxs]
132
+ return cols,lons
133
+
134
+ def latlon2rowcol(self,lats,lons,return_idx=False,integer=False):
135
+ """
136
+ Convert latitude and longitude to row and column number from the grid
137
+ """
138
+ # Always take bottom left corner of grid cell
139
+ rows = np.searchsorted(self.lats,lats)-1
140
+
141
+ # Get the possible points of the grid cells at the given latitude
142
+ possible_points = [self.points_by_row[row] for row in rows]
143
+
144
+ # For each point, find the rightmost point that is still to the left of the given longitude
145
+ cols = [poss_points.iloc[np.searchsorted(poss_points.geometry.x,lon)-1].col for poss_points,lon in zip(possible_points,lons)]
146
+ rows = self.rows[rows].tolist()
147
+
148
+ outputs = [rows, cols]
149
+ if return_idx:
150
+ # Get the table index for self.points with each row,col pair in rows, cols
151
+ idx = [self.points[(self.points.row==row) & (self.points.col==col)].index.values[0] for row,col in zip(rows,cols)]
152
+ outputs.append(idx)
153
+
154
+ # return raw numbers
155
+ if integer:
156
+ outputs[0] = [int(el[:-1]) if el[-1] == 'U' else -int(el[:-1]) for el in outputs[0]]
157
+ outputs[1] = [int(el[:-1]) if el[-1] == 'R' else -int(el[:-1]) for el in outputs[1]]
158
+
159
+ return outputs
160
+
161
+ def rowcol2latlon(self,rows,cols):
162
+ point_geoms = [self.points.loc[(self.points.row==row) & (self.points.col==col),'geometry'].values[0] for row,col in zip(rows,cols)]
163
+ lats = [point.y for point in point_geoms]
164
+ lons = [point.x for point in point_geoms]
165
+ return lats,lons
166
+
167
+ def get_bounded_footprint(self,point,buffer_ratio=0):
168
+ # Gets the polygon footprint of the grid cell for a given point, bounded by the other grid points' cells.
169
+ # Grid point defined as bottom-left corner of polygon. Buffer ratio is the ratio of the grid cell's width/height to buffer by.
170
+
171
+ bottom,left = point.geometry.y,point.geometry.x
172
+ row_idx = point.row_idx
173
+ col_idx = point.col_idx
174
+ next_row_idx = row_idx+1
175
+ next_col_idx = col_idx+1
176
+
177
+ if next_row_idx >= len(self.lats): # If at top row, use difference between top and second-to-top row for height
178
+ height = (self.lats[row_idx] - self.lats[row_idx-1])
179
+ top = self.lats[row_idx] + height
180
+ else:
181
+ top = self.lats[next_row_idx]
182
+
183
+ max_col = len(self.points_by_row[row_idx].col_idx)-1
184
+ if next_col_idx > max_col: # If at rightmost column, use difference between rightmost and second-to-rightmost column for width
185
+ width = (self.points_by_row[row_idx].iloc[col_idx].geometry.x - self.points_by_row[row_idx].iloc[col_idx-1].geometry.x)
186
+ right = self.points_by_row[row_idx].iloc[col_idx].geometry.x + width
187
+ else:
188
+ right = self.points_by_row[row_idx].iloc[next_col_idx].geometry.x
189
+
190
+ # Buffer the polygon by the ratio of the grid cell's width/height
191
+ width = right - left
192
+ height = top - bottom
193
+
194
+ buffer_horizontal = width * buffer_ratio
195
+ buffer_vertical = height * buffer_ratio
196
+
197
+ new_left = left - buffer_horizontal
198
+ new_right = right + buffer_horizontal
199
+
200
+ new_bottom = bottom - buffer_vertical
201
+ new_top = top + buffer_vertical
202
+
203
+ bbox = Polygon([(new_left,new_bottom),(new_left,new_top),(new_right,new_top),(new_right,new_bottom)])
204
+
205
+ return bbox
206
+
207
+ def get_utm_zone_from_latlng(latlng):
208
+ """
209
+ Get the UTM zone from a latlng list and return the corresponding EPSG code.
210
+
211
+ Parameters
212
+ ----------
213
+ latlng : List[Union[int, float]]
214
+ The latlng list to get the UTM zone from.
215
+
216
+ Returns
217
+ -------
218
+ str
219
+ The EPSG code for the UTM zone.
220
+ """
221
+ assert isinstance(latlng, (list, tuple)), "latlng must be in the form of a list or tuple."
222
+
223
+ longitude = latlng[1]
224
+ latitude = latlng[0]
225
+
226
+ zone_number = (math.floor((longitude + 180) / 6)) % 60 + 1
227
+
228
+ # Special zones for Svalbard and Norway
229
+ if latitude >= 56.0 and latitude < 64.0 and longitude >= 3.0 and longitude < 12.0:
230
+ zone_number = 32
231
+ elif latitude >= 72.0 and latitude < 84.0:
232
+ if longitude >= 0.0 and longitude < 9.0:
233
+ zone_number = 31
234
+ elif longitude >= 9.0 and longitude < 21.0:
235
+ zone_number = 33
236
+ elif longitude >= 21.0 and longitude < 33.0:
237
+ zone_number = 35
238
+ elif longitude >= 33.0 and longitude < 42.0:
239
+ zone_number = 37
240
+
241
+ # Determine the hemisphere and construct the EPSG code
242
+ if latitude < 0:
243
+ epsg_code = f"327{zone_number:02d}"
244
+ else:
245
+ epsg_code = f"326{zone_number:02d}"
246
+ if not re.match(r"32[6-7](0[1-9]|[1-5][0-9]|60)",epsg_code):
247
+ print(f"latlng: {latlng}, epsg_code: {epsg_code}")
248
+ raise ValueError(f"out of bound latlng resulted in incorrect EPSG code for the point")
249
+
250
+ return epsg_code
251
+
252
+
253
+ if __name__ == '__main__':
254
+
255
+ assert get_utm_zone_from_latlng([-1,-174.34]) == "32701"
256
+ assert get_utm_zone_from_latlng([48,-4]) == "32630"
257
+ assert get_utm_zone_from_latlng([78,13]) == "32633"
258
+ assert get_utm_zone_from_latlng([-34,19.7]) == "32734"
259
+ assert get_utm_zone_from_latlng([-36,175.7]) == "32760"
260
+
261
+
262
+ dist = 10 # 10 KM
263
+ grid = Grid(dist)
264
+
265
+ np.random.seed(0)
266
+ test_lons = np.random.uniform(-20,20,size=(1000)) % 180 # Checks edge-case of crossing 180th meridian
267
+ test_lats = np.random.uniform(-20,68,size=(1000))
268
+
269
+ test_rows,test_cols = grid.latlon2rowcol(test_lats,test_lons)
270
+ test_lats2,test_lons2 = grid.rowcol2latlon(test_rows,test_cols)
271
+
272
+ print(test_lons[:10])
273
+ print(test_lats[:10])
274
+ print(test_rows[:10])
275
+ print(test_cols[:10])
276
+
277
+ # Make line segments from the points to their corresponding grid points
278
+ lines = []
279
+ for i in range(len(test_lats)):
280
+ lines.append([(test_lons[i],test_lats[i]),(test_lons2[i],test_lats2[i])])
281
+
282
+ lines = gpd.GeoDataFrame(geometry=gpd.GeoSeries([LineString(line) for line in lines]))
283
+
284
+ # lines.to_file(f'testlines_{dist}km.geojson',driver='GeoJSON')
285
+ grid.points.to_file(f'grid_{dist}km.geojson',driver='GeoJSON')
sarpyx/utils/io.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import shutil
2
2
  import subprocess
3
+ import h5py
3
4
  from pathlib import Path
4
5
  from typing import Any, Union
5
6
  from zipfile import ZipFile
@@ -9,6 +10,8 @@ from typing import Optional, Tuple, Union, Dict, Any, List, Callable
9
10
  import numpy as np
10
11
  import matplotlib.pyplot as plt
11
12
 
13
+ from .meta import extract_core_metadata_sentinel
14
+
12
15
 
13
16
  # ------- Functions for memory efficiency -------
14
17
  def gc_collect(func: Callable) -> Callable:
@@ -184,12 +187,8 @@ class ArraySlicer:
184
187
  plt.show()
185
188
 
186
189
 
187
- # ------- Functions for file operations -------
188
-
189
-
190
-
191
-
192
190
 
191
+ # ------- Functions for file operations -------
193
192
 
194
193
  def calculate_slice_indices(array_height: int, slice_height: int) -> List[dict]:
195
194
  """Calculate slice indices and drop information for array slicing.
@@ -346,10 +345,31 @@ def calculate_slice_indices(array_height: int, slice_height: int) -> List[dict]:
346
345
  return slice_info_list
347
346
 
348
347
 
349
-
350
-
351
-
352
-
348
+ def _identify_mission_type(filename: str) -> str:
349
+ """Identify the mission type based on filename.
350
+
351
+ Args:
352
+ filename: Name of the product file.
353
+
354
+ Returns:
355
+ Product type string.
356
+
357
+ Raises:
358
+ ValueError: If product type cannot be determined.
359
+ """
360
+ # TODO: double check naming schemes
361
+ if 'BIO' in filename:
362
+ return 'BIOMASS'
363
+ if 'TSX' in filename:
364
+ return 'TerraSAR'
365
+ if 'S1' in filename:
366
+ return 'Sentinel-1'
367
+ elif 'CSK' in filename:
368
+ return 'COSMO-SkyMed'
369
+ elif 'SAO' in filename:
370
+ return 'SAOCOM'
371
+ else:
372
+ raise ValueError(f'Unknown product type for file: {filename}')
353
373
 
354
374
 
355
375
  def save_matlab_mat(data_object: Any, filename: str, filepath: Union[str, Path]) -> bool:
@@ -378,6 +398,7 @@ def save_matlab_mat(data_object: Any, filename: str, filepath: Union[str, Path])
378
398
  print(f"Could not save MATLAB file to {savename}: {e}")
379
399
  return False
380
400
 
401
+
381
402
  def delete(path_to_delete: Union[str, Path]):
382
403
  """Deletes a file or directory.
383
404
 
@@ -393,6 +414,7 @@ def delete(path_to_delete: Union[str, Path]):
393
414
  else:
394
415
  path_to_delete.unlink() # Use unlink for files
395
416
 
417
+
396
418
  def unzip(path_to_zip_file: Union[str, Path]):
397
419
  """Unzips a file to its parent directory.
398
420
 
@@ -404,6 +426,7 @@ def unzip(path_to_zip_file: Union[str, Path]):
404
426
  with ZipFile(zip_path, 'r') as zip_ref:
405
427
  zip_ref.extractall(output_dir)
406
428
 
429
+
407
430
  def delProd(prodToDelete: Union[str, Path]):
408
431
  """Deletes a SNAP product (.dim file and associated .data directory).
409
432
 
@@ -419,6 +442,7 @@ def delProd(prodToDelete: Union[str, Path]):
419
442
  delete(dim_file)
420
443
  delete(data_dir)
421
444
 
445
+
422
446
  def command_line(cmd: str):
423
447
  """Executes a command line process and prints its output.
424
448
 
@@ -437,6 +461,7 @@ def command_line(cmd: str):
437
461
  except FileNotFoundError:
438
462
  print(f"Error: Command not found - ensure the executable is in the system's PATH or provide the full path.")
439
463
 
464
+
440
465
  def iterNodes(root, val_dict: dict) -> dict:
441
466
  """Recursively iterates through XML nodes and extracts tag/text pairs.
442
467
 
@@ -461,6 +486,7 @@ def iterNodes(root, val_dict: dict) -> dict:
461
486
 
462
487
  return val_dict
463
488
 
489
+
464
490
  def find_dat_file(folder: Path, pol: str) -> Path:
465
491
  """
466
492
  Find the .dat file in a SAFE folder for a specific polarization using recursive search.
@@ -483,3 +509,45 @@ def find_dat_file(folder: Path, pol: str) -> Path:
483
509
  return file
484
510
 
485
511
  raise FileNotFoundError(f'No valid .dat file found in {folder} for polarization {pol}')
512
+
513
+
514
+ # =====================================================================
515
+ # Product Readers
516
+ def read_h5(file_path: str) -> tuple[dict, dict]:
517
+ """Read an HDF5 file and return its contents and metadata as dictionaries.
518
+
519
+ Args:
520
+ file_path: Path to the HDF5 file to read.
521
+
522
+ Returns:
523
+ Tuple containing:
524
+ - Dictionary with datasets and their values from the HDF5 file
525
+ - Dictionary with metadata (attributes) from the HDF5 file
526
+
527
+ Raises:
528
+ FileNotFoundError: If the file doesn't exist.
529
+ OSError: If the file cannot be opened or read.
530
+ """
531
+ data = {}
532
+ metadata = {}
533
+
534
+ with h5py.File(file_path, 'r') as h5_file:
535
+ # Extract root attributes
536
+ metadata['root'] = dict(h5_file.attrs)
537
+
538
+ def extract_data(name, obj):
539
+ if isinstance(obj, h5py.Dataset):
540
+ data[name] = obj[()]
541
+ # Extract dataset attributes
542
+ if obj.attrs:
543
+ metadata[name] = dict(obj.attrs)
544
+ elif isinstance(obj, h5py.Group):
545
+ # Extract group attributes
546
+ if obj.attrs:
547
+ metadata[name] = dict(obj.attrs)
548
+
549
+ h5_file.visititems(extract_data)
550
+
551
+ quickinfo = extract_core_metadata_sentinel(metadata.get('metadata/Abstracted_Metadata', {}))
552
+ metadata['quickinfo'] = quickinfo
553
+ return data, metadata
sarpyx/utils/meta.py ADDED
@@ -0,0 +1,55 @@
1
+ # =====================================================================
2
+ # Sentinel-1 core metadata extraction
3
+ # =====================================================================
4
+ def extract_core_metadata_sentinel(md: dict) -> dict:
5
+ """
6
+ Extract a minimal, cross-mission-relevant SAR metadata subset
7
+ for geospatial foundation models.
8
+
9
+ Args:
10
+ md (dict): Metadata dictionary containing SAR metadata.
11
+
12
+ Returns:
13
+ dict: A dictionary containing the extracted metadata subset with the following keys:
14
+ - MISSION
15
+ - ACQUISITION_MODE
16
+ - PRODUCT_TYPE
17
+ - radar_frequency
18
+ - pulse_repetition_frequency
19
+ - range_spacing
20
+ - azimuth_spacing
21
+ - range_bandwidth
22
+ - azimuth_bandwidth
23
+ - PASS
24
+ - avg_scene_height
25
+ """
26
+ def _decode(v):
27
+ # SNAP often stores strings as bytes
28
+ if isinstance(v, (bytes, bytearray)):
29
+ return v.decode('utf-8')
30
+ return v
31
+
32
+ keys = [
33
+ 'MISSION',
34
+ 'ACQUISITION_MODE',
35
+ 'PRODUCT_TYPE',
36
+ 'radar_frequency',
37
+ 'pulse_repetition_frequency',
38
+ 'range_spacing',
39
+ 'azimuth_spacing',
40
+ 'range_bandwidth',
41
+ 'azimuth_bandwidth',
42
+ 'antenna_pointing',
43
+ 'PASS',
44
+ 'avg_scene_height',
45
+ 'PRODUCT',
46
+ 'mds1_tx_rx_polar',
47
+ 'mds2_tx_rx_polar',
48
+ 'first_line_time',
49
+ ]
50
+
51
+ return {
52
+ k: _decode(md.get(k))
53
+ for k in keys
54
+ if k in md
55
+ }