diff --git a/python/packages/nisar/workflows/static.py b/python/packages/nisar/workflows/static.py index e7165519b..ebf58e9eb 100644 --- a/python/packages/nisar/workflows/static.py +++ b/python/packages/nisar/workflows/static.py @@ -15,7 +15,8 @@ from nisar.static.geo_grid import get_output_geo_grid from nisar.static.geometry_layers import compute_geometry_layers from nisar.static.granule_id import form_granule_id -from nisar.static.layover_shadow_mask import compute_geocoded_layover_shadow_mask +from nisar.static.layover_shadow_mask import \ + compute_geocoded_layover_shadow_mask from nisar.static.logging import get_logger, log_elapsed_time from nisar.static.product import ( build_hdf5_dataset_creation_kwds_dict, @@ -25,7 +26,8 @@ ) from nisar.static.rtc_anf_layers import compute_rtc_anf_layers from nisar.static.runconfig import get_runconfig_params -from nisar.static.util import get_raster_dataset_metadata_item, scratch_directory +from nisar.static.util import get_raster_dataset_metadata_item, \ + scratch_directory from nisar.static.water_mask import binarize_and_reproject_water_mask import isce3 @@ -34,7 +36,8 @@ def run_static_layers_workflow(config_file: os.PathLike | str) -> None: """ - Run the NISAR Static Layers workflow with the specified run configuration file. + Run the NISAR Static Layers workflow with the specified run configuration + file. Will generate a single STATIC HDF5 granule, as specified in the runconfig. @@ -73,43 +76,55 @@ def run_static_layers_workflow(config_file: os.PathLike | str) -> None: geo_grid = get_output_geo_grid(dem_raster=dem_raster, **geo_grid_params) logger.info(f"Output geo grid: {geo_grid}") - # Parse the orbit and attitude data from the input XML files. Crop the data to the - # time interval of interest to avoid possible geo2rdr convergence errors due to - # ambiguity between orbit periods. + # Parse the orbit and attitude data from the input XML files. Crop the + # data to the time interval of interest to avoid possible geo2rdr + # convergence errors due to ambiguity between orbit periods. orbit, attitude = get_cropped_orbit_and_attitude( orbit_xml_file=dynamic_ancillary_files["orbit_xml_file"], pointing_xml_file=dynamic_ancillary_files["pointing_xml_file"], **processing_params["ephemeris"], ) - # Get the Doppler centroid associated with the radar grid. NISAR image grids are - # always zero-Doppler. + # Get the Doppler centroid associated with the radar grid. NISAR image + # grids are always zero-Doppler. img_grid_doppler = isce3.core.LUT2d() - # Estimate the required radar grid spacing necessary to avoid undersampling the - # output geocoded grid. + # Estimate the required radar grid spacing necessary to avoid + # undersampling the output geocoded grid. # XXX: We deliberately don't pass geo2rdr parameters to either - # `infer_radar_grid_spacing_from_geo_grid()` or `get_bounding_radar_grid()` because - # these functions use `geo2rdr_bracket`, which takes different parameters than the - # legacy `geo2rdr` routine that's used by most of the workflow. Exposing both sets - # of parameters would introduce a lot of additional bookkeeping for seemingly little - # benefit. + # `infer_radar_grid_spacing_from_geo_grid()` or `get_bounding_radar_grid()` + # because these functions use `geo2rdr_bracket`, which takes different + # parameters than the legacy `geo2rdr` routine that's used by most of the + # workflow. Exposing both sets of parameters would introduce a lot of + # additional bookkeeping for seemingly little benefit. logger.info("Estimate maximum required radar grid spacing") radar_grid_params = processing_params["radar_grid"] look_side = radar_grid_params["look_side"] wavelength = radar_grid_params["wavelength"] - az_spacing, rg_spacing = isce3.geometry.infer_radar_grid_spacing_from_geo_grid( - geo_grid=geo_grid, - dem=dem, - orbit=orbit, - doppler=img_grid_doppler, - look_side=look_side, - wavelength=wavelength, - **radar_grid_params["spacing"], - ) - # Compute a radar grid whose footprint on the ground encloses the geocoded grid on - # which each output layer is defined. + radar_grid_spacing_params = radar_grid_params["spacing"] + az_spacing = radar_grid_spacing_params["az_spacing"] + rg_spacing = radar_grid_spacing_params["rg_spacing"] + pts_per_side = radar_grid_spacing_params["pts_per_side"] + + if rg_spacing is None or az_spacing is None: + az_spacing_inferred, rg_spacing_inferred = \ + isce3.geometry.infer_radar_grid_spacing_from_geo_grid( + geo_grid=geo_grid, + dem=dem, + orbit=orbit, + doppler=img_grid_doppler, + look_side=look_side, + wavelength=wavelength, + pts_per_side=pts_per_side + ) + if rg_spacing is None: + rg_spacing = rg_spacing_inferred + if az_spacing is None: + az_spacing = az_spacing_inferred + + # Compute a radar grid whose footprint on the ground encloses the geocoded + # grid on which each output layer is defined. logger.info("Compute a radar grid spanning the region of interest") radar_grid = isce3.geometry.get_bounding_radar_grid( geo_grid=geo_grid, @@ -133,14 +148,15 @@ def run_static_layers_workflow(config_file: os.PathLike | str) -> None: **processing_params["doppler"], ) - # Create a (possibly temporary) scratch directory to store intermediate files. + # Create a (possibly temporary) scratch directory to store intermediate + # files. logger.info("Create scratch directory") with scratch_directory( product_paths["scratch_dir"], delete=product_paths["delete_scratch_dir"] ) as scratch_dir: - # Compute static geometry layers (height above ellipsoid, line-of-sight X and Y, - # local incidence angle). Results are stored as GeoTIFF files in the scratch - # directory. + # Compute static geometry layers (height above ellipsoid, + # line-of-sight X and Y, local incidence angle). Results are stored as + # GeoTIFF files in the scratch directory. logger.info("Compute static geometry layers") geo2rdr_params = processing_params["geo2rdr"] with log_elapsed_time(logger.info, "Computing static geometry layers"): @@ -155,13 +171,16 @@ def run_static_layers_workflow(config_file: os.PathLike | str) -> None: dem_interp_method=dem_interp_method, geo2rdr_params=geo2rdr_params, ) - reprojected_dem, los_east, los_north, local_inc_angle = geometry_layers + reprojected_dem, los_east, los_north, local_inc_angle = \ + geometry_layers - # Compute static mask layers (geocoded layover/shadow mask and water mask). + # Compute static mask layers (geocoded layover/shadow mask and water + # mask). # Results are stored as GeoTIFF files in the scratch directory. logger.info("Compute geocoded layover/shadow mask layer") geocode_params = processing_params["geocode"] - with log_elapsed_time(logger.info, "Computing geocoded layover/shadow mask"): + with log_elapsed_time(logger.info, + "Computing geocoded layover/shadow mask"): layover_shadow_mask = compute_geocoded_layover_shadow_mask( radar_grid=radar_grid, orbit=orbit, @@ -179,7 +198,8 @@ def run_static_layers_workflow(config_file: os.PathLike | str) -> None: ) logger.info("Compute re-projected binary water mask layer") - with log_elapsed_time(logger.info, "Computing re-projected binary water mask"): + with log_elapsed_time(logger.info, + "Computing re-projected binary water mask"): binary_water_mask = binarize_and_reproject_water_mask( water_distance_raster_file=water_mask_raster_file, geo_grid=geo_grid, @@ -187,43 +207,48 @@ def run_static_layers_workflow(config_file: os.PathLike | str) -> None: **processing_params["water_mask"], ) - # Compute radiometric terrain correction (RTC) area normalization factor (ANF) - # layers. Results are stored as GeoTIFF files in the scratch directory. + # Compute radiometric terrain correction (RTC) area normalization + # factor (ANF) layers. Results are stored as GeoTIFF files in the + # scratch directory. logger.info("Compute RTC area normalization factor layers") rtc_params = processing_params["rtc"] - with log_elapsed_time(logger.info, "Computing RTC area normalization layers"): - gamma0_to_beta0_factor, gamma0_to_sigma0_factor = compute_rtc_anf_layers( - radar_grid=radar_grid, - orbit=orbit, - native_doppler=native_doppler, - img_grid_doppler=img_grid_doppler, - geo_grid=geo_grid, - dem_raster=dem_raster, - scratch_dir=scratch_dir, - dem_interp_method=dem_interp_method, - geo2rdr_params=geo2rdr_params, - **geocode_params, - **rtc_params, - ) + with log_elapsed_time(logger.info, + "Computing RTC area normalization layers"): + gamma0_to_beta0_factor, gamma0_to_sigma0_factor = \ + compute_rtc_anf_layers( + radar_grid=radar_grid, + orbit=orbit, + native_doppler=native_doppler, + img_grid_doppler=img_grid_doppler, + geo_grid=geo_grid, + dem_raster=dem_raster, + scratch_dir=scratch_dir, + dem_interp_method=dem_interp_method, + geo2rdr_params=geo2rdr_params, + **geocode_params, + **rtc_params, + ) # Infer the orbit pass direction from the orbit velocity vectors. orbit_pass_direction = isce3.core.get_orbit_pass_direction(orbit) - # Pop 'product_counter' from the dict. This parameter is used to form the - # granule ID but doesn't correspond to any dataset in the 'identification' group - # of the product. The other dict contents will be passed as keyword arguments to - # `populate_identification_group()` below. + # Pop 'product_counter' from the dict. This parameter is used to form + # the granule ID but doesn't correspond to any dataset in the + # 'identification' group of the product. The other dict contents will + # be passed as keyword arguments to `populate_identification_group()` + # below. product_counter = primary_executable_params.pop("product_counter") # Get `validity_start_datetime` from the input parameters as a - # `datetime.datetime` object. If it was passed as a non-quoted string in ISO - # 8601 format, `ruamel.yaml` will have already converted it. Otherwise, manually - # convert it here. + # `datetime.datetime` object. If it was passed as a non-quoted string + # in ISO 8601 format, `ruamel.yaml` will have already converted it. + # Otherwise, manually convert it here. validity_start_datetime = primary_executable_params.pop( "validity_start_datetime" ) if not isinstance(validity_start_datetime, datetime): - validity_start_datetime = datetime.fromisoformat(validity_start_datetime) + validity_start_datetime = \ + datetime.fromisoformat(validity_start_datetime) # Get the unique ID of the granule based on the input parameters. radar_band = primary_executable_params["radar_band"] @@ -237,7 +262,8 @@ def run_static_layers_workflow(config_file: os.PathLike | str) -> None: x_posting=abs(geo_grid.spacing_x), y_posting=abs(geo_grid.spacing_y), validity_start_datetime=validity_start_datetime, - composite_release_id=primary_executable_params["composite_release_id"], + composite_release_id=primary_executable_params[ + "composite_release_id"], processing_center=primary_executable_params["processing_center"], product_counter=product_counter, **geometry_params, @@ -262,28 +288,34 @@ def run_static_layers_workflow(config_file: os.PathLike | str) -> None: # Populate global attributes in the root group of the file. logger.info("Populate global HDF5 attributes") product_spec = nisar.products.get_product_spec("STATIC") - nisar.products.populate_global_attrs_from_spec(hdf5_file, product_spec) + nisar.products.populate_global_attrs_from_spec(hdf5_file, + product_spec) # Get the current processing datetime (truncated to integer seconds # precision). - processing_datetime = datetime.now(timezone.utc).replace(microsecond=0) + processing_datetime = \ + datetime.now(timezone.utc).replace(microsecond=0) - # XXX: It's not really obvious what should go in the `zeroDopplerStartTime` - # and `zeroDopplerEndTime` datasets in the 'identification' group. For now, - # we'll use the start & stop time of the radar grid, which is roughly + # XXX: It's not really obvious what should go in the + # `zeroDopplerStartTime` and `zeroDopplerEndTime` datasets in the + # 'identification' group. For now, we'll use the start & stop time + # of the radar grid, which is roughly # analogous what they represent in other NISAR L2 products. - img_grid_start_datetime = radar_grid.ref_epoch + isce3.core.TimeDelta( - radar_grid.sensing_start - ) - img_grid_end_datetime = radar_grid.ref_epoch + isce3.core.TimeDelta( - radar_grid.sensing_stop - ) + img_grid_start_datetime = (radar_grid.ref_epoch + + isce3.core.TimeDelta( + radar_grid.sensing_start)) + img_grid_end_datetime = (radar_grid.ref_epoch + + isce3.core.TimeDelta( + radar_grid.sensing_stop)) # Populate the 'identification' group. logger.info("Populate identification metadata in output HDF5 file") - instrument_group = hdf5_file.create_group(f"/science/{radar_band}SAR") - identification_group = instrument_group.create_group("identification") - bounding_polygon = make_geo_grid_bounding_polygon(geo_grid, dem=dem) + instrument_group = \ + hdf5_file.create_group(f"/science/{radar_band}SAR") + identification_group = \ + instrument_group.create_group("identification") + bounding_polygon = make_geo_grid_bounding_polygon(geo_grid, + dem=dem) populate_identification_group( identification_group=identification_group, product_spec=product_spec, @@ -311,13 +343,15 @@ def run_static_layers_workflow(config_file: os.PathLike | str) -> None: ) # Populate the 'grids' group. - logger.info("Populate raster layers and grid coordinates in output HDF5") + logger.info("Populate raster layers and grid coordinates in output" + " HDF5") grids_group = instrument_group.create_group("STATIC/grids") dataset_creation_kwds = build_hdf5_dataset_creation_kwds_dict( dataset_shape=(geo_grid.length, geo_grid.width), **output_params["dataset"] ) - with log_elapsed_time(logger.info, "Writing raster layers to output HDF5"): + with log_elapsed_time(logger.info, "Writing raster layers to" + " output HDF5"): populate_grids_group( grids_group=grids_group, product_spec=product_spec, @@ -360,18 +394,20 @@ def main(args: Sequence[str] | None = None) -> None: Parameters ---------- args : sequence of str or None, optional - The list of arguments. If None, the argument list is taken from `sys.argv`. - Defaults to None. + The list of arguments. If None, the argument list is taken from + `sys.argv`. Defaults to None. """ # Setup the argument parser. - parser = argparse.ArgumentParser(description="Run the NISAR Static Layers workflow") + parser = argparse.ArgumentParser( + description="Run the NISAR Static Layers workflow") parser.add_argument( "config_file", type=Path, help="Run configuration YAML file for the STATIC workflow", ) - # Parse the arguments and convert the result to a dict of keyword arguments. + # Parse the arguments and convert the result to a dict of keyword + # arguments. kwargs = vars(parser.parse_args(args)) # Run the workflow with the unpacked keyword arguments. diff --git a/share/nisar/defaults/static.yaml b/share/nisar/defaults/static.yaml index 83077376f..791536cf2 100644 --- a/share/nisar/defaults/static.yaml +++ b/share/nisar/defaults/static.yaml @@ -225,6 +225,16 @@ runconfig: # Defaults to 0.24. wavelength: 0.24 spacing: + # [OPTIONAL] Azimuth interval, in seconds. Equivalent to the Pulse + # Repetition Interval (PRI). + # If not provided, it will be inferred from the specified geographic + # grid. If provided, must be a positive value. + az_spacing: + # [OPTIONAL] Slant-range spacing, in meters, of the radar grid. + # Must be a positive value. + # If not provided, it will be inferred from the specified geographic + # grid. If provided, must be a positive value. + rg_spacing: # [OPTIONAL] Side length of the NxN grid of samples used to estimate the # required radar grid pixel spacing necessary to avoid undersampling the # output geocoded grid. diff --git a/share/nisar/schemas/static.yaml b/share/nisar/schemas/static.yaml index 7d20a5670..d0110ac4f 100644 --- a/share/nisar/schemas/static.yaml +++ b/share/nisar/schemas/static.yaml @@ -94,6 +94,8 @@ radar_grid_options: bounding_box: include('radar_grid_bounding_box_options', required=False) radar_grid_spacing_options: + az_spacing: num(min=0.0, required=False) + rg_spacing: num(min=0.0, required=False) pts_per_side: int(min=2, required=False) radar_grid_bounding_box_options: