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
@@ -0,0 +1,652 @@
1
+ """NISAR Product Reader and Cutter Utilities.
2
+
3
+ This module provides tools for reading NISAR GSLC (Geocoded Single Look Complex)
4
+ products and subsetting them based on WKT polygon geometries.
5
+
6
+ Classes:
7
+ NISARMetadata: Metadata container for NISAR products
8
+ NISARReader: Reader for NISAR HDF5 products
9
+ NISARCutter: Polygon-based product subsetting tool
10
+
11
+ Example:
12
+ >>> from nisar_utils import NISARReader, NISARCutter
13
+ >>>
14
+ >>> # Read product
15
+ >>> reader = NISARReader('nisar_product.h5')
16
+ >>> info = reader.info()
17
+ >>>
18
+ >>> # Cut by WKT polygon
19
+ >>> cutter = NISARCutter(reader)
20
+ >>> wkt_polygon = 'POLYGON((x1 y1, x2 y2, x3 y3, x4 y4, x1 y1))'
21
+ >>> subset = cutter.cut_by_wkt(wkt_polygon, 'HH')
22
+ >>> cutter.save_subset(subset, 'output.tif')
23
+ """
24
+
25
+ import h5py
26
+ import numpy as np
27
+ from typing import Dict, List, Tuple, Optional, Union
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ import rasterio
31
+ from rasterio.transform import Affine
32
+ from shapely import wkt
33
+ from shapely.geometry import Polygon
34
+ try:
35
+ from shapely.vectorized import contains
36
+ except ImportError:
37
+ from shapely import contains_xy as contains
38
+
39
+
40
+ @dataclass
41
+ class NISARMetadata:
42
+ """Metadata container for NISAR GSLC products.
43
+
44
+ Attributes:
45
+ product_path: Path to the NISAR product file
46
+ frequency: Frequency band (e.g., 'frequencyA')
47
+ polarizations: List of available polarizations
48
+ grid_name: Grid name
49
+ shape: Data shape as (rows, cols)
50
+ epsg: EPSG code for coordinate system
51
+ x_spacing: Pixel spacing in X direction
52
+ y_spacing: Pixel spacing in Y direction
53
+ x_min: Minimum X coordinate
54
+ y_min: Minimum Y coordinate
55
+ x_max: Maximum X coordinate
56
+ y_max: Maximum Y coordinate
57
+ """
58
+
59
+ product_path: str
60
+ frequency: str
61
+ polarizations: List[str]
62
+ grid_name: str
63
+ shape: Tuple[int, int]
64
+ epsg: Optional[int]
65
+ x_spacing: float
66
+ y_spacing: float
67
+ x_min: float
68
+ y_min: float
69
+ x_max: float
70
+ y_max: float
71
+
72
+ @property
73
+ def bounds(self) -> Tuple[float, float, float, float]:
74
+ """Return bounds as (xmin, ymin, xmax, ymax)."""
75
+ return (self.x_min, self.y_min, self.x_max, self.y_max)
76
+
77
+ @property
78
+ def transform(self) -> Affine:
79
+ """Return affine transform for georeferencing."""
80
+ return Affine.translation(self.x_min, self.y_max) * Affine.scale(
81
+ self.x_spacing, -abs(self.y_spacing)
82
+ )
83
+
84
+
85
+ class NISARReader:
86
+ """Reader for NISAR GSLC (Geocoded Single Look Complex) products.
87
+
88
+ This class provides methods to read NISAR HDF5 products and extract
89
+ geocoded SAR data with proper geospatial metadata.
90
+
91
+ Args:
92
+ product_path: Path to the NISAR HDF5 product file
93
+
94
+ Example:
95
+ >>> reader = NISARReader('nisar_product.h5')
96
+ >>> metadata = reader.get_metadata()
97
+ >>> data = reader.read_data('HH')
98
+ """
99
+
100
+ def __init__(self, product_path: Union[str, Path]):
101
+ """Initialize NISAR reader with product path.
102
+
103
+ Args:
104
+ product_path: Path to NISAR HDF5 file
105
+
106
+ Raises:
107
+ FileNotFoundError: If product file does not exist
108
+ """
109
+ self.product_path = Path(product_path)
110
+ if not self.product_path.exists():
111
+ raise FileNotFoundError(f'Product file not found: {self.product_path}')
112
+
113
+ self._file = None
114
+ self._metadata_cache = {}
115
+
116
+ def __enter__(self):
117
+ """Context manager entry."""
118
+ self._file = h5py.File(self.product_path, 'r')
119
+ return self
120
+
121
+ def __exit__(self, exc_type, exc_val, exc_tb):
122
+ """Context manager exit."""
123
+ if self._file is not None:
124
+ self._file.close()
125
+ self._file = None
126
+
127
+ def _get_science_path(self, frequency: str = 'frequencyA') -> str:
128
+ """Construct path to science data.
129
+
130
+ Args:
131
+ frequency: Frequency band
132
+
133
+ Returns:
134
+ HDF5 path string
135
+ """
136
+ return f'science/LSAR/GSLC/grids/{frequency}'
137
+
138
+ def get_available_polarizations(self, frequency: str = 'frequencyA') -> List[str]:
139
+ """Get list of available polarizations in the product.
140
+
141
+ Args:
142
+ frequency: Frequency band (default: 'frequencyA')
143
+
144
+ Returns:
145
+ List of polarization strings (e.g., ['HH', 'HV', 'VH', 'VV'])
146
+ """
147
+ with h5py.File(self.product_path, 'r') as f:
148
+ science_path = self._get_science_path(frequency)
149
+ polarizations = []
150
+
151
+ if science_path in f:
152
+ for key in f[science_path].keys():
153
+ if key in ['HH', 'HV', 'VH', 'VV']:
154
+ polarizations.append(key)
155
+
156
+ return sorted(polarizations)
157
+
158
+ def get_metadata(self, frequency: str = 'frequencyA') -> NISARMetadata:
159
+ """Extract metadata from NISAR product.
160
+
161
+ Args:
162
+ frequency: Frequency band (default: 'frequencyA')
163
+
164
+ Returns:
165
+ NISARMetadata object containing product metadata
166
+
167
+ Raises:
168
+ ValueError: If no polarizations found in product
169
+ """
170
+ if frequency in self._metadata_cache:
171
+ return self._metadata_cache[frequency]
172
+
173
+ with h5py.File(self.product_path, 'r') as f:
174
+ science_path = self._get_science_path(frequency)
175
+
176
+ # Get polarizations
177
+ polarizations = self.get_available_polarizations(frequency)
178
+
179
+ if not polarizations:
180
+ raise ValueError(f'No polarizations found at {science_path}')
181
+
182
+ # Read first polarization to get shape
183
+ first_pol = polarizations[0]
184
+ data_path = f'{science_path}/{first_pol}'
185
+ dataset = f[data_path]
186
+ shape = dataset.shape
187
+
188
+ # Get coordinate information
189
+ x_coords_path = f'{science_path}/xCoordinates'
190
+ y_coords_path = f'{science_path}/yCoordinates'
191
+
192
+ if x_coords_path in f and y_coords_path in f:
193
+ x_coords = f[x_coords_path][:]
194
+ y_coords = f[y_coords_path][:]
195
+
196
+ x_min, x_max = float(x_coords.min()), float(x_coords.max())
197
+ y_min, y_max = float(y_coords.min()), float(y_coords.max())
198
+
199
+ # Calculate spacing
200
+ x_spacing = (x_max - x_min) / (shape[1] - 1) if shape[1] > 1 else 0
201
+ y_spacing = (y_max - y_min) / (shape[0] - 1) if shape[0] > 1 else 0
202
+ else:
203
+ # Fallback if coordinates not found
204
+ x_min = y_min = 0.0
205
+ x_max = float(shape[1])
206
+ y_max = float(shape[0])
207
+ x_spacing = y_spacing = 1.0
208
+
209
+ # Try to get EPSG code
210
+ epsg = None
211
+ projection_path = f'{science_path}/projection'
212
+ if projection_path in f:
213
+ proj_group = f[projection_path]
214
+ if 'epsg' in proj_group.attrs:
215
+ epsg = int(proj_group.attrs['epsg'])
216
+
217
+ metadata = NISARMetadata(
218
+ product_path=str(self.product_path),
219
+ frequency=frequency,
220
+ polarizations=polarizations,
221
+ grid_name=frequency,
222
+ shape=shape,
223
+ epsg=epsg,
224
+ x_spacing=x_spacing,
225
+ y_spacing=y_spacing,
226
+ x_min=x_min,
227
+ y_min=y_min,
228
+ x_max=x_max,
229
+ y_max=y_max
230
+ )
231
+
232
+ self._metadata_cache[frequency] = metadata
233
+ return metadata
234
+
235
+ def read_data(
236
+ self,
237
+ polarization: str,
238
+ frequency: str = 'frequencyA',
239
+ window: Optional[Tuple[Tuple[int, int], Tuple[int, int]]] = None
240
+ ) -> np.ndarray:
241
+ """Read SAR data for a specific polarization.
242
+
243
+ Args:
244
+ polarization: Polarization to read (e.g., 'HH', 'HV', 'VH', 'VV')
245
+ frequency: Frequency band (default: 'frequencyA')
246
+ window: Optional window to read as ((row_start, row_stop), (col_start, col_stop))
247
+
248
+ Returns:
249
+ Complex numpy array containing SAR data
250
+
251
+ Raises:
252
+ ValueError: If polarization not found in product
253
+ """
254
+ with h5py.File(self.product_path, 'r') as f:
255
+ science_path = self._get_science_path(frequency)
256
+ data_path = f'{science_path}/{polarization}'
257
+
258
+ if data_path not in f:
259
+ raise ValueError(f'Polarization {polarization} not found at {data_path}')
260
+
261
+ dataset = f[data_path]
262
+
263
+ if window is not None:
264
+ (row_start, row_stop), (col_start, col_stop) = window
265
+ data = dataset[row_start:row_stop, col_start:col_stop]
266
+ else:
267
+ data = dataset[:]
268
+
269
+ return data
270
+
271
+ def get_coordinates(
272
+ self,
273
+ frequency: str = 'frequencyA'
274
+ ) -> Tuple[np.ndarray, np.ndarray]:
275
+ """Get X and Y coordinate arrays.
276
+
277
+ Args:
278
+ frequency: Frequency band (default: 'frequencyA')
279
+
280
+ Returns:
281
+ Tuple of (x_coordinates, y_coordinates) as 1D arrays
282
+ """
283
+ with h5py.File(self.product_path, 'r') as f:
284
+ science_path = self._get_science_path(frequency)
285
+
286
+ x_coords = f[f'{science_path}/xCoordinates'][:]
287
+ y_coords = f[f'{science_path}/yCoordinates'][:]
288
+
289
+ return x_coords, y_coords
290
+
291
+ def info(self) -> Dict:
292
+ """Get comprehensive product information.
293
+
294
+ Returns:
295
+ Dictionary containing product information
296
+ """
297
+ metadata = self.get_metadata()
298
+
299
+ return {
300
+ 'product_path': metadata.product_path,
301
+ 'frequency': metadata.frequency,
302
+ 'polarizations': metadata.polarizations,
303
+ 'grid': metadata.grid_name,
304
+ 'shape': metadata.shape,
305
+ 'epsg': metadata.epsg,
306
+ 'bounds': metadata.bounds,
307
+ 'x_spacing': metadata.x_spacing,
308
+ 'y_spacing': metadata.y_spacing,
309
+ }
310
+
311
+
312
+ class NISARCutter:
313
+ """Cut/subset NISAR products based on WKT polygons.
314
+
315
+ This class provides functionality to subset NISAR GSLC products
316
+ based on WKT polygon geometries, preserving georeferencing information.
317
+
318
+ Args:
319
+ reader: NISARReader instance
320
+
321
+ Example:
322
+ >>> reader = NISARReader('product.h5')
323
+ >>> cutter = NISARCutter(reader)
324
+ >>> wkt_polygon = 'POLYGON((lon1 lat1, lon2 lat2, ...))'
325
+ >>> subset_data = cutter.cut_by_wkt(wkt_polygon, 'HH')
326
+ """
327
+
328
+ def __init__(self, reader: NISARReader):
329
+ """Initialize cutter with a NISAR reader."""
330
+ self.reader = reader
331
+
332
+ def _wkt_to_polygon(self, wkt_string: str) -> Polygon:
333
+ """Convert WKT string to Shapely Polygon.
334
+
335
+ Args:
336
+ wkt_string: Well-Known Text polygon string
337
+
338
+ Returns:
339
+ Shapely Polygon object
340
+ """
341
+ geom = wkt.loads(wkt_string)
342
+ if not isinstance(geom, Polygon):
343
+ raise ValueError(f'WKT geometry must be a Polygon, got {type(geom).__name__}')
344
+ return geom
345
+
346
+ def _get_pixel_window(
347
+ self,
348
+ polygon: Polygon,
349
+ metadata: NISARMetadata
350
+ ) -> Tuple[Tuple[int, int], Tuple[int, int]]:
351
+ """Calculate pixel window from polygon bounds.
352
+
353
+ Args:
354
+ polygon: Shapely Polygon defining the area of interest
355
+ metadata: NISAR product metadata
356
+
357
+ Returns:
358
+ Window as ((row_start, row_stop), (col_start, col_stop))
359
+ """
360
+ # Get polygon bounds
361
+ minx, miny, maxx, maxy = polygon.bounds
362
+
363
+ # Convert to pixel coordinates
364
+ transform = metadata.transform
365
+ inv_transform = ~transform
366
+
367
+ # Transform polygon bounds to pixel space
368
+ col_min, row_max = inv_transform * (minx, miny)
369
+ col_max, row_min = inv_transform * (maxx, maxy)
370
+
371
+ # Clip to image bounds and ensure integer coordinates
372
+ row_start = max(0, int(np.floor(row_min)))
373
+ row_stop = min(metadata.shape[0], int(np.ceil(row_max)))
374
+ col_start = max(0, int(np.floor(col_min)))
375
+ col_stop = min(metadata.shape[1], int(np.ceil(col_max)))
376
+
377
+ # Ensure valid window
378
+ if row_start >= row_stop or col_start >= col_stop:
379
+ raise ValueError(f'Polygon does not intersect with product bounds: {metadata.bounds}')
380
+
381
+ return ((row_start, row_stop), (col_start, col_stop))
382
+
383
+ def _create_mask(
384
+ self,
385
+ polygon: Polygon,
386
+ window: Tuple[Tuple[int, int], Tuple[int, int]],
387
+ metadata: NISARMetadata
388
+ ) -> np.ndarray:
389
+ """Create binary mask for polygon within window.
390
+
391
+ Args:
392
+ polygon: Shapely Polygon defining the area of interest
393
+ window: Pixel window as ((row_start, row_stop), (col_start, col_stop))
394
+ metadata: NISAR product metadata
395
+
396
+ Returns:
397
+ Boolean mask array
398
+ """
399
+ (row_start, row_stop), (col_start, col_stop) = window
400
+ rows = row_stop - row_start
401
+ cols = col_stop - col_start
402
+
403
+ # Create coordinate grids for the window
404
+ transform = metadata.transform
405
+
406
+ # Create mesh grid of pixel coordinates
407
+ y_indices = np.arange(row_start, row_stop)
408
+ x_indices = np.arange(col_start, col_stop)
409
+
410
+ # Transform pixel coordinates to geographic coordinates
411
+ xx, yy = np.meshgrid(x_indices, y_indices)
412
+
413
+ # Apply affine transform
414
+ x_coords = transform.c + xx * transform.a + yy * transform.b
415
+ y_coords = transform.f + xx * transform.d + yy * transform.e
416
+
417
+ # Create mask by checking point inclusion in polygon
418
+ mask = np.zeros((rows, cols), dtype=bool)
419
+
420
+ # Vectorized point-in-polygon test
421
+ from shapely.vectorized import contains
422
+ mask = contains(polygon, x_coords.ravel(), y_coords.ravel()).reshape((rows, cols))
423
+
424
+ return mask
425
+
426
+ def cut_by_wkt(
427
+ self,
428
+ wkt_polygon: str,
429
+ polarization: str,
430
+ frequency: str = 'frequencyA',
431
+ apply_mask: bool = True
432
+ ) -> Dict[str, Union[np.ndarray, NISARMetadata, Affine]]:
433
+ """Cut NISAR product by WKT polygon.
434
+
435
+ Args:
436
+ wkt_polygon: Well-Known Text polygon string defining AOI
437
+ polarization: Polarization to read (e.g., 'HH', 'HV', 'VH', 'VV')
438
+ frequency: Frequency band (default: 'frequencyA')
439
+ apply_mask: If True, apply polygon mask to data (pixels outside polygon set to NaN)
440
+
441
+ Returns:
442
+ Dictionary containing:
443
+ - 'data': Subset SAR data array
444
+ - 'mask': Boolean mask (True inside polygon)
445
+ - 'metadata': Updated metadata for subset
446
+ - 'transform': Affine transform for subset
447
+ - 'window': Pixel window used for extraction
448
+ - 'polygon': Original polygon geometry
449
+ """
450
+ # Parse WKT polygon
451
+ polygon = self._wkt_to_polygon(wkt_polygon)
452
+
453
+ # Get product metadata
454
+ metadata = self.reader.get_metadata(frequency)
455
+
456
+ # Calculate pixel window
457
+ window = self._get_pixel_window(polygon, metadata)
458
+ (row_start, row_stop), (col_start, col_stop) = window
459
+
460
+ # Read data for window
461
+ data = self.reader.read_data(polarization, frequency, window)
462
+
463
+ # Create mask
464
+ mask = self._create_mask(polygon, window, metadata)
465
+
466
+ # Apply mask if requested
467
+ if apply_mask:
468
+ # Convert to float if integer type
469
+ if not np.iscomplexobj(data):
470
+ data = data.astype(float)
471
+ # Create masked array or set values outside polygon to NaN
472
+ masked_data = data.copy()
473
+ if np.iscomplexobj(data):
474
+ masked_data[~mask] = np.nan + 1j * np.nan
475
+ else:
476
+ masked_data[~mask] = np.nan
477
+ data = masked_data
478
+
479
+ # Update transform for subset
480
+ transform = metadata.transform
481
+ subset_transform = transform * Affine.translation(col_start, row_start)
482
+
483
+ # Calculate new bounds
484
+ subset_x_min = metadata.x_min + col_start * metadata.x_spacing
485
+ subset_y_max = metadata.y_max - row_start * abs(metadata.y_spacing)
486
+ subset_x_max = metadata.x_min + col_stop * metadata.x_spacing
487
+ subset_y_min = metadata.y_max - row_stop * abs(metadata.y_spacing)
488
+
489
+ # Create updated metadata for subset
490
+ subset_metadata = NISARMetadata(
491
+ product_path=metadata.product_path,
492
+ frequency=frequency,
493
+ polarizations=metadata.polarizations,
494
+ grid_name=metadata.grid_name,
495
+ shape=data.shape,
496
+ epsg=metadata.epsg,
497
+ x_spacing=metadata.x_spacing,
498
+ y_spacing=metadata.y_spacing,
499
+ x_min=subset_x_min,
500
+ y_min=subset_y_min,
501
+ x_max=subset_x_max,
502
+ y_max=subset_y_max
503
+ )
504
+
505
+ return {
506
+ 'data': data,
507
+ 'mask': mask,
508
+ 'metadata': subset_metadata,
509
+ 'transform': subset_transform,
510
+ 'window': window,
511
+ 'polygon': polygon
512
+ }
513
+
514
+ def save_subset(
515
+ self,
516
+ subset_result: Dict,
517
+ output_path: Union[str, Path],
518
+ driver: str = 'GTiff',
519
+ **kwargs
520
+ ) -> None:
521
+ """Save subset result to a georeferenced file.
522
+
523
+ Args:
524
+ subset_result: Result dictionary from cut_by_wkt()
525
+ output_path: Output file path
526
+ driver: Rasterio driver (default: 'GTiff') or 'HDF5'/'h5' for HDF5 format
527
+ **kwargs: Additional arguments passed to rasterio.open() or h5py.File.create_dataset()
528
+ """
529
+ data = subset_result['data']
530
+ metadata = subset_result['metadata']
531
+ transform = subset_result['transform']
532
+ output_path = Path(output_path)
533
+
534
+ # Check if HDF5 format is requested
535
+ if driver.upper() in ['HDF5', 'H5']:
536
+ self._save_subset_hdf5(subset_result, output_path, **kwargs)
537
+ else:
538
+ self._save_subset_rasterio(subset_result, output_path, driver, **kwargs)
539
+
540
+ print(f'Subset saved to: {output_path}')
541
+
542
+ def _save_subset_hdf5(
543
+ self,
544
+ subset_result: Dict,
545
+ output_path: Path,
546
+ **kwargs
547
+ ) -> None:
548
+ """Save subset result to HDF5 file.
549
+
550
+ Args:
551
+ subset_result: Result dictionary from cut_by_wkt()
552
+ output_path: Output file path
553
+ **kwargs: Additional arguments passed to h5py.File.create_dataset()
554
+ """
555
+ data = subset_result['data']
556
+ metadata = subset_result['metadata']
557
+ transform = subset_result['transform']
558
+ mask = subset_result['mask']
559
+
560
+ with h5py.File(output_path, 'w') as f:
561
+ # Create main data group
562
+ data_group = f.create_group('data')
563
+
564
+ # Save the SAR data
565
+ ds = data_group.create_dataset('array', data=data, **kwargs)
566
+
567
+ # Save mask
568
+ mask_ds = data_group.create_dataset('mask', data=mask, compression='gzip')
569
+
570
+ # Save metadata as attributes
571
+ meta_group = f.create_group('metadata')
572
+ meta_group.attrs['product_path'] = str(metadata.product_path)
573
+ meta_group.attrs['frequency'] = metadata.frequency
574
+ meta_group.attrs['grid_name'] = metadata.grid_name
575
+ meta_group.attrs['shape'] = metadata.shape
576
+ meta_group.attrs['epsg'] = metadata.epsg if metadata.epsg else -1
577
+ meta_group.attrs['x_spacing'] = metadata.x_spacing
578
+ meta_group.attrs['y_spacing'] = metadata.y_spacing
579
+ meta_group.attrs['x_min'] = metadata.x_min
580
+ meta_group.attrs['y_min'] = metadata.y_min
581
+ meta_group.attrs['x_max'] = metadata.x_max
582
+ meta_group.attrs['y_max'] = metadata.y_max
583
+
584
+ # Save transform as array
585
+ transform_arr = np.array([
586
+ transform.a, transform.b, transform.c,
587
+ transform.d, transform.e, transform.f
588
+ ])
589
+ meta_group.create_dataset('transform', data=transform_arr)
590
+
591
+ # Save polarizations as string array
592
+ if metadata.polarizations:
593
+ dt = h5py.special_dtype(vlen=str)
594
+ pol_ds = meta_group.create_dataset('polarizations',
595
+ (len(metadata.polarizations),),
596
+ dtype=dt)
597
+ for i, pol in enumerate(metadata.polarizations):
598
+ pol_ds[i] = pol
599
+
600
+ def _save_subset_rasterio(
601
+ self,
602
+ subset_result: Dict,
603
+ output_path: Path,
604
+ driver: str,
605
+ **kwargs
606
+ ) -> None:
607
+ """Save subset result using rasterio (GeoTIFF, etc.).
608
+
609
+ Args:
610
+ subset_result: Result dictionary from cut_by_wkt()
611
+ output_path: Output file path
612
+ driver: Rasterio driver
613
+ **kwargs: Additional arguments passed to rasterio.open()
614
+ """
615
+ data = subset_result['data']
616
+ metadata = subset_result['metadata']
617
+ transform = subset_result['transform']
618
+
619
+ # Determine data type and bands
620
+ if np.iscomplexobj(data):
621
+ # Save as two-band (real, imaginary)
622
+ count = 2
623
+ dtype = rasterio.float32
624
+
625
+ # Separate real and imaginary parts
626
+ real_part = np.real(data).astype(np.float32)
627
+ imag_part = np.imag(data).astype(np.float32)
628
+
629
+ write_data = [real_part, imag_part]
630
+ else:
631
+ count = 1
632
+ dtype = data.dtype
633
+ write_data = [data]
634
+
635
+ # Create rasterio profile
636
+ profile = {
637
+ 'driver': driver,
638
+ 'height': data.shape[0],
639
+ 'width': data.shape[1],
640
+ 'count': count,
641
+ 'dtype': dtype,
642
+ 'crs': f'EPSG:{metadata.epsg}' if metadata.epsg else None,
643
+ 'transform': transform,
644
+ 'nodata': np.nan
645
+ }
646
+ profile.update(kwargs)
647
+
648
+ # Write to file
649
+ with rasterio.open(output_path, 'w', **profile) as dst:
650
+ for i, band_data in enumerate(write_data, 1):
651
+ dst.write(band_data, i)
652
+