Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix track number/burst id calculation #77

Merged
merged 36 commits into from
Nov 23, 2022
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
58c0648
add more small testing data
scottstanie Oct 28, 2022
eb8b2f3
make a failing test for anx crossing
scottstanie Oct 28, 2022
556e44d
create methods for burst/track, fill tests
scottstanie Oct 28, 2022
42df314
fix erroneous burst id in existing test
scottstanie Oct 28, 2022
63859d8
add a sample burst db to compare
scottstanie Oct 29, 2022
2c904d5
add script for remaking the burst sample
scottstanie Oct 29, 2022
af6d4ca
add a geometry check for the esa database
scottstanie Oct 29, 2022
53dc5ea
perform test without pandas
scottstanie Oct 29, 2022
0f86d66
codacy items
scottstanie Oct 29, 2022
2ed1623
add the geometry comparison to the other test cases
scottstanie Oct 29, 2022
cbabae1
add two more test cases
scottstanie Nov 1, 2022
98eb819
refactor tests for new cases
scottstanie Nov 1, 2022
dba751c
redo logic for strange track175 case
scottstanie Nov 1, 2022
2daef08
update burst csv
scottstanie Nov 1, 2022
a1e13c4
fix first test problem, cut bursts
scottstanie Nov 1, 2022
2236cf7
get tests working again for track175 case
scottstanie Nov 1, 2022
7e95f3c
fix esa db csv
scottstanie Nov 1, 2022
2382df4
use nsmap instead of long manual urls
scottstanie Nov 1, 2022
9e1b544
remove testing script
scottstanie Nov 1, 2022
1c1dc57
working version on full orbit cycle
scottstanie Nov 1, 2022
5d19f7d
fix tests to check all subswaths
scottstanie Nov 1, 2022
fcf833d
try recursive include for circleci fail, codacy
scottstanie Nov 1, 2022
110b06c
retry manifest
scottstanie Nov 1, 2022
eae3490
Merge branch 'main' into burst-id-fix
scottstanie Nov 16, 2022
23c02d4
add better docstrings, add print missing tiff again, comment for star…
scottstanie Nov 18, 2022
e3cd8be
case sensitivity clarification
scottstanie Nov 18, 2022
c2b8602
revert tests folder back to current version
scottstanie Nov 18, 2022
a43a60b
fix unit test for correct burst id
scottstanie Nov 18, 2022
0fa41e5
Merge branch 'main' into burst-id-fix
scottstanie Nov 18, 2022
cf85764
adjust anx crossing to use mod, add comment on lxml nsmap
scottstanie Nov 18, 2022
84888ce
split out burst id into class
scottstanie Nov 21, 2022
6565058
fix burst load filtering for new class
scottstanie Nov 21, 2022
7cc6150
use the class vars in initialization
scottstanie Nov 21, 2022
ddd1dc1
formatting
scottstanie Nov 21, 2022
b227f84
just use __str__ instead of as_str, adjust as_dict
scottstanie Nov 21, 2022
2db91b1
address comments for clarity
scottstanie Nov 21, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1 +1 @@
include src/s1reader/data/sentinel1_track_burst_id.txt
recursive-include src/s1reader/data/ *
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a note: this will fix #85 by including all the files in the data folder

11 changes: 6 additions & 5 deletions src/s1reader/s1_burst_slc.py
Original file line number Diff line number Diff line change
Expand Up @@ -686,8 +686,9 @@ def eap_compensation_lut(self):
f' IPF version = {self.ipf_version}')

