pycontrails 0.53.0__cp313-cp313-macosx_10_13_x86_64.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/__init__.py +70 -0
- pycontrails/_version.py +16 -0
- pycontrails/core/__init__.py +30 -0
- pycontrails/core/aircraft_performance.py +641 -0
- pycontrails/core/airports.py +226 -0
- pycontrails/core/cache.py +881 -0
- pycontrails/core/coordinates.py +174 -0
- pycontrails/core/fleet.py +470 -0
- pycontrails/core/flight.py +2312 -0
- pycontrails/core/flightplan.py +220 -0
- pycontrails/core/fuel.py +140 -0
- pycontrails/core/interpolation.py +721 -0
- pycontrails/core/met.py +2833 -0
- pycontrails/core/met_var.py +307 -0
- pycontrails/core/models.py +1181 -0
- pycontrails/core/polygon.py +549 -0
- pycontrails/core/rgi_cython.cpython-313-darwin.so +0 -0
- pycontrails/core/vector.py +2191 -0
- pycontrails/datalib/__init__.py +12 -0
- pycontrails/datalib/_leo_utils/search.py +250 -0
- pycontrails/datalib/_leo_utils/static/bq_roi_query.sql +6 -0
- pycontrails/datalib/_leo_utils/vis.py +59 -0
- pycontrails/datalib/_met_utils/metsource.py +743 -0
- pycontrails/datalib/ecmwf/__init__.py +53 -0
- pycontrails/datalib/ecmwf/arco_era5.py +527 -0
- pycontrails/datalib/ecmwf/common.py +109 -0
- pycontrails/datalib/ecmwf/era5.py +538 -0
- pycontrails/datalib/ecmwf/era5_model_level.py +482 -0
- pycontrails/datalib/ecmwf/hres.py +782 -0
- pycontrails/datalib/ecmwf/hres_model_level.py +495 -0
- pycontrails/datalib/ecmwf/ifs.py +284 -0
- pycontrails/datalib/ecmwf/model_levels.py +79 -0
- pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
- pycontrails/datalib/ecmwf/variables.py +256 -0
- pycontrails/datalib/gfs/__init__.py +28 -0
- pycontrails/datalib/gfs/gfs.py +646 -0
- pycontrails/datalib/gfs/variables.py +100 -0
- pycontrails/datalib/goes.py +772 -0
- pycontrails/datalib/landsat.py +568 -0
- pycontrails/datalib/sentinel.py +512 -0
- pycontrails/datalib/spire.py +739 -0
- pycontrails/ext/bada.py +41 -0
- pycontrails/ext/cirium.py +14 -0
- pycontrails/ext/empirical_grid.py +140 -0
- pycontrails/ext/synthetic_flight.py +426 -0
- pycontrails/models/__init__.py +1 -0
- pycontrails/models/accf.py +406 -0
- pycontrails/models/apcemm/__init__.py +8 -0
- pycontrails/models/apcemm/apcemm.py +983 -0
- pycontrails/models/apcemm/inputs.py +226 -0
- pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
- pycontrails/models/apcemm/utils.py +437 -0
- pycontrails/models/cocip/__init__.py +29 -0
- pycontrails/models/cocip/cocip.py +2617 -0
- pycontrails/models/cocip/cocip_params.py +299 -0
- pycontrails/models/cocip/cocip_uncertainty.py +285 -0
- pycontrails/models/cocip/contrail_properties.py +1517 -0
- pycontrails/models/cocip/output_formats.py +2261 -0
- pycontrails/models/cocip/radiative_forcing.py +1262 -0
- pycontrails/models/cocip/radiative_heating.py +520 -0
- pycontrails/models/cocip/unterstrasser_wake_vortex.py +403 -0
- pycontrails/models/cocip/wake_vortex.py +396 -0
- pycontrails/models/cocip/wind_shear.py +120 -0
- pycontrails/models/cocipgrid/__init__.py +9 -0
- pycontrails/models/cocipgrid/cocip_grid.py +2573 -0
- pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
- pycontrails/models/dry_advection.py +486 -0
- pycontrails/models/emissions/__init__.py +21 -0
- pycontrails/models/emissions/black_carbon.py +594 -0
- pycontrails/models/emissions/emissions.py +1353 -0
- pycontrails/models/emissions/ffm2.py +336 -0
- pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
- pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
- pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
- pycontrails/models/humidity_scaling/__init__.py +37 -0
- pycontrails/models/humidity_scaling/humidity_scaling.py +1025 -0
- pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
- pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
- pycontrails/models/issr.py +210 -0
- pycontrails/models/pcc.py +327 -0
- pycontrails/models/pcr.py +154 -0
- pycontrails/models/ps_model/__init__.py +17 -0
- pycontrails/models/ps_model/ps_aircraft_params.py +376 -0
- pycontrails/models/ps_model/ps_grid.py +505 -0
- pycontrails/models/ps_model/ps_model.py +1017 -0
- pycontrails/models/ps_model/ps_operational_limits.py +540 -0
- pycontrails/models/ps_model/static/ps-aircraft-params-20240524.csv +68 -0
- pycontrails/models/ps_model/static/ps-synonym-list-20240524.csv +103 -0
- pycontrails/models/sac.py +459 -0
- pycontrails/models/tau_cirrus.py +168 -0
- pycontrails/physics/__init__.py +1 -0
- pycontrails/physics/constants.py +116 -0
- pycontrails/physics/geo.py +989 -0
- pycontrails/physics/jet.py +837 -0
- pycontrails/physics/thermo.py +451 -0
- pycontrails/physics/units.py +472 -0
- pycontrails/py.typed +0 -0
- pycontrails/utils/__init__.py +1 -0
- pycontrails/utils/dependencies.py +66 -0
- pycontrails/utils/iteration.py +13 -0
- pycontrails/utils/json.py +188 -0
- pycontrails/utils/temp.py +50 -0
- pycontrails/utils/types.py +165 -0
- pycontrails-0.53.0.dist-info/LICENSE +178 -0
- pycontrails-0.53.0.dist-info/METADATA +181 -0
- pycontrails-0.53.0.dist-info/NOTICE +43 -0
- pycontrails-0.53.0.dist-info/RECORD +109 -0
- pycontrails-0.53.0.dist-info/WHEEL +5 -0
- pycontrails-0.53.0.dist-info/top_level.txt +3 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Support for Sentinel-2 imagery retrieval through Google Cloud Platform."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pathlib
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from xml.etree import ElementTree
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
import xarray as xr
|
|
12
|
+
|
|
13
|
+
from pycontrails.core import Flight, cache
|
|
14
|
+
from pycontrails.datalib._leo_utils import search
|
|
15
|
+
from pycontrails.datalib._leo_utils.vis import equalize, normalize
|
|
16
|
+
from pycontrails.utils import dependencies
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import gcsfs
|
|
20
|
+
except ModuleNotFoundError as exc:
|
|
21
|
+
dependencies.raise_module_not_found_error(
|
|
22
|
+
name="sentinel module",
|
|
23
|
+
package_name="gcsfs",
|
|
24
|
+
module_not_found_error=exc,
|
|
25
|
+
pycontrails_optional_package="sat",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import pyproj
|
|
30
|
+
except ModuleNotFoundError as exc:
|
|
31
|
+
dependencies.raise_module_not_found_error(
|
|
32
|
+
name="sentinel module",
|
|
33
|
+
package_name="pyproj",
|
|
34
|
+
module_not_found_error=exc,
|
|
35
|
+
pycontrails_optional_package="sat",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
from PIL import Image
|
|
40
|
+
except ModuleNotFoundError as exc:
|
|
41
|
+
dependencies.raise_module_not_found_error(
|
|
42
|
+
name="sentinel module",
|
|
43
|
+
package_name="pillow",
|
|
44
|
+
module_not_found_error=exc,
|
|
45
|
+
pycontrails_optional_package="sat",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_path_to_static = pathlib.Path(__file__).parent / "static"
|
|
50
|
+
ROI_QUERY_FILENAME = _path_to_static / "sentinel_roi_query.sql"
|
|
51
|
+
|
|
52
|
+
#: BigQuery table with imagery metadata
|
|
53
|
+
BQ_TABLE = "bigquery-public-data.cloud_storage_geo_index.sentinel_2_index"
|
|
54
|
+
|
|
55
|
+
#: Default columns to include in queries
|
|
56
|
+
BQ_DEFAULT_COLUMNS = ["base_url", "granule_id", "sensing_time"]
|
|
57
|
+
|
|
58
|
+
#: Default spatial extent for queries
|
|
59
|
+
BQ_DEFAULT_EXTENT = search.GLOBAL_EXTENT
|
|
60
|
+
|
|
61
|
+
#: Default Sentinel channels to use if none are specified.
|
|
62
|
+
#: These are visible bands for producing a true color composite.
|
|
63
|
+
DEFAULT_BANDS = ["B02", "B03", "B04"]
|
|
64
|
+
|
|
65
|
+
#: Strip this prefix from GCP URLs when caching Sentinel files locally
|
|
66
|
+
GCP_STRIP_PREFIX = "gs://gcp-public-data-sentinel-2/"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def query(
|
|
70
|
+
start_time: np.datetime64,
|
|
71
|
+
end_time: np.datetime64,
|
|
72
|
+
extent: str | None = None,
|
|
73
|
+
columns: list[str] | None = None,
|
|
74
|
+
) -> pd.DataFrame:
|
|
75
|
+
"""Find Sentinel-2 imagery within spatiotemporal region of interest.
|
|
76
|
+
|
|
77
|
+
This function requires access to the
|
|
78
|
+
`Google BigQuery API <https://cloud.google.com/bigquery?hl=en>`__
|
|
79
|
+
and uses the `BigQuery python library <https://cloud.google.com/python/docs/reference/bigquery/latest/index.html>`__.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
start_time : np.datetime64
|
|
84
|
+
Start of time period for search
|
|
85
|
+
end_time : np.datetime64
|
|
86
|
+
End of time period for search
|
|
87
|
+
extent : str, optional
|
|
88
|
+
Spatial region of interest as a GeoJSON string. If not provided, defaults
|
|
89
|
+
to a global extent.
|
|
90
|
+
columns : list[str], optional
|
|
91
|
+
Columns to return from Google
|
|
92
|
+
`BigQuery table <https://console.cloud.google.com/bigquery?p=bigquery-public-data&d=cloud_storage_geo_index&t=landsat_index&page=table&_ga=2.90807450.1051800793.1716904050-255800408.1705955196>`__.
|
|
93
|
+
By default, returns imagery base URL, granule ID, and sensing time.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
pd.DataFrame
|
|
98
|
+
Query results in pandas DataFrame
|
|
99
|
+
|
|
100
|
+
See Also
|
|
101
|
+
--------
|
|
102
|
+
:func:`search.query`
|
|
103
|
+
"""
|
|
104
|
+
extent = extent or BQ_DEFAULT_EXTENT
|
|
105
|
+
roi = search.ROI(start_time, end_time, extent)
|
|
106
|
+
columns = columns or BQ_DEFAULT_COLUMNS
|
|
107
|
+
return search.query(BQ_TABLE, roi, columns)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def intersect(
|
|
111
|
+
flight: Flight,
|
|
112
|
+
columns: list[str] | None = None,
|
|
113
|
+
) -> pd.DataFrame:
|
|
114
|
+
"""Find Sentinel-2 imagery intersecting with flight track.
|
|
115
|
+
|
|
116
|
+
This function will return all scenes with a bounding box that includes flight waypoints
|
|
117
|
+
both before and after the sensing time.
|
|
118
|
+
|
|
119
|
+
This function requires access to the
|
|
120
|
+
`Google BigQuery API <https://cloud.google.com/bigquery?hl=en>`__
|
|
121
|
+
and uses the `BigQuery python library <https://cloud.google.com/python/docs/reference/bigquery/latest/index.html>`__.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
flight : Flight
|
|
126
|
+
Flight for intersection
|
|
127
|
+
columns : list[str], optional.
|
|
128
|
+
Columns to return from Google
|
|
129
|
+
`BigQuery table <https://console.cloud.google.com/bigquery?p=bigquery-public-data&d=cloud_storage_geo_index&t=landsat_index&page=table&_ga=2.90807450.1051800793.1716904050-255800408.1705955196>`__.
|
|
130
|
+
By default, returns imagery base URL, granule ID, and sensing time.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
pd.DataFrame
|
|
135
|
+
Query results in pandas DataFrame
|
|
136
|
+
|
|
137
|
+
See Also
|
|
138
|
+
--------
|
|
139
|
+
:func:`search.intersect`
|
|
140
|
+
"""
|
|
141
|
+
columns = columns or BQ_DEFAULT_COLUMNS
|
|
142
|
+
return search.intersect(BQ_TABLE, flight, columns)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Sentinel:
|
|
146
|
+
"""Support for Sentinel-2 data handling.
|
|
147
|
+
|
|
148
|
+
This class uses the `PROJ <https://proj.org/en/9.4/index.html>`__ coordinate
|
|
149
|
+
transformation software through the
|
|
150
|
+
`pyproj <https://pyproj4.github.io/pyproj/stable/index.html>`__ python interface.
|
|
151
|
+
pyproj is installed as part of the ``sat`` set of optional dependencies
|
|
152
|
+
(``pip install pycontrails[sat]``), but PROJ must be installed manually.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
base_url : str
|
|
157
|
+
Base URL of Sentinel-2 scene. To find URLs for Sentinel-2 scenes at
|
|
158
|
+
specific locations and times, see :func:`query` and :func:`intersect`.
|
|
159
|
+
granule_id : str
|
|
160
|
+
Granule ID of Sentinel-2 scene. To find URLs for Sentinel-2 scenes at
|
|
161
|
+
specific locations and times, see :func:`query` and :func:`intersect`.
|
|
162
|
+
bands : str | set[str] | None
|
|
163
|
+
Set of bands to retrieve. The 13 possible bands are represented by
|
|
164
|
+
the string "B01" to "B12" plus "B8A". For the true color scheme, set
|
|
165
|
+
``bands=("B02", "B03", "B04")``. By default, bands for the true color scheme
|
|
166
|
+
are used. Bands must share a common resolution. The resolutions of each band are:
|
|
167
|
+
|
|
168
|
+
- B02-B04, B08: 10 m
|
|
169
|
+
- B05-B07, B8A, B11, B12: 20 m
|
|
170
|
+
- B01, B09, B10: 60 m
|
|
171
|
+
|
|
172
|
+
cachestore : cache.CacheStore, optional
|
|
173
|
+
Cache store for Landsat data. If None, a :class:`DiskCacheStore` is used.
|
|
174
|
+
|
|
175
|
+
See Also
|
|
176
|
+
--------
|
|
177
|
+
query
|
|
178
|
+
intersect
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
base_url: str,
|
|
184
|
+
granule_id: str,
|
|
185
|
+
bands: str | Iterable[str] | None = None,
|
|
186
|
+
cachestore: cache.CacheStore | None = None,
|
|
187
|
+
) -> None:
|
|
188
|
+
|
|
189
|
+
self.base_url = base_url
|
|
190
|
+
self.granule_id = granule_id
|
|
191
|
+
self.bands = _parse_bands(bands)
|
|
192
|
+
_check_band_resolution(self.bands)
|
|
193
|
+
self.fs = gcsfs.GCSFileSystem(token="anon")
|
|
194
|
+
|
|
195
|
+
if cachestore is None:
|
|
196
|
+
cache_root = cache._get_user_cache_dir()
|
|
197
|
+
cache_dir = f"{cache_root}/sentinel"
|
|
198
|
+
cachestore = cache.DiskCacheStore(cache_dir=cache_dir)
|
|
199
|
+
self.cachestore = cachestore
|
|
200
|
+
|
|
201
|
+
def __repr__(self) -> str:
|
|
202
|
+
"""Return string representation."""
|
|
203
|
+
return (
|
|
204
|
+
f"Sentinel(base_url='{self.base_url}',\n"
|
|
205
|
+
f"\tgranule_id='{self.granule_id}',\n"
|
|
206
|
+
f"\tbands={sorted(self.bands)})"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def get(self, reflective: str = "reflectance") -> xr.Dataset:
|
|
210
|
+
"""Retrieve Sentinel-2 imagery.
|
|
211
|
+
|
|
212
|
+
Parameters
|
|
213
|
+
----------
|
|
214
|
+
reflective : str = {"raw", "reflectance"}, optional
|
|
215
|
+
Whether to return raw values or rescaled reflectances for reflective bands.
|
|
216
|
+
By default, return reflectances.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
xr.DataArray
|
|
221
|
+
DataArray of Sentinel-2 data.
|
|
222
|
+
"""
|
|
223
|
+
if reflective not in ["raw", "reflectance"]:
|
|
224
|
+
msg = "reflective band processing must be one of ['raw', 'radiance', 'reflectance']"
|
|
225
|
+
raise ValueError(msg)
|
|
226
|
+
|
|
227
|
+
ds = xr.Dataset()
|
|
228
|
+
for band in self.bands:
|
|
229
|
+
ds[band] = self._get(band, reflective)
|
|
230
|
+
return ds
|
|
231
|
+
|
|
232
|
+
def _get(self, band: str, processing: str) -> xr.DataArray:
|
|
233
|
+
"""Download Sentinel-2 band to the :attr:`cachestore` and return processed data."""
|
|
234
|
+
jp2_path = self._get_jp2(band)
|
|
235
|
+
granule_meta_path, safe_meta_path = self._get_meta()
|
|
236
|
+
return _read(jp2_path, granule_meta_path, safe_meta_path, band, processing)
|
|
237
|
+
|
|
238
|
+
def _get_jp2(self, band: str) -> str:
|
|
239
|
+
"""Download Sentinel-2 imagery and return path to cached file."""
|
|
240
|
+
fs = self.fs
|
|
241
|
+
base_url = self.base_url
|
|
242
|
+
granule_id = self.granule_id
|
|
243
|
+
prefix = f"{base_url}/GRANULE/{granule_id}/IMG_DATA"
|
|
244
|
+
files = fs.ls(prefix)
|
|
245
|
+
|
|
246
|
+
urls = [f"gs://{f}" for f in files if f.endswith(f"{band}.jp2")]
|
|
247
|
+
if len(urls) > 1:
|
|
248
|
+
msg = f"Multiple image files found for band {band}"
|
|
249
|
+
raise ValueError(msg)
|
|
250
|
+
if len(urls) == 0:
|
|
251
|
+
msg = f"No image files found for band {band}"
|
|
252
|
+
raise ValueError(msg)
|
|
253
|
+
url = urls[0]
|
|
254
|
+
|
|
255
|
+
sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
|
|
256
|
+
if not self.cachestore.exists(sink):
|
|
257
|
+
fs.get(url, sink)
|
|
258
|
+
return sink
|
|
259
|
+
|
|
260
|
+
def _get_meta(self) -> tuple[str, str]:
|
|
261
|
+
"""Download Sentinel-2 metadata files and return path to cached files.
|
|
262
|
+
|
|
263
|
+
Note that two XML files must be retrieved: one inside the GRANULE
|
|
264
|
+
subdirectory, and one at the top level of the SAFE archive.
|
|
265
|
+
"""
|
|
266
|
+
fs = self.fs
|
|
267
|
+
base_url = self.base_url
|
|
268
|
+
granule_id = self.granule_id
|
|
269
|
+
|
|
270
|
+
url = f"{base_url}/GRANULE/{granule_id}/MTD_TL.xml"
|
|
271
|
+
granule_sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
|
|
272
|
+
if not self.cachestore.exists(granule_sink):
|
|
273
|
+
fs.get(url, granule_sink)
|
|
274
|
+
|
|
275
|
+
url = f"{base_url}/MTD_MSIL1C.xml"
|
|
276
|
+
safe_sink = self.cachestore.path(url.removeprefix(GCP_STRIP_PREFIX))
|
|
277
|
+
if not self.cachestore.exists(safe_sink):
|
|
278
|
+
fs.get(url, safe_sink)
|
|
279
|
+
|
|
280
|
+
return granule_sink, safe_sink
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _parse_bands(bands: str | Iterable[str] | None) -> set[str]:
|
|
284
|
+
"""Check that the bands are valid and return as a set."""
|
|
285
|
+
if bands is None:
|
|
286
|
+
return set(DEFAULT_BANDS)
|
|
287
|
+
|
|
288
|
+
if isinstance(bands, str):
|
|
289
|
+
bands = (bands,)
|
|
290
|
+
|
|
291
|
+
available = {f"B{i:02d}" for i in range(1, 13)} | {"B8A"}
|
|
292
|
+
bands = {b.upper() for b in bands}
|
|
293
|
+
if len(bands) == 0:
|
|
294
|
+
msg = "At least one band must be provided"
|
|
295
|
+
raise ValueError(msg)
|
|
296
|
+
if not bands.issubset(available):
|
|
297
|
+
msg = f"Bands must be in {sorted(available)}"
|
|
298
|
+
raise ValueError(msg)
|
|
299
|
+
return bands
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _check_band_resolution(bands: set[str]) -> None:
|
|
303
|
+
"""Confirm requested bands have a common horizontal resolution."""
|
|
304
|
+
groups = [
|
|
305
|
+
{"B02", "B03", "B04", "B08"}, # 10 m
|
|
306
|
+
{"B05", "B06", "B07", "B8A", "B11", "B12"}, # 20 m
|
|
307
|
+
{"B01", "B09", "B10"}, # 60 m
|
|
308
|
+
]
|
|
309
|
+
if not any(bands.issubset(group) for group in groups):
|
|
310
|
+
msg = "Bands must have a common horizontal resolution."
|
|
311
|
+
raise ValueError(msg)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _read(path: str, granule_meta: str, safe_meta: str, band: str, processing: str) -> xr.DataArray:
|
|
315
|
+
"""Read imagery data from Sentinel-2 files."""
|
|
316
|
+
Image.MAX_IMAGE_PIXELS = None # avoid decompression bomb warning
|
|
317
|
+
src = Image.open(path)
|
|
318
|
+
img = np.asarray(src)
|
|
319
|
+
src.close()
|
|
320
|
+
|
|
321
|
+
if processing == "reflectance":
|
|
322
|
+
gain, offset = _read_band_reflectance_rescaling(safe_meta, band)
|
|
323
|
+
img = np.where(img == 0, np.nan, (img + offset) / gain).astype("float32")
|
|
324
|
+
|
|
325
|
+
tree = ElementTree.parse(granule_meta)
|
|
326
|
+
elem = tree.find(".//HORIZONTAL_CS_CODE")
|
|
327
|
+
if elem is None or elem.text is None:
|
|
328
|
+
msg = "Could not find imagery projection in metadata."
|
|
329
|
+
raise ValueError(msg)
|
|
330
|
+
epsg = int(elem.text.split(":")[1])
|
|
331
|
+
crs = pyproj.CRS.from_epsg(epsg)
|
|
332
|
+
|
|
333
|
+
x, y = _read_image_coordinates(granule_meta, band)
|
|
334
|
+
|
|
335
|
+
da = xr.DataArray(
|
|
336
|
+
data=img,
|
|
337
|
+
coords={"y": y, "x": x},
|
|
338
|
+
dims=("y", "x"),
|
|
339
|
+
attrs={
|
|
340
|
+
"long_name": f"{band} {processing}",
|
|
341
|
+
"units": "nondim" if processing == "reflectance" else "none",
|
|
342
|
+
"crs": crs,
|
|
343
|
+
},
|
|
344
|
+
)
|
|
345
|
+
da["x"].attrs = {"long_name": "easting", "units": "m"}
|
|
346
|
+
da["y"].attrs = {"long_name": "northing", "units": "m"}
|
|
347
|
+
return da
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _band_resolution(band: str) -> int:
|
|
351
|
+
"""Get band resolution in meters."""
|
|
352
|
+
return (
|
|
353
|
+
60 if band in ("B01", "B09", "B10") else 10 if band in ("B02", "B03", "B04", "B08") else 20
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _band_id(band: str) -> int:
|
|
358
|
+
"""Get band ID used in some metadata files."""
|
|
359
|
+
if band in (f"B{i:2d}" for i in range(1, 9)):
|
|
360
|
+
return int(band[1:]) - 1
|
|
361
|
+
elif band == "B8A":
|
|
362
|
+
return 8
|
|
363
|
+
else:
|
|
364
|
+
return int(band[1:])
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _read_band_reflectance_rescaling(meta: str, band: str) -> tuple[float, float]:
|
|
368
|
+
"""Read reflectance rescaling factors from metadata file.
|
|
369
|
+
|
|
370
|
+
See https://sentiwiki.copernicus.eu/web/s2-processing#S2Processing-TOAReflectanceComputation
|
|
371
|
+
and https://scihub.copernicus.eu/news/News00931.
|
|
372
|
+
"""
|
|
373
|
+
# Find quantization gain (present in all files)
|
|
374
|
+
tree = ElementTree.parse(meta)
|
|
375
|
+
elem = tree.find(".//QUANTIFICATION_VALUE")
|
|
376
|
+
if elem is None or elem.text is None:
|
|
377
|
+
msg = "Could not find reflectance quantization gain."
|
|
378
|
+
raise ValueError(msg)
|
|
379
|
+
gain = float(elem.text)
|
|
380
|
+
|
|
381
|
+
# See if offset (used in recently processed files) is present
|
|
382
|
+
elems = tree.findall(".//RADIO_ADD_OFFSET")
|
|
383
|
+
|
|
384
|
+
# If not, set offset to 0
|
|
385
|
+
if len(elems) == 0:
|
|
386
|
+
return gain, 0.0
|
|
387
|
+
|
|
388
|
+
# Otherwise, search for offset with correct band ID
|
|
389
|
+
band_id = _band_id(band)
|
|
390
|
+
for elem in elems:
|
|
391
|
+
if int(elem.attrib["band_id"]) == band_id and elem.text is not None:
|
|
392
|
+
offset = float(elem.text)
|
|
393
|
+
break
|
|
394
|
+
else:
|
|
395
|
+
msg = f"Could not find reflectance offset for band {band} (band ID {band_id})"
|
|
396
|
+
raise ValueError(msg)
|
|
397
|
+
|
|
398
|
+
return gain, offset
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _read_image_coordinates(meta: str, band: str) -> tuple[np.ndarray, np.ndarray]:
|
|
402
|
+
"""Read image x and y coordinates."""
|
|
403
|
+
|
|
404
|
+
# convenience function that satisfies mypy
|
|
405
|
+
def _text_from_tag(parent: ElementTree.Element, tag: str) -> str:
|
|
406
|
+
elem = parent.find(tag)
|
|
407
|
+
if elem is None or elem.text is None:
|
|
408
|
+
msg = f"Could not find text in {tag} element"
|
|
409
|
+
raise ValueError(msg)
|
|
410
|
+
return elem.text
|
|
411
|
+
|
|
412
|
+
resolution = _band_resolution(band)
|
|
413
|
+
|
|
414
|
+
# find coordinates of upper left corner and pixel size
|
|
415
|
+
tree = ElementTree.parse(meta)
|
|
416
|
+
elems = tree.findall(".//Geoposition")
|
|
417
|
+
for elem in elems:
|
|
418
|
+
if int(elem.attrib["resolution"]) == resolution:
|
|
419
|
+
ulx = float(_text_from_tag(elem, "ULX"))
|
|
420
|
+
uly = float(_text_from_tag(elem, "ULY"))
|
|
421
|
+
dx = float(_text_from_tag(elem, "XDIM"))
|
|
422
|
+
dy = float(_text_from_tag(elem, "YDIM"))
|
|
423
|
+
break
|
|
424
|
+
else:
|
|
425
|
+
msg = f"Could not find image geoposition for resolution of {resolution} m"
|
|
426
|
+
raise ValueError(msg)
|
|
427
|
+
|
|
428
|
+
# find image size
|
|
429
|
+
elems = tree.findall(".//Size")
|
|
430
|
+
for elem in elems:
|
|
431
|
+
if int(elem.attrib["resolution"]) == resolution:
|
|
432
|
+
nx = int(_text_from_tag(elem, "NCOLS"))
|
|
433
|
+
ny = int(_text_from_tag(elem, "NROWS"))
|
|
434
|
+
break
|
|
435
|
+
else:
|
|
436
|
+
msg = f"Could not find image size for resolution of {resolution} m"
|
|
437
|
+
raise ValueError(msg)
|
|
438
|
+
|
|
439
|
+
# compute pixel coordinates
|
|
440
|
+
xlim = (ulx, ulx + (nx - 1) * dx)
|
|
441
|
+
ylim = (uly, uly + (ny - 1) * dy) # dy is < 0
|
|
442
|
+
x = np.linspace(xlim[0], xlim[1], nx)
|
|
443
|
+
y = np.linspace(ylim[0], ylim[1], ny)
|
|
444
|
+
|
|
445
|
+
return x, y
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def extract_sentinel_visualization(
|
|
449
|
+
ds: xr.Dataset, color_scheme: str = "true"
|
|
450
|
+
) -> tuple[np.ndarray, pyproj.CRS, tuple[float, float, float, float]]:
|
|
451
|
+
"""Extract artifacts for visualizing Sentinel data with the given color scheme.
|
|
452
|
+
|
|
453
|
+
Parameters
|
|
454
|
+
----------
|
|
455
|
+
ds : xr.Dataset
|
|
456
|
+
Dataset of Sentinel data as returned by :meth:`Sentinel.get`.
|
|
457
|
+
color_scheme : str = {"true"}
|
|
458
|
+
Color scheme to use for visualization. The true color scheme
|
|
459
|
+
(the only option currently implemented) requires bands B02, B03, and B04.
|
|
460
|
+
|
|
461
|
+
Returns
|
|
462
|
+
-------
|
|
463
|
+
rgb : npt.NDArray[np.float32]
|
|
464
|
+
3D RGB array of shape ``(height, width, 3)``.
|
|
465
|
+
src_crs : pyproj.CRS
|
|
466
|
+
Imagery projection
|
|
467
|
+
src_extent : tuple[float,float,float,float]
|
|
468
|
+
Imagery extent in projected coordinates
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
if color_scheme == "true":
|
|
472
|
+
rgb, src_crs = to_true_color(ds)
|
|
473
|
+
else:
|
|
474
|
+
raise ValueError(f"Color scheme must be 'true', not '{color_scheme}'")
|
|
475
|
+
|
|
476
|
+
x = ds["x"].values
|
|
477
|
+
y = ds["y"].values
|
|
478
|
+
src_extent = x.min(), x.max(), y.min(), y.max()
|
|
479
|
+
|
|
480
|
+
return rgb, src_crs, src_extent
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def to_true_color(ds: xr.Dataset) -> tuple[np.ndarray, pyproj.CRS]:
|
|
484
|
+
"""Compute 3d RGB array for the true color scheme.
|
|
485
|
+
|
|
486
|
+
Parameters
|
|
487
|
+
----------
|
|
488
|
+
ds : xr.Dataset
|
|
489
|
+
DataArray of Sentinel data with bands B02, B03, and B04.
|
|
490
|
+
|
|
491
|
+
Returns
|
|
492
|
+
-------
|
|
493
|
+
np.ndarray
|
|
494
|
+
3d RGB array with true color scheme.
|
|
495
|
+
|
|
496
|
+
src_crs : pyproj.CRS
|
|
497
|
+
Imagery projection
|
|
498
|
+
"""
|
|
499
|
+
red = ds["B04"]
|
|
500
|
+
green = ds["B03"]
|
|
501
|
+
blue = ds["B02"]
|
|
502
|
+
|
|
503
|
+
crs = red.attrs["crs"]
|
|
504
|
+
if not (crs.equals(green.attrs["crs"]) and crs.equals(blue.attrs["crs"])):
|
|
505
|
+
msg = "Bands B02, B03, and B04 do not share a common projection."
|
|
506
|
+
raise ValueError(msg)
|
|
507
|
+
|
|
508
|
+
img = np.dstack(
|
|
509
|
+
[equalize(normalize(band.values), clip_limit=0.03) for band in (red, green, blue)]
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
return img, crs
|