Skip to content

Commit 2ab7ad5

Browse files
authored
Merge pull request #50 from forrestfwilliams/develop
Release v0.4.0
2 parents 0297342 + ee1a05f commit 2ab7ad5

8 files changed

Lines changed: 338 additions & 70 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/)
77
and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

9+
## [0.4.0]
10+
11+
### Changed
12+
* For the S1RTC platform `chipdata` now processes and download all nessary RTC prior to getting individual chip data.
13+
14+
### Fixed
15+
* Fix fmask path when chipping HLS data
16+
917
## [0.3.0]
1018

1119
### Added

requirements-static.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
ruff==0.12.12
2-
mypy==1.17.1
1+
ruff==0.13.2
2+
mypy==1.18.2

src/satchip/chip_data.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,11 @@
1010
import satchip
1111
from satchip import utils
1212
from satchip.chip_hls import get_hls_data
13-
from satchip.chip_sentinel1rtc import get_s1rtc_data
13+
from satchip.chip_sentinel1rtc import get_rtc_paths_for_chips, get_s1rtc_chip_data
1414
from satchip.chip_sentinel2 import get_s2l2a_data
1515
from satchip.terra_mind_grid import TerraMindGrid
1616

1717

18-
GET_DATA_FNS = {'S2L2A': get_s2l2a_data, 'S1RTC': get_s1rtc_data, 'HLS': get_hls_data}
19-
20-
2118
def fill_missing_times(data_chip: xr.DataArray, times: np.ndarray) -> xr.DataArray:
2219
missing_times = np.setdiff1d(times, data_chip.time.data)
2320
missing_shape = (len(missing_times), len(data_chip.band), data_chip.y.size, data_chip.x.size)
@@ -42,28 +39,35 @@ def chip_data(
4239
strategy: str,
4340
max_cloud_pct: int,
4441
output_dir: Path,
45-
scratch_dir: Path | None = None,
42+
scratch_dir: Path,
4643
) -> xr.Dataset:
47-
get_data_fn = GET_DATA_FNS[platform]
4844
labels = utils.load_chip(label_path)
4945
date = labels.time.data[0].astype('M8[ms]').astype(datetime)
5046
bounds = labels.attrs['bounds']
47+
5148
grid = TerraMindGrid([bounds[1] - 1, bounds[3] + 1], [bounds[0] - 1, bounds[2] + 1]) # type: ignore
5249
terra_mind_chips = [c for c in grid.terra_mind_chips if c.name in list(labels.sample.data)]
5350

54-
opts = {'strategy': strategy, 'date_start': date_start, 'date_end': date_end}
51+
opts: utils.ChipDataOpts = {'strategy': strategy, 'date_start': date_start, 'date_end': date_end}
5552
if platform in ['S2L2A', 'HLS']:
5653
opts['max_cloud_pct'] = max_cloud_pct
5754

55+
if platform == 'S1RTC':
56+
rtc_paths_for_chips = get_rtc_paths_for_chips(terra_mind_chips, bounds, scratch_dir, opts)
57+
5858
data_chips = []
59-
if scratch_dir is not None:
60-
for chip in tqdm(terra_mind_chips):
61-
data_chips.append(get_data_fn(chip, scratch_dir, opts=opts))
62-
else:
63-
with TemporaryDirectory() as tmp_dir:
64-
scratch_dir = Path(tmp_dir)
65-
for chip in tqdm(terra_mind_chips):
66-
data_chips.append(get_data_fn(chip, scratch_dir, opts=opts))
59+
for chip in tqdm(terra_mind_chips):
60+
if platform == 'S1RTC':
61+
rtc_paths = rtc_paths_for_chips[chip.name]
62+
chip_data = get_s1rtc_chip_data(chip, rtc_paths, scratch_dir, opts=opts)
63+
elif platform == 'S2L2A':
64+
chip_data = get_s2l2a_data(chip, scratch_dir, opts=opts)
65+
elif platform == 'HLS':
66+
chip_data = get_hls_data(chip, scratch_dir, opts=opts)
67+
else:
68+
raise Exception(f'Unknown platform {platform}')
69+
70+
data_chips.append(chip_data)
6771

