SAR / radar — Sentinel-1 GRD VV backscatter¶
Sentinel-1 Ground Range Detected, VV-polarisation, monthly mean over Giza. Sentinel-1 carries VV/VH over land and HH/HV over polar regions; VV is the universal pick.
This notebook downloads a single monthly composite, previews it, then demonstrates the asynchronous (batch) export path with full job tracking and cleanup.
Setup¶
First the imports and the output directory. pyramids provides Dataset (GeoTIFF/NetCDF reading); earthlens provides the unified EarthLens entry point and the Earth Engine Catalog.
import os
from pathlib import Path
from pyramids.dataset import Dataset
from pyramids.plot import ColorBar
from earthlens.core import EarthLens
from earthlens.gee import Catalog, cancel_task
OUT_DIR = Path('out') / 'sar-radar'
OUT_DIR.mkdir(parents=True, exist_ok=True)
print(f'output directory: {OUT_DIR.resolve()}')
Credentials¶
The notebook reads the GEE service-account credentials from the GEE_SERVICE_ACCOUNT / GEE_SERVICE_KEY environment variables. Both must be set before running this cell.
SERVICE_ACCOUNT = os.environ['GEE_SERVICE_ACCOUNT']
SERVICE_KEY = os.environ['GEE_SERVICE_KEY']
Inspect the catalog entry¶
Before downloading anything, look at what the bundled catalog knows about the asset — bands, cadence, license, provider.
cat = Catalog()
ds = cat.get_dataset('COPERNICUS/S1_GRD')
print(ds)
# The summary clips long text and shows only a band count, so an explorer
# notebook still wants the untruncated title and the fields it omits:
print(f'title (full): {ds.title}')
print(f'ee_type: {ds.ee_type}')
print(f'default_reducer: {ds.default_reducer}')
print(f'license: {ds.license}')
print(f'band ids (first 5): {list(ds.bands)[:5]}')
Download¶
Build the request first: a tiny AOI ([29.95, 30.05] lat, [31.15, 31.25] lon) at 30.0 m, monthly cadence — small enough to keep the synchronous download under EE's 32768-px per-axis cap.
gee = EarthLens(
data_source="gee",
start='2024-06-01',
end='2024-06-30',
dataset='COPERNICUS/S1_GRD',
variables=['VV'],
aoi=[31.15, 29.95, 31.25, 30.05],
cadence='monthly',
path=OUT_DIR,
scale=30.0,
reducer='mean',
)
Authentication and download are kept as separate steps: authenticate() resolves the service-account credentials, then download() writes the composite to disk and returns the written paths.
gee.authenticate(service_account=SERVICE_ACCOUNT, service_key=SERVICE_KEY)
paths = gee.download(progress_bar=False)
print(f'wrote {len(paths)} GeoTIFF(s):')
for p in paths:
print(f' {p} ({p.stat().st_size / 1024:.1f} KB)')
Quick preview¶
Load the first written GeoTIFF through pyramids and pull out the single band as an array. (pyramids.dataset.Dataset is the project's GeoTIFF/NetCDF wrapper.)
preview = Dataset.read_file(paths[0])
Mask the dataset's nodata value so the colormap doesn't get pinned to it, then render the band with a viridis colormap and report the value range.
# Sentinel-1 GRD arrives in dB. A handful of specular returns off buildings reach
# +29 dB while 95% of the scene sits below 0, so the full range crushes the image
# into one tone. -25 … 0 dB is the conventional display stretch for VV, and
# greyscale is how single-channel SAR is read.
VV_DISPLAY_RANGE = (-25.0, 0.0)
glyph = preview.plot(
cmap='gray',
colorbar=ColorBar(label='VV backscatter (dB)'),
title='Sentinel-1 VV — Cairo and the Nile',
)
glyph.ax.title.set_fontsize(11)
glyph.im.set_clim(*VV_DISPLAY_RANGE)
stats = preview.stats(approx_ok=False)
low, high = stats['min'].iloc[0], stats['max'].iloc[0]
print(f'value range: [{low:.4g}, {high:.4g}] dB')
print(f'display stretch: [{VV_DISPLAY_RANGE[0]:.0f}, {VV_DISPLAY_RANGE[1]:.0f}] dB')
Tracking submitted jobs (asynchronous export)¶
The download above uses export_via="url" — a synchronous getDownloadURL round-trip. Nothing was queued, so there's no Earth Engine job to track.
To track an export instead, switch to an asynchronous sink (drive / gcs / asset) and pass wait_for_export=False so .download() returns a TaskInfo at submission time rather than blocking until completion. The cells below submit the same (asset_id, band, AOI, scale) request as an export_via="asset" task into the service account's own asset folder, then walk the four jobs-API calls (list_recent_tasks → wait_for_task_id → ee.data.getAsset → ee.data.deleteAsset) to make the job finish and tidy up. See track-batch-exports.ipynb for a deeper worked example.
Prepare the demo asset folder¶
GEE._export_via_batch writes the image at <asset_id>/<prefix>, so asset_id here is the parent Folder asset (not the final image path). We clear any leftovers from a previous run, then create a fresh empty folder that we own.
import ee
from earthlens.gee import list_recent_tasks, wait_for_task_id
_proj = ee.data._get_projects_path().removeprefix('projects/')
PARENT = f'projects/{_proj}/assets'
DEMO_FOLDER = f'{PARENT}/earthlens-demo-sar-radar'
print(f'demo folder: {DEMO_FOLDER}')
# Listing the parent says whether a previous run left the folder behind, so the
# cleanup below never has to swallow a "not found" from Earth Engine.
listed = ee.data.listAssets({'parent': PARENT})
siblings = [asset['name'] for asset in listed.get('assets', [])]
if DEMO_FOLDER in siblings:
children = ee.data.listAssets({'parent': DEMO_FOLDER})
for child in children.get('assets', []):
ee.data.deleteAsset(child['name'])
print(f'cleared leftover child: {child["name"]}')
ee.data.deleteAsset(DEMO_FOLDER)
print(f'cleared leftover folder: {DEMO_FOLDER}')
# Create the parent folder — EE requires it to exist before a child write.
ee.data.createAsset({'type': 'Folder'}, DEMO_FOLDER)
print(f'created folder: {DEMO_FOLDER}')
Submit¶
Same (asset_id, band, AOI, scale) request as the sync download above, just routed through export_via="asset" + wait_for_export=False. We build the request, authenticate, then submit — download() returns a TaskInfo per submitted bucket at the moment the task is queued, with no blocking.
async_gee = EarthLens(
data_source="gee",
start='2024-06-01',
end='2024-06-30',
dataset='COPERNICUS/S1_GRD',
variables=['VV'],
aoi=[31.15, 29.95, 31.25, 30.05],
cadence='monthly',
path=OUT_DIR,
scale=30.0,
reducer='mean',
export_via='asset',
asset_id=DEMO_FOLDER,
wait_for_export=False,
)
Authenticate and submit the export. The returned TaskInfo carries the queued task's id and state.
async_gee.authenticate(service_account=SERVICE_ACCOUNT, service_key=SERVICE_KEY)
submitted = async_gee.download(progress_bar=False)
task_info = submitted[0]
print(f'submitted: id={task_info.id} state={task_info.state}')
print(f' description={task_info.description}')
List + wait¶
list_recent_tasks(description_prefix=...) returns every matching task across the current project; wait_for_task_id blocks until the one we care about reaches a terminal state. A real workflow would just poll later from a separate process — the wait here exists so the notebook shows the full success path end-to-end.
recent = list_recent_tasks(
description_prefix=task_info.description,
max_age_min=10,
)
print(f'list_recent_tasks matched {len(recent)} task(s):')
for t in recent:
print(f' {t.id} {t.state:<12} {t.description}')
Now block on the specific task id until it reaches a terminal state. On FAILED / CANCELLED wait_for_task_id raises a RuntimeError; we cancel-if-still-running so we don't leak an in-flight task on notebook restart.
final = None
try:
final = wait_for_task_id(
task_info.id,
poll_seconds=10,
progress_bar=False,
)
print(f'final state: {final.state}')
finally:
if final is None:
# The wait raises on FAILED / CANCELLED *and on timeout* — and a
# timeout leaves the export still running. Cancel it so an aborted
# notebook does not leave a live task behind; cancel_task is a no-op
# on an already-terminal task, and the original error still
# propagates out of this finally.
cancel_task(task_info.id)
print(f'cancelled {task_info.id} after the wait failed')
Verify + clean up¶
Confirm the produced asset exists on Earth Engine, then delete it (and the surrounding demo folder) so we don't leak storage between notebook runs. The backend wrote the image at <DEMO_FOLDER>/<task description>.
produced = f'{DEMO_FOLDER}/{task_info.description}'
meta = ee.data.getAsset(produced)
print(f'asset exists: type={meta.get("type")} name={meta.get("name")}')
ee.data.deleteAsset(produced)
print('asset deleted')
# Tear down the parent folder.
ee.data.deleteAsset(DEMO_FOLDER)
print(f'folder deleted: {DEMO_FOLDER}')
What's on disk¶
The GeoTIFF is left under the per-notebook out/ directory for you to inspect. That directory is .gitignored — re-running the notebook overwrites it.
for p in sorted(OUT_DIR.iterdir()) if OUT_DIR.exists() else []:
print(f'{p} ({p.stat().st_size / 1024:.1f} KB)')