return self.burst_eap.compute_eap_compensation_lut(self.width)
def bbox(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason why we are removing this function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed this once I realized why the border was a list of Polygon's, when it seemed like it was always a list of one single Polygon: opera-adt/burst_db#1

the anti-meridian crossing bursts (international date line) have two polygons, since it's conventional to split the latlon that way, so this function is giving an incorrect bbox for those ones. I can leave it in and submit an issue to correct it in the future if you'd like.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixing it might be as simple as

    return shapely.geometry.MultiPolygon(b.border).bounds

as long as we don't care that it returns longitudes greater than 180:

In [5]: b = s1reader.load_bursts('S1A_IW_SLC__1SDV_20220110T181913_20220110T181938_041402_04EC40_E2D9.zip', None, 1)[0]
In [6]: b.border
Out[6]: [<shapely.geometry.polygon.Polygon object at 0x7fadef947670>, <shapely.geometry.polygon.Polygon object at 0x7fadef944d00>]

In [8]: b.border[0].bounds
Out[8]: (180.0, 52.19675210021637, 180.6997402722514, 52.44622930532407)

In [11]: import shapely.geometry
In [12]: shapely.geometry.MultiPolygon(b.border).bounds
Out[12]: (179.3998234765543, 52.19675210021637, 180.6997402722514, 52.50961732430616)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's submit an issue separately. We do not want to have longitudes greater than 180 (if this is the only fix)

'''Returns the (west, south, east, north) bounding box of the burst.'''
# Uses https://shapely.readthedocs.io/en/stable/manual.html#object.bounds
# Returns a tuple of 4 floats representing (west, south, east, north) in degrees.
return self.border[0].bounds

@property
def relative_orbit_number(self):
'''Returns the relative orbit number of the burst.'''
orbit_number_offset = 73 if self.platform_id == 'S1A' else 202
return (self.abs_orbit_number - orbit_number_offset) % 175 + 1
217 changes: 166 additions & 51 deletions src/s1reader/s1_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ def as_datetime(t_str):
----------
t_str : string
Time string to be parsed. (e.g., "2021-12-10T12:00:0.0")
fmt : string
Format of string provided. Defaults to az time format found in annotation XML.
(e.g., "%Y-%m-%dT%H:%M:%S.%f").

Returns:
------
Expand Down Expand Up @@ -268,23 +265,62 @@ def get_burst_centers_and_boundaries(tree):
return center_pts, boundary_pts

def get_ipf_version(tree: ET):
'''Extract the IPF version from the ET of manifest.safe
'''
# path to xmlData in manifest
xml_meta_path = 'metadataSection/metadataObject/metadataWrap/xmlData'
'''Extract the IPF version from the ET of manifest.safe.

# piecemeal build path to software path to access version attrib
esa_http = '{http://www.esa.int/safe/sentinel-1.0}'
processing = xml_meta_path + f'/{esa_http}processing'
facility = processing + f'/{esa_http}facility'
software = facility + f'/{esa_http}software'
Parameters
----------
tree : xml.etree.ElementTree
ElementTree containing the parsed 'manifest.safe' file.

Returns
-------
ipf_version : version.Version
IPF version of the burst.
'''
# get version from software element
software_elem = tree.find(software)
search_term, nsmap = _get_manifest_pattern(tree, ['processing', 'facility', 'software'])
software_elem = tree.find(search_term, nsmap)
ipf_version = version.parse(software_elem.attrib['version'])

return ipf_version

def get_start_end_track(tree: ET):
'''Extract the start/end relative orbits from manifest.safe file'''
scottstanie marked this conversation as resolved.
Show resolved Hide resolved
search_term, nsmap = _get_manifest_pattern(tree, ['orbitReference', 'relativeOrbitNumber'])
elem_start, elem_end = tree.findall(search_term, nsmap)
return int(elem_start.text), int(elem_end.text)


def _get_manifest_pattern(tree: ET, keys: list):
'''Get the search path to extract data from the ET of manifest.safe.

Parameters
----------
tree : xml.etree.ElementTree
ElementTree containing the parsed 'manifest.safe' file.
keys : list
List of keys to search for in the manifest file.

Returns
-------
str
Search path to extract from the ET of the manifest.safe XML.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice improvement of the function!
Please add description for the returning namespace nsmap

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea, i added a comment and a link to the lxml document where it came from

'''
# https://lxml.de/tutorial.html#namespaces
# Get the namespace from the root element to avoid full urls
# This is a dictionary with a short name containing the labels in the
# XML tree, and the fully qualified URL as the value.
try:
nsmap = tree.nsmap
except AttributeError:
nsmap = tree.getroot().nsmap
# path to xmlData in manifest
xml_meta_path = 'metadataSection/metadataObject/metadataWrap/xmlData'
vbrancat marked this conversation as resolved.
Show resolved Hide resolved
safe_terms = "/".join([f'safe:{key}' for key in keys])
return f'{xml_meta_path}/{safe_terms}', nsmap


def get_path_aux_cal(directory_aux_cal: str, str_annotation: str):
'''
Decide which aux_cal to load
Expand Down Expand Up @@ -323,8 +359,7 @@ def get_path_aux_cal(directory_aux_cal: str, str_annotation: str):
list_aux_cal = glob.glob(f'{directory_aux_cal}/{str_platform}_AUX_CAL_V*.SAFE.zip')

if len(list_aux_cal) == 0:
raise ValueError( 'Cannot find AUX_CAL files from directory: '
f'{directory_aux_cal}')
raise ValueError(f'Cannot find AUX_CAL files from {directory_aux_cal} .')

format_datetime = '%Y%m%dT%H%M%S'

Expand Down Expand Up @@ -457,27 +492,18 @@ def burst_from_xml(annotation_path: str, orbit_path: str, tiff_path: str,
bursts : list
List of Sentinel1BurstSlc objects found in annotation XML.
'''

# a dict where the key is the track number and the value is a list of
# two integers for the start and stop burst number
track_burst_num = get_track_burst_num()

_, tail = os.path.split(annotation_path)
platform_id, swath_name, _, pol = [x.upper() for x in tail.split('-')[:4]]
safe_filename = os.path.basename(annotation_path.split('.SAFE')[0])

# For IW mode, one burst has a duration of ~2.75 seconds and a burst
# overlap of approximately ~0.4 seconds.
# https://sentinels.copernicus.eu/web/sentinel/user-guides/sentinel-1-sar/product-types-processing-levels/level-1
# Additional precision calculated from averaging the differences between
# burst sensing starts in prototyping test data
burst_interval = 2.758277

# parse manifest.safe to retrieve IPF version
manifest_path = os.path.dirname(annotation_path).replace('annotation','') + 'manifest.safe'
with open_method(manifest_path, 'r') as f_manifest:
tree_manfest = ET.parse(f_manifest)
ipf_version = get_ipf_version(tree_manfest)
tree_manifest = ET.parse(f_manifest)
scottstanie marked this conversation as resolved.
Show resolved Hide resolved
ipf_version = get_ipf_version(tree_manifest)
# Parse out the start/end track to determine if we have an
# equator crossing (for the burst_id calculation).
start_track, end_track = get_start_end_track(tree_manifest)

# Load the Product annotation - for EAP calibration
with open_method(annotation_path, 'r') as f_lads:
Expand Down Expand Up @@ -543,7 +569,7 @@ def burst_from_xml(annotation_path: str, orbit_path: str, tiff_path: str,
slant_range_time = float(image_info_element.find('slantRangeTime').text)
ascending_node_time = as_datetime(image_info_element.find('ascendingNodeTime').text)

downlink_element = tree.find('generalAnnotation/downlinkInformationList/downlinkInformation')
downlink_element = tree.find('generalAnnotation/downlinkInformationList/downlinkInformation')
prf_raw_data = float(downlink_element.find('prf').text)
rank = int(downlink_element.find('downlinkValues/rank').text)
range_chirp_ramp_rate = float(downlink_element.find('downlinkValues/txPulseRampRate').text)
Expand All @@ -565,8 +591,6 @@ def burst_from_xml(annotation_path: str, orbit_path: str, tiff_path: str,
range_window_coeff = float(rng_processing_element.find('windowCoefficient').text)

orbit_number = int(tree.find('adsHeader/absoluteOrbitNumber').text)
orbit_number_offset = 73 if platform_id == 'S1A' else 202
track_number = (orbit_number - orbit_number_offset) % 175 + 1

center_pts, boundary_pts = get_burst_centers_and_boundaries(tree)

Expand All @@ -593,28 +617,25 @@ def burst_from_xml(annotation_path: str, orbit_path: str, tiff_path: str,
burst_list_elements = tree.find('swathTiming/burstList')
n_bursts = int(burst_list_elements.attrib['count'])
bursts = [[]] * n_bursts
sensing_starts = [[]] * n_bursts
sensing_times = [[]] * n_bursts

for i, burst_list_element in enumerate(burst_list_elements):
# get burst timing
d_seconds = 0.5 * (n_lines - 1) * azimuth_time_interval
scottstanie marked this conversation as resolved.
Show resolved Hide resolved
# Zero Doppler azimuth time of the first line of this burst
sensing_start = as_datetime(burst_list_element.find('azimuthTime').text)
sensing_starts[i] = sensing_start
sensing_times[i] = as_datetime(burst_list_element.find('sensingTime').text)
dt = sensing_times[i] - ascending_node_time
# local burst_num within one track, starting from 0
burst_num = int((dt.seconds + dt.microseconds / 1e6) // burst_interval)
azimuth_time_mid = sensing_start + datetime.timedelta(seconds=d_seconds)

# convert the local burst_num to the global burst_num, starting from 1
burst_num += track_burst_num[track_number][0]
# Sensing time of the first input line of this burst [UTC]
sensing_time = as_datetime(burst_list_element.find('sensingTime').text)
# Create the burst ID to match the ESA ID scheme
burst_id = get_burst_id(
sensing_time, ascending_node_time, start_track, end_track, swath_name
)

# choose nearest azimuth FM rate
d_seconds = 0.5 * (n_lines - 1) * azimuth_time_interval
sensing_mid = sensing_start + datetime.timedelta(seconds=d_seconds)
az_fm_rate = get_nearest_polynomial(sensing_mid, az_fm_rate_list)
az_fm_rate = get_nearest_polynomial(azimuth_time_mid, az_fm_rate_list)

# choose nearest doppler
poly1d = get_nearest_polynomial(sensing_mid, doppler_list)
poly1d = get_nearest_polynomial(azimuth_time_mid, doppler_list)
lut2d = doppler_poly1d_to_lut2d(poly1d, starting_range,
range_pxl_spacing, (n_lines, n_samples),
azimuth_time_interval)
Expand Down Expand Up @@ -643,9 +664,6 @@ def burst_from_xml(annotation_path: str, orbit_path: str, tiff_path: str,
last_sample = min(last_valid_samples[first_valid_line],
last_valid_samples[last_line])


burst_id = f't{track_number:03d}_{burst_num}_{swath_name.lower()}'

# Extract burst-wise information for Calibration, Noise, and EAP correction
if calibration_annotation is None:
burst_calibration = None
Expand Down Expand Up @@ -785,7 +803,7 @@ def _burst_from_zip(zip_path: str, id_str: str, orbit_path: str, flag_apply_eap:
path : str
Path to zip file.
id_str: str
Identifcation of desired burst. Format: iw[swath_num]-slc-[pol]
Identification of desired burst. Format: iw[swath_num]-slc-[pol]
orbit_path : str
Path the orbit file.

Expand Down Expand Up @@ -819,6 +837,7 @@ def _burst_from_zip(zip_path: str, id_str: str, orbit_path: str, flag_apply_eap:
flag_apply_eap=flag_apply_eap)
return bursts


def _burst_from_safe_dir(safe_dir_path: str, id_str: str, orbit_path: str, flag_apply_eap: bool):
'''Find bursts in a Sentinel-1 SAFE structured directory.

Expand All @@ -827,7 +846,7 @@ def _burst_from_safe_dir(safe_dir_path: str, id_str: str, orbit_path: str, flag_
path : str
Path to SAFE directory.
id_str: str
Identifcation of desired burst. Format: iw[swath_num]-slc-[pol]
Identification of desired burst. Format: iw[swath_num]-slc-[pol]
orbit_path : str
Path the orbit file.

Expand Down Expand Up @@ -866,3 +885,99 @@ def _burst_from_safe_dir(safe_dir_path: str, id_str: str, orbit_path: str, flag_
bursts = burst_from_xml(f_annotation, orbit_path, f_tiff, iw2_f_annotation,
flag_apply_eap=flag_apply_eap)
return bursts


def get_burst_id(
sensing_time: datetime.datetime,
ascending_node_dt: datetime.datetime,
start_track: int,
end_track: int,
subswath_name: str,
) -> int:
scottstanie marked this conversation as resolved.
Show resolved Hide resolved
"""Calculate burst ID and current track number of a burst.

Accounts for equator crossing frames, and uses the ESA convention defined
in the Sentinel-1 Level 1 Detailed Algorithm Definition

Parameters
----------
sensing_time : datetime
Sensing time of the first input line of this burst [UTC]
The XML tag is sensingTime in the annotation file.
ascending_node_dt : datetime
Time of the ascending node prior to the start of the scene.
start_track : int
Relative orbit number at the start of the acquisition, from 1-175.
end_track : int
Relative orbit number at the end of the acquisition.
subswath_name : str, {'IW1', 'IW2', 'IW3'}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this case-sensitive?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, and I'll add a comment saying so

Name of the subswath of the burst (not case sensitive).

Returns
-------
relative_orbit : int
Relative orbit number (track number) at the current burst.
burst_id : int
The burst ID matching ESA's relative numbering scheme.
scottstanie marked this conversation as resolved.
Show resolved Hide resolved

Notes
-----
The `start_track` and `end_track` parameters are used to determine if the
scene crosses the equator. They are the same if the frame does not cross
the equator.

References
----------
ESA Sentinel-1 Level 1 Detailed Algorithm Definition
https://sentinels.copernicus.eu/documents/247904/1877131/S1-TN-MDA-52-7445_Sentinel-1+Level+1+Detailed+Algorithm+Definition_v2-4.pdf/83624863-6429-cfb8-2371-5c5ca82907b8
"""
# Constants in Table 9-7
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering again whether we should have a separate file in the repository where to insert all the constants

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here- I can move these to a constants file if you'd like, but i'm unsure if the constants would be easier to interpret if they're moved away from function that uses them. If we're planning on making a big constants.py then I can start with these

Copy link
Contributor

@LiangJYu LiangJYu Nov 18, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's enough for a dedicated constants file. From a quick glance, I didn't see anything beyond 3 constants below in the repo.

What do you about moving get_burst_id into its own file? A s1_burst_id.py that's something like the following (some inspiration from):

from typing import ClassVar
from dataclasses import dataclass

@dataclass
class S1BurstID:
    T_beam: ClassVar[float] = 2.758273  # interval of one burst [s]
    T_pre: ClassVar[float] = 2.299849  # Preamble time interval [s]
    T_orb: ClassVar[float] = 12 * 24 * 3600 / 175  # Nominal orbit period [s]
    track_number: int
    esa_burst_id: int
    subswath_name: str

    @classmethod
    def params_to_id(cls, 
        sensing_time: datetime.datetime, 
        ascending_node_dt: datetime.datetime,
        start_track: int,
        end_track: int,
        subswath_name: str):
        # do same computations
        return cls(track_numer, esa_burst_id, subswath)

    @property
    def as_str(self):
        return f"t{self.track_number:03d}_{self.esa_burst_id:06d}_{self.subswath_name.lower()}"

We can then change this from str to S1BurstID

Benefits:

  • By importing this module, all the constants as accessible a class properties.
  • Clearer way to import and generate JPL S1 burst IDs without having to dig through s1_reader.py.

T_beam = 2.758273 # interval of one burst [s]
T_pre = 2.299849 # Preamble time interval [s]
T_orb = 12 * 24 * 3600 / 175 # Nominal orbit period [s]

swath_num = int(subswath_name[-1])
# Since we only have access to the current subswath, we need to use the
# burst-to-burst times to figure out
# 1. if IW1 crossed the equator, and
# 2. The mid-burst sensing time for IW2
# IW1 -> IW2 takes ~0.83220 seconds
# IW2 -> IW3 takes ~1.07803 seconds
# IW3 -> IW1 takes ~0.84803 seconds
burst_times = np.array([0.832, 1.078, 0.848])
iw1_start_offsets = [
0,
-burst_times[0],
-burst_times[0] - burst_times[1],
]
offset = iw1_start_offsets[swath_num - 1]
start_iw1 = sensing_time + datetime.timedelta(seconds=offset)

start_iw1_to_mid_iw2 = burst_times[0] + burst_times[1] / 2
mid_iw2 = start_iw1 + datetime.timedelta(seconds=start_iw1_to_mid_iw2)

has_anx_crossing = (end_track == (start_track + 1) % 175)

time_since_anx_iw1 = (start_iw1 - ascending_node_dt).total_seconds()
time_since_anx = (mid_iw2 - ascending_node_dt).total_seconds()

if (time_since_anx_iw1 - T_orb) < 0:
# Less than a full orbit has passed
track_number = start_track
else:
track_number = end_track
# Additional check for scenes which have a given ascending node
# that's more than 1 orbit in the past
if not has_anx_crossing:
time_since_anx = time_since_anx - T_orb

# Eq. 9-89: ∆tb = tb − t_anx + (r - 1)T_orb
# tb: mid-burst sensing time (sensing_time)
# t_anx: ascending node time (ascending_node_dt)
# r: relative orbit number (relative_orbit_start)
dt_b = time_since_anx + (start_track - 1) * T_orb

# Eq. 9-91 : 1 + floor((∆tb − T_pre) / T_beam )
esa_burst_id = 1 + int(np.floor((dt_b - T_pre) / T_beam))
# Form the unique JPL ID by combining track/burst/swath
return f"t{track_number:03d}_{esa_burst_id:06d}_{subswath_name.lower()}"
1 change: 0 additions & 1 deletion src/s1reader/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,3 @@
# latest release version number and date
release_version = release_history[0].version
release_date = release_history[0].date

2 changes: 1 addition & 1 deletion tests/test_bursts.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def test_burst(bursts):
[-2056.701472691132, 353389.9614836443, -54143009.57327797]]

for i, burst in enumerate(bursts):
expected_burst_id = f't071_{151200 + i}_iw3'
expected_burst_id = f't071_{151199 + i}_iw3'
assert burst.burst_id == expected_burst_id
assert burst.i_burst == i
assert burst.abs_orbit_number == 32518
Expand Down