6872
times = np.unique(np.concatenate([dc.time.data for dc in data_chips]))
6973
for i, data_chip in enumerate(data_chips):
@@ -99,17 +103,24 @@ def main() -> None:
99103
assert 0 <= args.maxcloudpct <= 100, 'maxcloudpct must be between 0 and 100'
100104
date_start, date_end = [datetime.strptime(d, '%Y%m%d') for d in args.daterange.split('-')]
101105
assert date_start < date_end, 'start date must be before end date'
102-
chip_data(
106+
107+
params = (
103108
args.labelpath,
104109
args.platform,
105110
date_start,
106111
date_end,
107112
args.strategy,
108113
args.maxcloudpct,
109114
args.outdir,
110-
args.scratchdir,
111115
)
112116

117+
if args.scratchdir is not None:
118+
chip_data(*params, args.scratchdir)
119+
else:
120+
with TemporaryDirectory() as tmp_dir:
121+
scratch_dir = Path(tmp_dir)
122+
chip_data(*params, scratch_dir)
123+
113124

114125
if __name__ == '__main__':
115126
main()

src/satchip/chip_hls.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import xarray as xr
99
from earthaccess.results import DataGranule
1010

11+
from satchip import utils
1112
from satchip.chip_xr_base import create_template_da
1213
from satchip.terra_mind_grid import TerraMindChip
1314

@@ -76,7 +77,7 @@ def get_scenes(
7677
n_products = len(list(scratch_dir.glob(f'{product_id}*')))
7778
if n_products < 15:
7879
earthaccess.download([item], scratch_dir, pqdm_kwargs={'disable': True})
79-
fmask_path = scratch_dir / f'{product_id}.v2.0.FMask.tif'
80+
fmask_path = scratch_dir / f'{product_id}.v2.0.Fmask.tif'
8081
assert fmask_path.exists(), f'File not found: {fmask_path}'
8182
qual_da = rioxarray.open_rasterio(fmask_path).rio.clip_box(*roi.bounds, crs='EPSG:4326') # type: ignore
8283
bit_masks = np.unpackbits(qual_da.data[0][..., np.newaxis], axis=-1)
@@ -93,7 +94,7 @@ def get_scenes(
9394
return valid_scenes
9495

9596

96-
def get_hls_data(chip: TerraMindChip, scratch_dir: Path, opts: dict) -> xr.DataArray:
97+
def get_hls_data(chip: TerraMindChip, scratch_dir: Path, opts: utils.ChipDataOpts) -> xr.DataArray:
9798
"""Returns XArray DataArray of a Harmonized Landsat Sentinel-2 image for the given bounds and
9899
closest collection after date.
99100
"""

src/satchip/chip_sentinel1rtc.py

Lines changed: 131 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,162 @@
11
from datetime import datetime, timedelta
22
from pathlib import Path
33

4-
import asf_search as search
4+
import asf_search as asf
5+
import hyp3_sdk
56
import numpy as np
67
import rioxarray
78
import shapely
89
import xarray as xr
9-
from asf_search import ASFSearchResults, S1Product, constants
10-
from hyp3_sdk import Batch, HyP3, Job
11-
from hyp3_sdk.util import extract_zipped_product
1210

11+
from satchip import utils
1312
from satchip.chip_xr_base import create_template_da
1413
from satchip.terra_mind_grid import TerraMindChip
1514

1615

17-
def get_pct_intersect(product: S1Product, roi: shapely.geometry.Polygon) -> int:
16+
def get_rtc_paths_for_chips(
17+
terra_mind_chips: list[TerraMindChip], bounds: list[float], scratch_dir: Path, opts: utils.ChipDataOpts
18+
) -> dict[str, list[Path]]:
19+
_check_bounds_size(bounds)
20+
granules = _get_granules(bounds, opts['date_start'], opts['date_end'])
21+
slcs_for_chips = _get_slcs_for_each_chip(terra_mind_chips, granules, opts['strategy'])
22+
assert len(slcs_for_chips) == len(terra_mind_chips)
23+
24+
rtc_paths_for_chips = _get_rtcs_for(slcs_for_chips, scratch_dir)
25+
return rtc_paths_for_chips
26+
27+
28+
def _check_bounds_size(bounds: list[float]) -> None:
29+
min_lon, min_lat, max_lon, max_lat = bounds
30+
MAX_BOUND_AREA_DEGREES = 3
31+
bounds_area_degrees = (max_lon - min_lon) * (max_lat - min_lat)
32+
33+
err_message = f'Bounds area is to large ({bounds_area_degrees}). Must be less than {MAX_BOUND_AREA_DEGREES} degrees'
34+
assert bounds_area_degrees < MAX_BOUND_AREA_DEGREES, err_message
35+
36+
37+
def _get_granules(bounds: list[float], date_start: datetime, date_end: datetime) -> list[asf.S1Product]:
38+
date_start = date_start
39+
date_end = date_end + timedelta(days=1) # inclusive end
40+
roi = shapely.box(*bounds)
41+
search_results = asf.geo_search(
42+
intersectsWith=roi.wkt,
43+
start=date_start,
44+
end=date_end,
45+
beamMode=asf.constants.BEAMMODE.IW,
46+
polarization=asf.constants.POLARIZATION.VV_VH,
47+
platform=asf.constants.PLATFORM.SENTINEL1,
48+
processingLevel=asf.constants.PRODUCT_TYPE.SLC,
49+
)
50+
51+
return list(search_results)
52+
53+
54+
def _get_slcs_for_each_chip(
55+
chips: list[TerraMindChip], granules: list[asf.S1Product], strategy: str, intersection_pct: int = 95
56+
) -> dict[str, list[asf.S1Product]]:
57+
slcs_for_chips: dict[str, list[asf.S1Product]] = {}
58+
59+
for chip in chips:
60+
chip_roi = shapely.box(*chip.bounds)
61+
intersecting = [granule for granule in granules if _get_pct_intersect(granule, chip_roi) > intersection_pct]
62+
intersecting = sorted(intersecting, key=lambda g: (-_get_pct_intersect(g, chip_roi), g.properties['startTime']))
63+
64+
if len(intersecting) < 1:
65+
raise ValueError(f'No products found for chip {chip.name} in given date range')
66+
67+
if strategy == 'BEST':
68+
slcs_for_chips[chip.name] = intersecting[:1]
69+
else:
70+
slcs_for_chips[chip.name] = intersecting
71+
72+
return slcs_for_chips
73+
74+
75+
def _get_pct_intersect(product: asf.S1Product, roi: shapely.geometry.Polygon) -> int:
1876
footprint = shapely.geometry.shape(product.geometry)
1977
intersection = int(np.round(100 * roi.intersection(footprint).area / roi.area))
2078
return intersection
2179

2280

23-
def download_hyp3_rtc(job: Job, scratch_dir: Path) -> tuple[Path, Path]:
81+
def _get_rtcs_for(slcs_for_chips: dict[str, list[asf.S1Product]], scratch_dir: Path) -> dict[str, list[Path]]:
82+
flat_slcs = sum(slcs_for_chips.values(), [])
83+
slc_names = set(granule.properties['sceneName'] for granule in flat_slcs)
84+
85+
finished_rtc_jobs = _process_rtcs(slc_names)
86+
87+
paths_for_slc_name: dict[str, Path] = {}
88+
for job in finished_rtc_jobs:
89+
rtc_path = _download_hyp3_rtc(job, scratch_dir)
90+
slc_name = job.job_parameters['granules'][0]
91+
92+
paths_for_slc_name[slc_name] = rtc_path
93+
94+
rtc_paths_for_chips: dict[str, list[Path]] = {}
95+
for chip_name, chip_slcs in slcs_for_chips.items():
96+
rtc_paths = [paths_for_slc_name[name.properties['sceneName']] for name in chip_slcs]
97+
rtc_paths_for_chips[chip_name] = rtc_paths
98+
99+
return rtc_paths_for_chips
100+
101+
102+
def _process_rtcs(slc_names: set[str]) -> hyp3_sdk.Batch:
103+
hyp3 = hyp3_sdk.HyP3()
104+
jobs_by_scene_name = _get_rtc_jobs_by_scene_name(hyp3)
105+
106+
hyp3_jobs = []
107+
for slc_name in slc_names:
108+
if slc_name in jobs_by_scene_name:
109+
job = jobs_by_scene_name[slc_name]
110+
hyp3_jobs.append(job)
111+
else:
112+
new_batch = hyp3.submit_rtc_job(slc_name, radiometry='gamma0', resolution=20)
113+
hyp3_jobs.append(list(new_batch)[0])
114+
115+
batch = hyp3_sdk.Batch(hyp3_jobs)
116+
batch = hyp3.watch(batch)
117+
assert all([j.succeeded() for j in batch]), 'One or more HyP3 jobs failed'
118+
119+
return batch
120+
121+
122+
def _get_rtc_jobs_by_scene_name(hyp3: hyp3_sdk.HyP3) -> dict[str, hyp3_sdk.Job]:
123+
jobs_by_scene_name = {}
124+
125+
for job in hyp3.find_jobs(job_type='RTC_GAMMA'):
126+
if not _is_valid_rtc_job(job):
127+
continue
128+
129+
name = job.job_parameters['granules'][0]
130+
jobs_by_scene_name[name] = job
131+
132+
return jobs_by_scene_name
133+
134+
135+
def _is_valid_rtc_job(job: hyp3_sdk.Job) -> bool:
136+
return (
137+
not job.failed()
138+
and not job.expired()
139+
and job.job_parameters['radiometry'] == 'gamma0'
140+
and job.job_parameters['resolution'] == 20
141+
)
142+
143+
144+
def _download_hyp3_rtc(job: hyp3_sdk.Job, scratch_dir: Path) -> tuple[Path, Path]:
24145
output_path = scratch_dir / job.to_dict()['files'][0]['filename']
25146
output_dir = output_path.with_suffix('')
26147
output_zip = output_path.with_suffix('.zip')
27148
if not output_dir.exists():
28149
job.download_files(location=scratch_dir)
29-
extract_zipped_product(output_zip)
150+
hyp3_sdk.util.extract_zipped_product(output_zip)
30151
vv_path = list(output_dir.glob('*_VV.tif'))[0]
31152
vh_path = list(output_dir.glob('*_VH.tif'))[0]
32153
return vv_path, vh_path
33154

34155

35-
def get_hyp3_rtcs(items: ASFSearchResults, roi: shapely.geometry.Polygon, strategy: str, scratch_dir: Path) -> list:
36-
valid_items = [item for item in items if get_pct_intersect(item, roi) > 95]
37-
valid_items = sorted(items, key=lambda x: (-get_pct_intersect(x, roi), x.properties['startTime']))
38-
if strategy == 'BEST':
39-
valid_items = valid_items[:1]
40-
hyp3 = HyP3()
41-
old_jobs = [j for j in hyp3.find_jobs(job_type='RTC_GAMMA') if not j.failed() and not j.expired()]
42-
old_jobs = [j for j in old_jobs if j.job_parameters['radiometry'] == 'gamma0'] # type: ignore
43-
old_jobs = [j for j in old_jobs if j.job_parameters['resolution'] == 20] # type: ignore
44-
jobs = []
45-
for item in valid_items:
46-
scene_name = item.properties['sceneName']
47-
matching_jobs = [j for j in old_jobs if j.job_parameters['granules'] == [scene_name]] # type: ignore
48-
if len(matching_jobs) == 0:
49-
new_batch = hyp3.submit_rtc_job(scene_name, radiometry='gamma0', resolution=20)
50-
jobs.append(list(new_batch)[0])
51-
else:
52-
jobs.append(matching_jobs[0])
53-
jobs = Batch(jobs)
54-
hyp3.watch(jobs)
55-
assert all([j.succeeded() for j in jobs]), 'One or more HyP3 jobs failed'
56-
paths = [download_hyp3_rtc(job, scratch_dir) for job in jobs]
57-
return paths
58-
59-
60-
def get_s1rtc_data(chip: TerraMindChip, scratch_dir: Path, opts: dict) -> xr.DataArray:
61-
date_start = opts['date_start']
62-
date_end = opts['date_end'] + timedelta(days=1) # inclusive end
156+
def get_s1rtc_chip_data(
157+
chip: TerraMindChip, image_sets: list[Path], scratch_dir: Path, opts: utils.ChipDataOpts
158+
) -> xr.DataArray:
63159
roi = shapely.box(*chip.bounds)
64-
search_results = search.geo_search(
65-
intersectsWith=roi.wkt,
66-
start=date_start,
67-
end=date_end,
68-
beamMode=constants.BEAMMODE.IW,
69-
polarization=constants.POLARIZATION.VV_VH,
70-
platform=constants.PLATFORM.SENTINEL1,
71-
processingLevel=constants.PRODUCT_TYPE.SLC,
72-
)
73-
if len(search_results) == 0:
74-
raise ValueError(f'No products found for chip {chip.name} in date range {date_start} to {date_end}')
75-
strategy = opts.get('strategy', 'BEST').upper()
76-
image_sets = get_hyp3_rtcs(search_results, roi, strategy, scratch_dir)
77160
das = []
78161
template = create_template_da(chip)
79162
for image_set in image_sets:

src/satchip/chip_sentinel2.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pystac.item import Item
1212
from pystac_client import Client
1313

14+
from satchip import utils
1415
from satchip.chip_xr_base import create_template_da
1516
from satchip.terra_mind_grid import TerraMindChip
1617

@@ -127,7 +128,7 @@ def get_scenes(
127128
return valid_scenes
128129

129130

130-
def get_s2l2a_data(chip: TerraMindChip, scratch_dir: Path, opts: dict) -> xr.DataArray:
131+
def get_s2l2a_data(chip: TerraMindChip, scratch_dir: Path, opts: utils.ChipDataOpts) -> xr.DataArray:
131132
"""Get XArray DataArray of Sentinel-2 L2A image for the given bounds and best collection parameters.
132133
133134
Args:

src/satchip/utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1+
import datetime
12
from pathlib import Path
3+
from typing import TypedDict
24

35
import xarray as xr
46
import zarr
57
from pyproj import CRS, Transformer
68

79

10+
class ChipDataRequiredOpts(TypedDict):
11+
strategy: str
12+
date_start: datetime.datetime
13+
date_end: datetime.datetime
14+
15+
16+
class ChipDataOpts(ChipDataRequiredOpts, total=False):
17+
max_cloud_pct: int
18+
19+
820
def get_epsg4326_point(x: float, y: float, in_epsg: int) -> tuple[float, float]:
921
if in_epsg == 4326:
1022
return x, y

0 commit comments

Comments
 (0)