pycontrails 0.55.0__cp310-cp310-macosx_11_0_arm64.whl → 0.56.0__cp310-cp310-macosx_11_0_arm64.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.
Potentially problematic release.
This version of pycontrails might be problematic. Click here for more details.
- pycontrails/_version.py +3 -3
- pycontrails/core/airports.py +1 -1
- pycontrails/core/cache.py +3 -3
- pycontrails/core/fleet.py +1 -1
- pycontrails/core/flight.py +47 -43
- pycontrails/core/met_var.py +1 -1
- pycontrails/core/rgi_cython.cpython-310-darwin.so +0 -0
- pycontrails/core/vector.py +28 -30
- pycontrails/datalib/landsat.py +49 -26
- pycontrails/datalib/leo_utils/__init__.py +5 -0
- pycontrails/datalib/leo_utils/correction.py +266 -0
- pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
- pycontrails/datalib/{_leo_utils → leo_utils}/search.py +1 -1
- pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
- pycontrails/datalib/sentinel.py +236 -93
- pycontrails/models/dry_advection.py +1 -1
- pycontrails/models/extended_k15.py +8 -8
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/METADATA +3 -1
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/RECORD +25 -21
- /pycontrails/datalib/{_leo_utils → leo_utils}/static/bq_roi_query.sql +0 -0
- /pycontrails/datalib/{_leo_utils → leo_utils}/vis.py +0 -0
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/WHEEL +0 -0
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/licenses/LICENSE +0 -0
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/licenses/NOTICE +0 -0
- {pycontrails-0.55.0.dist-info → pycontrails-0.56.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
"""Download and parse Sentinel metadata."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from collections.abc import Collection
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import numpy.typing as npt
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import pyproj
|
|
13
|
+
import xarray as xr
|
|
14
|
+
from scipy.interpolate import griddata
|
|
15
|
+
|
|
16
|
+
from pycontrails.utils import dependencies
|
|
17
|
+
|
|
18
|
+
BAND_ID_MAPPING = {
|
|
19
|
+
"B01": 0,
|
|
20
|
+
"B02": 1,
|
|
21
|
+
"B03": 2,
|
|
22
|
+
"B04": 3,
|
|
23
|
+
"B05": 4,
|
|
24
|
+
"B06": 5,
|
|
25
|
+
"B07": 6,
|
|
26
|
+
"B08": 7,
|
|
27
|
+
"B8A": 8,
|
|
28
|
+
"B09": 9,
|
|
29
|
+
"B10": 10,
|
|
30
|
+
"B11": 11,
|
|
31
|
+
"B12": 12,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _band_id(band: str) -> int:
|
|
36
|
+
"""Get band ID used in some metadata files."""
|
|
37
|
+
if band in (f"B{i:2d}" for i in range(1, 9)):
|
|
38
|
+
return int(band[1:]) - 1
|
|
39
|
+
if band == "B8A":
|
|
40
|
+
return 8
|
|
41
|
+
return int(band[1:])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_viewing_incidence_angle_by_detector(
|
|
45
|
+
metadata_path: str, target_detector_id: str, target_band_id: str = "2"
|
|
46
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
47
|
+
"""
|
|
48
|
+
Read sensor incidence angles from metadata.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
metadata_path : str
|
|
53
|
+
Path to the XML file containing TILE metadata.
|
|
54
|
+
target_detector_id : str
|
|
55
|
+
Target Detector_ID.
|
|
56
|
+
target_band_id : str
|
|
57
|
+
Starts from 0 (e.g. band 2 (blue) = band_id "1")
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
tuple[np.ndarray, np.ndarray]
|
|
62
|
+
Zenith Angles, Azimuth Angles ((23x23) numpy arrays)
|
|
63
|
+
"""
|
|
64
|
+
tree = ET.parse(metadata_path)
|
|
65
|
+
root = tree.getroot()
|
|
66
|
+
|
|
67
|
+
tile_angles_element = root.find(".//Tile_Angles")
|
|
68
|
+
if tile_angles_element is not None:
|
|
69
|
+
for band in tile_angles_element.findall(".//Viewing_Incidence_Angles_Grids"):
|
|
70
|
+
band_id = band.get("bandId")
|
|
71
|
+
detector_id = band.get("detectorId")
|
|
72
|
+
|
|
73
|
+
if band_id == target_band_id and detector_id == target_detector_id:
|
|
74
|
+
zenith_element = band.find(".//Zenith")
|
|
75
|
+
azimuth_element = band.find(".//Azimuth")
|
|
76
|
+
|
|
77
|
+
if zenith_element is not None and azimuth_element is not None:
|
|
78
|
+
zenith_values_list = zenith_element.find(".//Values_List")
|
|
79
|
+
azimuth_values_list = azimuth_element.find(".//Values_List")
|
|
80
|
+
|
|
81
|
+
zenith_2d_array = []
|
|
82
|
+
azimuth_2d_array = []
|
|
83
|
+
|
|
84
|
+
if zenith_values_list is not None:
|
|
85
|
+
for values in zenith_values_list.findall(".//VALUES"):
|
|
86
|
+
if values.text is not None:
|
|
87
|
+
zenith_row = list(map(float, values.text.split()))
|
|
88
|
+
zenith_2d_array.append(zenith_row)
|
|
89
|
+
|
|
90
|
+
if azimuth_values_list is not None:
|
|
91
|
+
for values in azimuth_values_list.findall(".//VALUES"):
|
|
92
|
+
if values.text is not None:
|
|
93
|
+
azimuth_row = list(map(float, values.text.split()))
|
|
94
|
+
azimuth_2d_array.append(azimuth_row)
|
|
95
|
+
|
|
96
|
+
return np.array(zenith_2d_array), np.array(azimuth_2d_array)
|
|
97
|
+
|
|
98
|
+
# If no matching band/detector found, return empty arrays
|
|
99
|
+
return np.array([]), np.array([])
|
|
100
|
+
|
|
101
|
+
raise ValueError("Viewing_Incidence_Angles_Grids element not found.")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_viewing_incidence_angles(
|
|
105
|
+
metadata_path: str, target_band_id: str = "2"
|
|
106
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
107
|
+
"""
|
|
108
|
+
Read sensor incidence angles from metadata. Returns the total of all detectors.
|
|
109
|
+
|
|
110
|
+
Parameters
|
|
111
|
+
----------
|
|
112
|
+
metadata_path : str
|
|
113
|
+
Path to the XML file containing TILE metadata.
|
|
114
|
+
target_band_id : str
|
|
115
|
+
Starts from 0 (e.g. band 2 (blue) = band_id "1")
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
tuple[np.ndarray, np.ndarray]
|
|
120
|
+
Zenith Angles, Azimuth Angles ((23x23) numpy array)
|
|
121
|
+
"""
|
|
122
|
+
total_zenith = np.full((23, 23), np.nan, dtype=np.float64)
|
|
123
|
+
total_azimuth = np.full((23, 23), np.nan, dtype=np.float64)
|
|
124
|
+
|
|
125
|
+
# loop over all 12 detector id's
|
|
126
|
+
for detector_id in [str(i) for i in range(1, 13)]:
|
|
127
|
+
zen, azi = parse_viewing_incidence_angle_by_detector(
|
|
128
|
+
metadata_path, detector_id, target_band_id
|
|
129
|
+
)
|
|
130
|
+
if zen is None or azi is None or zen.size == 0 or azi.size == 0:
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# convert to np array
|
|
134
|
+
zen_array = np.array(zen, dtype=np.float64)
|
|
135
|
+
azi_array = np.array(azi, dtype=np.float64)
|
|
136
|
+
|
|
137
|
+
# remove NaN values
|
|
138
|
+
mask = ~np.isnan(zen_array)
|
|
139
|
+
|
|
140
|
+
total_zenith[mask] = zen_array[mask]
|
|
141
|
+
total_azimuth[mask] = azi_array[mask]
|
|
142
|
+
|
|
143
|
+
return total_zenith, total_azimuth
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_high_res_detector_mask(metadata_path: str, scale: int = 10) -> npt.NDArray[np.integer]:
|
|
147
|
+
"""
|
|
148
|
+
Load in the detector mask from either JP2 or GML file.
|
|
149
|
+
|
|
150
|
+
- JP2: Reads pixel-level mask indicating which detector [1-12] captured each pixel.
|
|
151
|
+
- GML: Converts detector polygons to raster mask, where each pixel corresponds to a detector ID.
|
|
152
|
+
|
|
153
|
+
Lower the resolution with 'scale' to speed up processing.
|
|
154
|
+
Scale 1 -> 10m resolution. Scale 10 -> 100m resolution.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
metadata_path : str
|
|
159
|
+
Path to metadata file (.jp2 or .gml).
|
|
160
|
+
scale : int
|
|
161
|
+
Factor by which to lower the resolution.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
npt.NDArray[np.integer]
|
|
166
|
+
2D array of detector IDs (1 to 12), shape (height, width).
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
import rasterio
|
|
170
|
+
import rasterio.enums
|
|
171
|
+
import rasterio.features
|
|
172
|
+
import rasterio.transform
|
|
173
|
+
except ModuleNotFoundError as exc:
|
|
174
|
+
dependencies.raise_module_not_found_error(
|
|
175
|
+
name="landsat module",
|
|
176
|
+
package_name="rasterio",
|
|
177
|
+
module_not_found_error=exc,
|
|
178
|
+
pycontrails_optional_package="sat",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
scale = int(scale)
|
|
182
|
+
|
|
183
|
+
file_ext = os.path.splitext(metadata_path)[1].lower()
|
|
184
|
+
|
|
185
|
+
if file_ext == ".jp2":
|
|
186
|
+
# --- Handle JP2 case ---
|
|
187
|
+
with rasterio.open(metadata_path) as src:
|
|
188
|
+
return src.read(
|
|
189
|
+
1,
|
|
190
|
+
out_shape=(int(src.height // scale), int(src.width // scale)),
|
|
191
|
+
resampling=rasterio.enums.Resampling.nearest,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if file_ext == ".gml":
|
|
195
|
+
# --- Handle GML case ---
|
|
196
|
+
try:
|
|
197
|
+
import geopandas as gpd
|
|
198
|
+
except ModuleNotFoundError as exc:
|
|
199
|
+
dependencies.raise_module_not_found_error(
|
|
200
|
+
name="landsat module",
|
|
201
|
+
package_name="geopandas",
|
|
202
|
+
module_not_found_error=exc,
|
|
203
|
+
pycontrails_optional_package="sat",
|
|
204
|
+
)
|
|
205
|
+
gdf = gpd.read_file(metadata_path)
|
|
206
|
+
|
|
207
|
+
# Extract detector_id from gml_id (assuming format contains "-Bxx-<id>-")
|
|
208
|
+
def _extract_detector_id(gml_id: str) -> int:
|
|
209
|
+
match = re.search(r"-B\d+-(\d+)-", gml_id)
|
|
210
|
+
if match:
|
|
211
|
+
return int(match.group(1))
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
gdf["detector_id"] = gdf["gml_id"].apply(_extract_detector_id)
|
|
215
|
+
|
|
216
|
+
# Calculate bounding box and raster size
|
|
217
|
+
minx, miny, maxx, maxy = gdf.total_bounds
|
|
218
|
+
resolution = 10 * scale
|
|
219
|
+
|
|
220
|
+
transform = rasterio.transform.from_origin(minx, maxy, resolution, resolution)
|
|
221
|
+
|
|
222
|
+
# Create an emmpty instance for the full grid
|
|
223
|
+
full_width = 10980 // scale
|
|
224
|
+
full_height = 10980 // scale
|
|
225
|
+
mask_full = np.full((full_height, full_width), 0, dtype="uint8")
|
|
226
|
+
|
|
227
|
+
# Rasterize detector polygons
|
|
228
|
+
local_width = int((maxx - minx) / resolution)
|
|
229
|
+
local_height = int((maxy - miny) / resolution)
|
|
230
|
+
local_mask = rasterio.features.rasterize(
|
|
231
|
+
[(geom, det_id) for geom, det_id in zip(gdf.geometry, gdf.detector_id, strict=False)],
|
|
232
|
+
out_shape=(local_height, local_width),
|
|
233
|
+
transform=transform,
|
|
234
|
+
fill=0,
|
|
235
|
+
dtype="int32",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Insert local raster into top-left corner of full grid
|
|
239
|
+
mask_full[:local_height, :local_width] = local_mask.astype("uint8")
|
|
240
|
+
|
|
241
|
+
return mask_full
|
|
242
|
+
|
|
243
|
+
raise ValueError(f"Unsupported file extension: {file_ext}. Expected .jp2 or .gml.")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _band_resolution(band: str) -> int:
|
|
247
|
+
"""Get band resolution in meters."""
|
|
248
|
+
return (
|
|
249
|
+
60 if band in ("B01", "B09", "B10") else 10 if band in ("B02", "B03", "B04", "B08") else 20
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def read_image_coordinates(meta: str, band: str) -> tuple[np.ndarray, np.ndarray]:
|
|
254
|
+
"""Read image x and y coordinates."""
|
|
255
|
+
|
|
256
|
+
# convenience function that satisfies mypy
|
|
257
|
+
def _text_from_tag(parent: ET.Element, tag: str) -> str:
|
|
258
|
+
elem = parent.find(tag)
|
|
259
|
+
if elem is None or elem.text is None:
|
|
260
|
+
msg = f"Could not find text in {tag} element"
|
|
261
|
+
raise ValueError(msg)
|
|
262
|
+
return elem.text
|
|
263
|
+
|
|
264
|
+
resolution = _band_resolution(band)
|
|
265
|
+
|
|
266
|
+
# find coordinates of upper left corner and pixel size
|
|
267
|
+
tree = ET.parse(meta)
|
|
268
|
+
elems = tree.findall(".//Geoposition")
|
|
269
|
+
for elem in elems:
|
|
270
|
+
if int(elem.attrib["resolution"]) == resolution:
|
|
271
|
+
ulx = float(_text_from_tag(elem, "ULX"))
|
|
272
|
+
uly = float(_text_from_tag(elem, "ULY"))
|
|
273
|
+
dx = float(_text_from_tag(elem, "XDIM"))
|
|
274
|
+
dy = float(_text_from_tag(elem, "YDIM"))
|
|
275
|
+
break
|
|
276
|
+
else:
|
|
277
|
+
msg = f"Could not find image geoposition for resolution of {resolution} m"
|
|
278
|
+
raise ValueError(msg)
|
|
279
|
+
|
|
280
|
+
# find image size
|
|
281
|
+
elems = tree.findall(".//Size")
|
|
282
|
+
for elem in elems:
|
|
283
|
+
if int(elem.attrib["resolution"]) == resolution:
|
|
284
|
+
nx = int(_text_from_tag(elem, "NCOLS"))
|
|
285
|
+
ny = int(_text_from_tag(elem, "NROWS"))
|
|
286
|
+
break
|
|
287
|
+
else:
|
|
288
|
+
msg = f"Could not find image size for resolution of {resolution} m"
|
|
289
|
+
raise ValueError(msg)
|
|
290
|
+
|
|
291
|
+
# compute pixel coordinates
|
|
292
|
+
xlim = (ulx, ulx + (nx - 1) * dx)
|
|
293
|
+
ylim = (uly, uly + (ny - 1) * dy) # dy is < 0
|
|
294
|
+
x = np.linspace(xlim[0], xlim[1], nx)
|
|
295
|
+
y = np.linspace(ylim[0], ylim[1], ny)
|
|
296
|
+
|
|
297
|
+
return x, y
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def parse_high_res_viewing_incidence_angles(
|
|
301
|
+
tile_metadata_path: str, detector_band_metadata_path: str, scale: int = 10
|
|
302
|
+
) -> xr.Dataset:
|
|
303
|
+
"""
|
|
304
|
+
Parse high-resolution viewing incidence angles (zenith and azimuth).
|
|
305
|
+
|
|
306
|
+
Parameters
|
|
307
|
+
----------
|
|
308
|
+
tile_metadata_path : str
|
|
309
|
+
Path to the tile-level metadata file (usually MTD_TL.xml).
|
|
310
|
+
detector_band_metadata_path : str
|
|
311
|
+
Path to the detector-specific metadata file (e.g., MTD_DETFOO_B03.jp2).
|
|
312
|
+
scale : int, optional
|
|
313
|
+
Desired resolution scale (default is 10, e.g., 10 for 10m resolution).
|
|
314
|
+
|
|
315
|
+
Returns
|
|
316
|
+
-------
|
|
317
|
+
xr.Dataset
|
|
318
|
+
Dataset with coordinates ('y', 'x') containing:
|
|
319
|
+
- VZA: View Zenith Angle
|
|
320
|
+
- VAA: View Azimuth Angle
|
|
321
|
+
|
|
322
|
+
Raises
|
|
323
|
+
------
|
|
324
|
+
ValueError
|
|
325
|
+
If required data (zenith or azimuth) cannot be parsed.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
import skimage.transform
|
|
329
|
+
except ModuleNotFoundError as exc:
|
|
330
|
+
dependencies.raise_module_not_found_error(
|
|
331
|
+
name="landsat module",
|
|
332
|
+
package_name="scikit-image",
|
|
333
|
+
module_not_found_error=exc,
|
|
334
|
+
pycontrails_optional_package="sat",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Load the detector mask
|
|
338
|
+
detector_mask = parse_high_res_detector_mask(detector_band_metadata_path, scale=scale)
|
|
339
|
+
if detector_mask is None:
|
|
340
|
+
raise ValueError("Detector mask could not be parsed.")
|
|
341
|
+
|
|
342
|
+
# Load averaged low-resolution zenith angles
|
|
343
|
+
low_res_zenith, _ = parse_viewing_incidence_angles(tile_metadata_path)
|
|
344
|
+
if low_res_zenith is None:
|
|
345
|
+
raise ValueError("Zenith angles could not be parsed.")
|
|
346
|
+
|
|
347
|
+
low_res_zenith = extrapolate_array(low_res_zenith)
|
|
348
|
+
|
|
349
|
+
# Upsample zenith angles to high resolution
|
|
350
|
+
high_res_zenith = skimage.transform.resize(
|
|
351
|
+
low_res_zenith,
|
|
352
|
+
output_shape=detector_mask.shape,
|
|
353
|
+
order=1,
|
|
354
|
+
mode="edge",
|
|
355
|
+
anti_aliasing=True,
|
|
356
|
+
preserve_range=True,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Dictionary to store upsampled azimuth data per detector
|
|
360
|
+
low_res_azimuth_dict = {}
|
|
361
|
+
|
|
362
|
+
for detector_id in [str(i) for i in range(1, 13)]:
|
|
363
|
+
zen, azi = parse_viewing_incidence_angle_by_detector(tile_metadata_path, detector_id, "2")
|
|
364
|
+
if zen is None or azi is None or zen.size == 0 or azi.size == 0:
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
azi_array = np.array(azi, dtype=np.float64)
|
|
368
|
+
azi_extrapolated = extrapolate_array(azi_array)
|
|
369
|
+
|
|
370
|
+
azi_extrapolated_highres = skimage.transform.resize(
|
|
371
|
+
azi_extrapolated,
|
|
372
|
+
output_shape=detector_mask.shape,
|
|
373
|
+
order=1,
|
|
374
|
+
mode="edge",
|
|
375
|
+
anti_aliasing=True,
|
|
376
|
+
preserve_range=True,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
low_res_azimuth_dict[detector_id] = azi_extrapolated_highres
|
|
380
|
+
|
|
381
|
+
if not low_res_azimuth_dict:
|
|
382
|
+
raise ValueError("No azimuth data could be parsed for any detector.")
|
|
383
|
+
|
|
384
|
+
# Initialize high-res azimuth array
|
|
385
|
+
high_res_azimuth = np.zeros_like(detector_mask, dtype=np.float32)
|
|
386
|
+
for i in range(detector_mask.shape[0]):
|
|
387
|
+
for j in range(detector_mask.shape[1]):
|
|
388
|
+
pixel_val = detector_mask[i, j]
|
|
389
|
+
high_res_azimuth[i, j] = process_pixel(
|
|
390
|
+
pixel_val, (i, j), detector_mask.shape, low_res_azimuth_dict
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Get UTM coordinates from the image
|
|
394
|
+
x_img, y_img = read_image_coordinates(tile_metadata_path, "B03")
|
|
395
|
+
x_min, x_max = float(x_img.min()), float(x_img.max())
|
|
396
|
+
y_min, y_max = float(y_img.min()), float(y_img.max())
|
|
397
|
+
|
|
398
|
+
# Create evenly spaced coordinate arrays that span the UTM extent
|
|
399
|
+
height, width = high_res_zenith.shape
|
|
400
|
+
x_coords = np.linspace(x_min, x_max, num=width)
|
|
401
|
+
y_coords = np.linspace(y_max, y_min, num=height) # y decreases in image space
|
|
402
|
+
|
|
403
|
+
# Save the extent for metadata
|
|
404
|
+
extent = (x_min, x_max, y_min, y_max)
|
|
405
|
+
|
|
406
|
+
# Create xarray.Dataset
|
|
407
|
+
return xr.Dataset(
|
|
408
|
+
data_vars={
|
|
409
|
+
"VZA": (("y", "x"), high_res_zenith.astype(np.float32)),
|
|
410
|
+
"VAA": (("y", "x"), high_res_azimuth),
|
|
411
|
+
},
|
|
412
|
+
coords={
|
|
413
|
+
"x": x_coords,
|
|
414
|
+
"y": y_coords,
|
|
415
|
+
},
|
|
416
|
+
attrs={"title": "Sentinel Viewing Incidence Angles", "scale": scale, "extent": extent},
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def parse_ephemeris_sentinel(datatsrip_metadata_path: str) -> pd.DataFrame:
|
|
421
|
+
"""Return the ephemeris data from the DATASTRIP xml file.
|
|
422
|
+
|
|
423
|
+
Parameters
|
|
424
|
+
----------
|
|
425
|
+
datatsrip_metadata_path : str
|
|
426
|
+
The location of the DATASTRIP xml file
|
|
427
|
+
|
|
428
|
+
Returns
|
|
429
|
+
-------
|
|
430
|
+
pd.DataFrame
|
|
431
|
+
A :class:`pandas.DataFrame` containing the ephemeris track with columns:
|
|
432
|
+
- EPHEMERIS_TIME: Timestamps of the ephemeris data.
|
|
433
|
+
- EPHEMERIS_ECEF_X: ECEF X coordinates.
|
|
434
|
+
- EPHEMERIS_ECEF_Y: ECEF Y coordinates.
|
|
435
|
+
- EPHEMERIS_ECEF_Z: ECEF Z coordinates.
|
|
436
|
+
"""
|
|
437
|
+
tree = ET.parse(datatsrip_metadata_path)
|
|
438
|
+
root = tree.getroot()
|
|
439
|
+
|
|
440
|
+
ns = root[0].tag.split("}")[0][1:]
|
|
441
|
+
|
|
442
|
+
satellite_ancillary_data = root.find(f".//{{{ns}}}Satellite_Ancillary_Data_Info")
|
|
443
|
+
|
|
444
|
+
if satellite_ancillary_data is None:
|
|
445
|
+
return pd.DataFrame(
|
|
446
|
+
columns=["EPHEMERIS_TIME", "EPHEMERIS_ECEF_X", "EPHEMERIS_ECEF_Y", "EPHEMERIS_ECEF_Z"]
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
records = []
|
|
450
|
+
|
|
451
|
+
for elem in satellite_ancillary_data:
|
|
452
|
+
if elem.tag.endswith("Ephemeris"):
|
|
453
|
+
gps_points_list = elem.find("GPS_Points_List")
|
|
454
|
+
if gps_points_list is None:
|
|
455
|
+
continue # skip if missing
|
|
456
|
+
|
|
457
|
+
for point in gps_points_list:
|
|
458
|
+
gps_time_elem = point.find(".//GPS_TIME")
|
|
459
|
+
position_elem = point.find(".//POSITION_VALUES")
|
|
460
|
+
|
|
461
|
+
if gps_time_elem is None or gps_time_elem.text is None:
|
|
462
|
+
continue # skip if missing
|
|
463
|
+
|
|
464
|
+
if position_elem is None or position_elem.text is None:
|
|
465
|
+
continue # skip if missing
|
|
466
|
+
|
|
467
|
+
gps_time = datetime.strptime(gps_time_elem.text, "%Y-%m-%dT%H:%M:%S")
|
|
468
|
+
|
|
469
|
+
# Convert GPS to UTC time as there is a few seconds between them
|
|
470
|
+
utc_time = gps_to_utc(gps_time).replace(tzinfo=timezone.utc)
|
|
471
|
+
|
|
472
|
+
# Parse positions in ECEF coordinate system
|
|
473
|
+
x, y, z = map(float, position_elem.text.split())
|
|
474
|
+
|
|
475
|
+
records.append(
|
|
476
|
+
{
|
|
477
|
+
"EPHEMERIS_TIME": pd.Timestamp(utc_time).tz_localize(None),
|
|
478
|
+
"EPHEMERIS_ECEF_X": x / 1000,
|
|
479
|
+
"EPHEMERIS_ECEF_Y": y / 1000,
|
|
480
|
+
"EPHEMERIS_ECEF_Z": z / 1000,
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
return pd.DataFrame(records)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def parse_sentinel_crs(granule_metadata_path: str) -> pyproj.CRS:
|
|
488
|
+
"""Parse the CRS in the granule metadata."""
|
|
489
|
+
tree = ET.parse(granule_metadata_path)
|
|
490
|
+
root = tree.getroot()
|
|
491
|
+
|
|
492
|
+
# Get the namespace of the XML file
|
|
493
|
+
ns = root[0].tag.split("}")[0][1:]
|
|
494
|
+
|
|
495
|
+
# Find the CS code in the XML file
|
|
496
|
+
epsg_elem = root.find(f".//{{{ns}}}Geometric_Info/Tile_Geocoding/HORIZONTAL_CS_CODE")
|
|
497
|
+
if epsg_elem is None or epsg_elem.text is None:
|
|
498
|
+
raise ValueError("HORIZONTAL_CS_CODE element not found or empty in metadata")
|
|
499
|
+
|
|
500
|
+
epsg_code = epsg_elem.text.strip()
|
|
501
|
+
|
|
502
|
+
return pyproj.CRS.from_string(epsg_code)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def parse_sensing_time(granule_metadata_path: str) -> pd.Timestamp:
|
|
506
|
+
"""Parse the sensing_time in the granule metadata."""
|
|
507
|
+
tree = ET.parse(granule_metadata_path)
|
|
508
|
+
root = tree.getroot()
|
|
509
|
+
|
|
510
|
+
# Get the namespace of the XML file
|
|
511
|
+
ns = root[0].tag.split("}")[0][1:]
|
|
512
|
+
|
|
513
|
+
# Find the SENSING_TIME element
|
|
514
|
+
sensing_elem = root.find(f".//{{{ns}}}General_Info/SENSING_TIME")
|
|
515
|
+
if sensing_elem is None or sensing_elem.text is None:
|
|
516
|
+
raise ValueError("SENSING_TIME element not found or empty in metadata")
|
|
517
|
+
|
|
518
|
+
sensing_time = sensing_elem.text.strip()
|
|
519
|
+
return pd.to_datetime(sensing_time)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def get_detector_id(
|
|
523
|
+
detector_band_metadata_path: str,
|
|
524
|
+
tile_metadata_path: str,
|
|
525
|
+
x: npt.NDArray[np.floating],
|
|
526
|
+
y: npt.NDArray[np.floating],
|
|
527
|
+
band: str = "B03",
|
|
528
|
+
) -> npt.NDArray[np.integer]:
|
|
529
|
+
"""
|
|
530
|
+
Return the detector ID that captured a given pixel in a Sentinel-2 image.
|
|
531
|
+
|
|
532
|
+
Parameters
|
|
533
|
+
----------
|
|
534
|
+
detector_band_metadata_path : str
|
|
535
|
+
Path to the MSK_DETFOO_Bxx.jp2 detector band mask file.
|
|
536
|
+
tile_metadata_path : str
|
|
537
|
+
Path to the tile metadata XML file (MTD_TL.xml) containing image geometry.
|
|
538
|
+
x : npt.NDArray[np.floating]
|
|
539
|
+
X coordinate (in UTM coordinate system) of the target pixel.
|
|
540
|
+
y : npt.NDArray[np.floating]
|
|
541
|
+
Y coordinate (in UTM coordinate system) of the target pixel.
|
|
542
|
+
band : str, optional
|
|
543
|
+
Spectral band to use for geometry parsing. Default is "B03".
|
|
544
|
+
|
|
545
|
+
Returns
|
|
546
|
+
-------
|
|
547
|
+
npt.NDArray[np.integer]
|
|
548
|
+
The detector ID (in the range 1 to 12) that captured the pixel. Returns 0 if
|
|
549
|
+
the pixel is outside the image bounds or not covered by any detector.
|
|
550
|
+
"""
|
|
551
|
+
x, y = np.atleast_1d(x, y)
|
|
552
|
+
|
|
553
|
+
detector_mask = parse_high_res_detector_mask(detector_band_metadata_path, scale=10)
|
|
554
|
+
|
|
555
|
+
height, width = detector_mask.shape
|
|
556
|
+
|
|
557
|
+
x_img, y_img = read_image_coordinates(tile_metadata_path, band)
|
|
558
|
+
x_min, x_max = float(x_img.min()), float(x_img.max())
|
|
559
|
+
y_min, y_max = float(y_img.min()), float(y_img.max())
|
|
560
|
+
|
|
561
|
+
# Compute resolution
|
|
562
|
+
pixel_width = (x_max - x_min) / width
|
|
563
|
+
pixel_height = (y_max - y_min) / height
|
|
564
|
+
|
|
565
|
+
valid = (x >= x_min) & (x <= x_max) & (y >= y_min) & (y <= y_max)
|
|
566
|
+
|
|
567
|
+
# Convert x, y to column, row
|
|
568
|
+
col = ((x[valid] - x_min) // pixel_width).astype(int)
|
|
569
|
+
row = ((y_max - y[valid]) // pixel_height).astype(int) # Note: y axis is top-down in images
|
|
570
|
+
|
|
571
|
+
out = np.zeros(x.shape, dtype=detector_mask.dtype)
|
|
572
|
+
out[valid] = detector_mask[row, col]
|
|
573
|
+
|
|
574
|
+
return out
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def get_time_delay_detectors(
|
|
578
|
+
datastrip_metadata_path: str, band: str = "B03"
|
|
579
|
+
) -> dict[int, pd.Timedelta]:
|
|
580
|
+
"""
|
|
581
|
+
Return the time delay for a given detector.
|
|
582
|
+
|
|
583
|
+
Detector id's are positioned in alternating viewing angle.
|
|
584
|
+
|
|
585
|
+
Even detectors capture earlier, odd detectors later.
|
|
586
|
+
Check page 41: https://sentiwiki.copernicus.eu/__attachments/1692737/S2-PDGS-CS-DI-PSD%20-%20S2%20Product%20Specification%20Document%202024%20-%2015.0.pdf?inst-v=e48c493c-f3ee-4a19-8673-f60058308b2a.
|
|
587
|
+
|
|
588
|
+
This function checks the DATASTRIP xml to find the reference times used
|
|
589
|
+
for intializing the offset. Currently it calculates the average time for a certain
|
|
590
|
+
band_id, and then returns the offset between the detector_id time and the average
|
|
591
|
+
time. (Unsure whether average is actually correct usage)
|
|
592
|
+
|
|
593
|
+
Parameters
|
|
594
|
+
----------
|
|
595
|
+
datastrip_metadata_path : str
|
|
596
|
+
The location of the DATASTRIP xml file
|
|
597
|
+
band : str, optional
|
|
598
|
+
Spectral band to use for geometry parsing. Default is "B03".
|
|
599
|
+
|
|
600
|
+
Returns
|
|
601
|
+
-------
|
|
602
|
+
dict[int, pd.Timedelta]
|
|
603
|
+
Time offset for each detector ID (1 to 12) as a dictionary.
|
|
604
|
+
"""
|
|
605
|
+
band_id = str(_band_id(band))
|
|
606
|
+
|
|
607
|
+
# Parse XML
|
|
608
|
+
tree = ET.parse(datastrip_metadata_path)
|
|
609
|
+
root = tree.getroot()
|
|
610
|
+
|
|
611
|
+
ns = root[0].tag.split("}")[0][1:]
|
|
612
|
+
|
|
613
|
+
time_information_element = root.find(
|
|
614
|
+
f".//{{{ns}}}Image_Data_Info/Sensor_Configuration/Time_Stamp"
|
|
615
|
+
)
|
|
616
|
+
if time_information_element is None:
|
|
617
|
+
raise ValueError("Time_Stamp element not found in DATASTRIP metadata")
|
|
618
|
+
|
|
619
|
+
cband = next((c for c in time_information_element if c.get("bandId") == band_id), None)
|
|
620
|
+
if cband is None:
|
|
621
|
+
raise ValueError(f"Band ID {band_id} not found in Time_Stamp element")
|
|
622
|
+
|
|
623
|
+
delays = {}
|
|
624
|
+
for detector in cband:
|
|
625
|
+
detector_id = detector.get("detectorId")
|
|
626
|
+
if detector_id is None:
|
|
627
|
+
continue
|
|
628
|
+
|
|
629
|
+
gps_time_elem = detector.find("GPS_TIME")
|
|
630
|
+
if gps_time_elem is None or gps_time_elem.text is None:
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
# Convert detector_id to int and store the GPS time
|
|
634
|
+
delays[int(detector_id)] = gps_time_elem.text
|
|
635
|
+
|
|
636
|
+
if not delays:
|
|
637
|
+
raise ValueError(f"No GPS times found for band {band_id}")
|
|
638
|
+
|
|
639
|
+
return _calculate_timedeltas(delays)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# -----------------------------------------------------------------------------------
|
|
643
|
+
# Time helper functions
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def gps_to_utc(gps_time: datetime) -> datetime:
|
|
647
|
+
"""Convert GPS time (datetime object) to UTC time.
|
|
648
|
+
|
|
649
|
+
https://gssc.esa.int/navipedia/index.php/Transformations_between_Time_Systems
|
|
650
|
+
"""
|
|
651
|
+
|
|
652
|
+
gps_tai_offset = timedelta(seconds=19)
|
|
653
|
+
utc_tai_offset = timedelta(seconds=37)
|
|
654
|
+
|
|
655
|
+
# Convert GPS time to UTC
|
|
656
|
+
return gps_time + gps_tai_offset - utc_tai_offset
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _calculate_average_time(times: Collection[datetime]) -> datetime:
|
|
660
|
+
"""Return the average time from a list of times."""
|
|
661
|
+
# Compute the average time
|
|
662
|
+
avg_timestamp = sum(t.timestamp() for t in times) / len(times)
|
|
663
|
+
return datetime.fromtimestamp(avg_timestamp)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _calculate_timedeltas(detector_times: dict[int, str]) -> dict[int, pd.Timedelta]:
|
|
667
|
+
"""Calculate the time difference between a detector and the average time."""
|
|
668
|
+
detector_times_dt = {
|
|
669
|
+
detector_id: datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%S.%f")
|
|
670
|
+
for detector_id, time_str in detector_times.items()
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
avg_time = _calculate_average_time(detector_times_dt.values())
|
|
674
|
+
return {
|
|
675
|
+
detector_id: pd.Timedelta(det_time - avg_time)
|
|
676
|
+
for detector_id, det_time in detector_times_dt.items()
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# -----------------------------------------------------------------------------------
|
|
681
|
+
# Viewing angle correction helper functions
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def process_pixel(
|
|
685
|
+
pixel_val: int,
|
|
686
|
+
pixel_location: tuple[int, int],
|
|
687
|
+
image_shape: tuple[int, ...],
|
|
688
|
+
azimuth_dict: dict[str, np.ndarray],
|
|
689
|
+
) -> float:
|
|
690
|
+
"""Map a pixel value and location to an azimuth value."""
|
|
691
|
+
# Convert dict keys to integers once
|
|
692
|
+
available_detectors = sorted(int(k) for k in azimuth_dict)
|
|
693
|
+
min_det = available_detectors[0]
|
|
694
|
+
max_det = available_detectors[-1]
|
|
695
|
+
|
|
696
|
+
# Inside your loop or function:
|
|
697
|
+
pixel_val = int(pixel_val) # Ensure it's an integer
|
|
698
|
+
pixel_val = max(min(pixel_val, max_det), min_det) # Clip to valid range
|
|
699
|
+
azi_array = azimuth_dict[str(pixel_val)]
|
|
700
|
+
|
|
701
|
+
# remap the pixel location to the 23x23 grid
|
|
702
|
+
i, j = pixel_location
|
|
703
|
+
H, W = image_shape
|
|
704
|
+
low_res_height, low_res_width = azi_array.shape
|
|
705
|
+
|
|
706
|
+
# Map (i,j) from high-res to low-res pixel coordinates
|
|
707
|
+
low_res_y = int(i * low_res_height / H)
|
|
708
|
+
low_res_x = int(j * low_res_width / W)
|
|
709
|
+
|
|
710
|
+
# Clamp to bounds (just in case)
|
|
711
|
+
low_res_y = min(low_res_y, low_res_height - 1)
|
|
712
|
+
low_res_x = min(low_res_x, low_res_width - 1)
|
|
713
|
+
|
|
714
|
+
# Get azimuth value at mapped pixel
|
|
715
|
+
return azi_array[low_res_y, low_res_x]
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def extrapolate_array(array: np.ndarray) -> np.ndarray:
|
|
719
|
+
"""Extrapolate NaN values in a 2D azimuth array using linear interpolation/extrapolation."""
|
|
720
|
+
# Get the shape
|
|
721
|
+
h, w = array.shape
|
|
722
|
+
|
|
723
|
+
# Meshgrid of coordinates
|
|
724
|
+
xx, yy = np.meshgrid(np.arange(w), np.arange(h))
|
|
725
|
+
|
|
726
|
+
# Mask for valid (non-NaN) values
|
|
727
|
+
mask = ~np.isnan(array)
|
|
728
|
+
|
|
729
|
+
# Known points and their values
|
|
730
|
+
known_points = np.stack((xx[mask], yy[mask]), axis=-1)
|
|
731
|
+
known_values = array[mask]
|
|
732
|
+
|
|
733
|
+
# Points to interpolate (includes all)
|
|
734
|
+
all_points = np.stack((xx.ravel(), yy.ravel()), axis=-1)
|
|
735
|
+
|
|
736
|
+
if np.unique(known_points[:, 0]).size < 2 or np.unique(known_points[:, 1]).size < 2:
|
|
737
|
+
# not enough variation in x or y — use nearest neighbor directly
|
|
738
|
+
interpolated = griddata(known_points, known_values, all_points, method="nearest")
|
|
739
|
+
else:
|
|
740
|
+
# Try linear, fallback to nearest
|
|
741
|
+
interpolated = griddata(known_points, known_values, all_points, method="linear")
|
|
742
|
+
nan_mask = np.isnan(interpolated)
|
|
743
|
+
if np.any(nan_mask):
|
|
744
|
+
interpolated[nan_mask] = griddata(
|
|
745
|
+
known_points, known_values, all_points[nan_mask], method="nearest"
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
return interpolated.reshape((h, w))
|