pycontrails 0.55.0__cp313-cp313-win_amd64.whl → 0.57.0__cp313-cp313-win_amd64.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.

Files changed (31) hide show
  1. pycontrails/_version.py +3 -3
  2. pycontrails/core/airports.py +1 -1
  3. pycontrails/core/cache.py +3 -3
  4. pycontrails/core/fleet.py +1 -1
  5. pycontrails/core/flight.py +47 -43
  6. pycontrails/core/met_var.py +1 -1
  7. pycontrails/core/rgi_cython.cp313-win_amd64.pyd +0 -0
  8. pycontrails/core/vector.py +28 -30
  9. pycontrails/datalib/geo_utils.py +261 -0
  10. pycontrails/datalib/gfs/gfs.py +58 -64
  11. pycontrails/datalib/goes.py +193 -399
  12. pycontrails/datalib/himawari/__init__.py +27 -0
  13. pycontrails/datalib/himawari/header_struct.py +266 -0
  14. pycontrails/datalib/himawari/himawari.py +654 -0
  15. pycontrails/datalib/landsat.py +49 -26
  16. pycontrails/datalib/leo_utils/__init__.py +5 -0
  17. pycontrails/datalib/leo_utils/correction.py +266 -0
  18. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  19. pycontrails/datalib/{_leo_utils → leo_utils}/search.py +1 -1
  20. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  21. pycontrails/datalib/sentinel.py +236 -93
  22. pycontrails/models/dry_advection.py +1 -1
  23. pycontrails/models/extended_k15.py +8 -8
  24. {pycontrails-0.55.0.dist-info → pycontrails-0.57.0.dist-info}/METADATA +4 -2
  25. {pycontrails-0.55.0.dist-info → pycontrails-0.57.0.dist-info}/RECORD +31 -23
  26. /pycontrails/datalib/{_leo_utils → leo_utils}/static/bq_roi_query.sql +0 -0
  27. /pycontrails/datalib/{_leo_utils → leo_utils}/vis.py +0 -0
  28. {pycontrails-0.55.0.dist-info → pycontrails-0.57.0.dist-info}/WHEEL +0 -0
  29. {pycontrails-0.55.0.dist-info → pycontrails-0.57.0.dist-info}/licenses/LICENSE +0 -0
  30. {pycontrails-0.55.0.dist-info → pycontrails-0.57.0.dist-info}/licenses/NOTICE +0 -0
  31. {pycontrails-0.55.0.dist-info → pycontrails-0.57.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))