pyramids-eo 0.1.0__py3-none-any.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.
- pyramids_eo/__init__.py +33 -0
- pyramids_eo/composites/__init__.py +49 -0
- pyramids_eo/composites/_common.py +55 -0
- pyramids_eo/composites/background.py +173 -0
- pyramids_eo/composites/blend.py +134 -0
- pyramids_eo/composites/geometry.py +175 -0
- pyramids_eo/composites/overlay.py +86 -0
- pyramids_eo/composites/true_color.py +66 -0
- pyramids_eo/composites/true_color_night.py +87 -0
- pyramids_eo/earthengine/__init__.py +30 -0
- pyramids_eo/earthengine/credentials.py +390 -0
- pyramids_eo/earthengine/reader.py +1205 -0
- pyramids_eo/errors.py +27 -0
- pyramids_eo/readers/__init__.py +23 -0
- pyramids_eo/readers/_common.py +78 -0
- pyramids_eo/readers/fci.py +160 -0
- pyramids_eo/readers/harmonise.py +81 -0
- pyramids_eo/readers/seviri.py +106 -0
- pyramids_eo/registry/__init__.py +27 -0
- pyramids_eo/registry/calibration.py +110 -0
- pyramids_eo/registry/data/fci.yaml +25 -0
- pyramids_eo/registry/data/seviri.yaml +21 -0
- pyramids_eo/registry/sensors.py +109 -0
- pyramids_eo/stac/__init__.py +41 -0
- pyramids_eo/stac/auth_cdse.py +86 -0
- pyramids_eo/stac/signers.py +1120 -0
- pyramids_eo-0.1.0.dist-info/METADATA +87 -0
- pyramids_eo-0.1.0.dist-info/RECORD +31 -0
- pyramids_eo-0.1.0.dist-info/WHEEL +5 -0
- pyramids_eo-0.1.0.dist-info/licenses/LICENSE +674 -0
- pyramids_eo-0.1.0.dist-info/top_level.txt +1 -0
pyramids_eo/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""pyramids-eo — the Earth-observation layer of the pyramids stack.
|
|
2
|
+
|
|
3
|
+
``pyramids-eo`` is built on top of :mod:`pyramids` (``pyramids-gis``, the generic
|
|
4
|
+
raster/vector engine) and adds the logic that is specific to
|
|
5
|
+
**Earth-observation data**. Where pyramids knows how to *move rasters around*,
|
|
6
|
+
pyramids-eo knows what a pixel *means* for a given instrument or provider: which
|
|
7
|
+
subdataset is which channel, how to calibrate it, how to composite it, how to
|
|
8
|
+
resample a swath — and how to reach signed EO cloud assets. It is scoped by
|
|
9
|
+
*domain* (EO data), not by a restriction on what it may do.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from importlib.metadata import PackageNotFoundError
|
|
15
|
+
from importlib.metadata import version as _get_version
|
|
16
|
+
|
|
17
|
+
from pyramids_eo.earthengine import (
|
|
18
|
+
EarthEngineCredentials,
|
|
19
|
+
collection_from_earthengine,
|
|
20
|
+
from_earthengine,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
__version__ = _get_version("pyramids-eo")
|
|
25
|
+
except PackageNotFoundError: # pragma: no cover
|
|
26
|
+
__version__ = "unknown"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"EarthEngineCredentials",
|
|
30
|
+
"__version__",
|
|
31
|
+
"collection_from_earthengine",
|
|
32
|
+
"from_earthengine",
|
|
33
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Day/night compositing for EO imagery.
|
|
2
|
+
|
|
3
|
+
The pyramids-eo day/night composite chain (the `true_color_with_night_ir`
|
|
4
|
+
look), implemented over NumPy + pyramids-gis with no third-party compositing
|
|
5
|
+
dependency. So far:
|
|
6
|
+
|
|
7
|
+
* `solar_zenith_angle` / `cos_solar_zenith_angle` — per-pixel solar zenith angle
|
|
8
|
+
(degrees) and its cosine (the `cos_sza` form the readers expect), the geometry
|
|
9
|
+
the day/night blend keys off.
|
|
10
|
+
* `day_night_blend` / `day_weight` — the SZA-weighted cross-fade of a day and a
|
|
11
|
+
night image.
|
|
12
|
+
* `alpha_overlay` — the "over" composite of an RGBA foreground on an RGB(A)
|
|
13
|
+
background.
|
|
14
|
+
* `static_image` — load a georeferenced background image (e.g. Black Marble),
|
|
15
|
+
caching a remote URL and warping it to a target grid.
|
|
16
|
+
* `true_color` — true-colour RGB from calibrated reflectance bands with a CIMSS
|
|
17
|
+
synthetic green (no Rayleigh).
|
|
18
|
+
* `night_ir` / `true_color_with_night_ir` — assemble the full day/night image
|
|
19
|
+
(RGBA IR clouds over city lights, cross-faded against the day image by SZA).
|
|
20
|
+
|
|
21
|
+
Together these compose the `true_color_with_night_ir` day/night look.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from pyramids_eo.composites.background import static_image
|
|
27
|
+
from pyramids_eo.composites.blend import day_night_blend, day_weight
|
|
28
|
+
from pyramids_eo.composites.geometry import (
|
|
29
|
+
cos_solar_zenith_angle,
|
|
30
|
+
solar_zenith_angle,
|
|
31
|
+
)
|
|
32
|
+
from pyramids_eo.composites.overlay import alpha_overlay
|
|
33
|
+
from pyramids_eo.composites.true_color import true_color
|
|
34
|
+
from pyramids_eo.composites.true_color_night import (
|
|
35
|
+
night_ir,
|
|
36
|
+
true_color_with_night_ir,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"alpha_overlay",
|
|
41
|
+
"cos_solar_zenith_angle",
|
|
42
|
+
"day_night_blend",
|
|
43
|
+
"day_weight",
|
|
44
|
+
"night_ir",
|
|
45
|
+
"solar_zenith_angle",
|
|
46
|
+
"static_image",
|
|
47
|
+
"true_color",
|
|
48
|
+
"true_color_with_night_ir",
|
|
49
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared helpers for the compositing primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _as_array(value: Any) -> np.ndarray:
|
|
11
|
+
"""Return `value` as a float ndarray, reading a pyramids `Dataset` if given.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
value: An ndarray-like, or a pyramids `Dataset` (read via `read_array`).
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
A float ndarray.
|
|
18
|
+
"""
|
|
19
|
+
if hasattr(value, "read_array"):
|
|
20
|
+
return np.asarray(value.read_array(), dtype=float)
|
|
21
|
+
return np.asarray(value, dtype=float)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _wrap_like(out: np.ndarray, *candidates: Any) -> Any:
|
|
25
|
+
"""Wrap `out` in a pyramids `Dataset` cloned from the first Dataset candidate.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
out: The result array to return.
|
|
29
|
+
*candidates: Inputs to search for a georeferenced template; the first one
|
|
30
|
+
exposing both `read_array` and `geotransform` supplies the
|
|
31
|
+
geotransform + CRS.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A pyramids `Dataset` carrying the template's geotransform + CRS when a
|
|
35
|
+
candidate is a `Dataset`, otherwise `out` unchanged (an ndarray).
|
|
36
|
+
"""
|
|
37
|
+
out = np.asarray(out, dtype=float)
|
|
38
|
+
template = next(
|
|
39
|
+
(
|
|
40
|
+
candidate
|
|
41
|
+
for candidate in candidates
|
|
42
|
+
if hasattr(candidate, "read_array") and hasattr(candidate, "geotransform")
|
|
43
|
+
),
|
|
44
|
+
None,
|
|
45
|
+
)
|
|
46
|
+
if template is None:
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
from pyramids.dataset import Dataset
|
|
50
|
+
|
|
51
|
+
# Composited data can legitimately hold NaN (masked terminator / gaps), so
|
|
52
|
+
# declare NaN as the nodata value rather than the default -9999 sentinel.
|
|
53
|
+
return Dataset.create_from_array(
|
|
54
|
+
out, geo=template.geotransform, epsg=template.epsg, no_data_value=np.nan
|
|
55
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Static georeferenced background images for compositing.
|
|
2
|
+
|
|
3
|
+
`static_image` loads a georeferenced raster (e.g. the NASA **Black Marble** city
|
|
4
|
+
lights that back the night-IR clouds), caching it locally when the source is a
|
|
5
|
+
URL, and optionally warps/crops it to another dataset's grid via pyramids'
|
|
6
|
+
`align`.
|
|
7
|
+
|
|
8
|
+
It takes a plain local **or** remote path and caches a remote one. A live
|
|
9
|
+
Black Marble mirror is
|
|
10
|
+
`https://eoimages.gsfc.nasa.gov/images/imagerecords/144000/144898/BlackMarble_2016_3km_geo.tif`
|
|
11
|
+
(the older `neo.gsfc.nasa.gov/archive/blackmarble/...` URLs are 404).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.parse import urlparse
|
|
22
|
+
|
|
23
|
+
from pyramids_eo.errors import EOError
|
|
24
|
+
|
|
25
|
+
#: Default cache directory for downloaded static images.
|
|
26
|
+
_DEFAULT_CACHE = Path.home() / ".cache" / "pyramids-eo"
|
|
27
|
+
#: Default cap on a single cached download (bytes) — guards against disk fill.
|
|
28
|
+
_DEFAULT_MAX_BYTES = 1_000_000_000
|
|
29
|
+
#: Read block size for streamed downloads.
|
|
30
|
+
_BLOCK = 65536
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _download(
|
|
34
|
+
url: str, target: Path, timeout: float, max_bytes: int = _DEFAULT_MAX_BYTES
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Stream `url` to `target`, writing atomically via a `.part` temp file.
|
|
37
|
+
|
|
38
|
+
Streams in blocks with a running size cap and removes the `.part` file on any
|
|
39
|
+
failure, so a partial/oversized download never lingers in the cache.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
url: The http/https source URL.
|
|
43
|
+
target: Destination path for the downloaded file.
|
|
44
|
+
timeout: Per-request timeout in seconds.
|
|
45
|
+
max_bytes: Maximum bytes to accept before aborting (default ~1 GB).
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
EOError: When the download exceeds `max_bytes`.
|
|
49
|
+
"""
|
|
50
|
+
request = urllib.request.Request(url)
|
|
51
|
+
part = target.with_name(target.name + ".part")
|
|
52
|
+
try:
|
|
53
|
+
total = 0
|
|
54
|
+
with (
|
|
55
|
+
urllib.request.urlopen(request, timeout=timeout) as response, # nosec B310 - scheme restricted to http/https by _resolve_source
|
|
56
|
+
open(part, "wb") as handle,
|
|
57
|
+
):
|
|
58
|
+
while block := response.read(_BLOCK):
|
|
59
|
+
total += len(block)
|
|
60
|
+
if total > max_bytes:
|
|
61
|
+
raise EOError(
|
|
62
|
+
f"static image exceeds the {max_bytes}-byte download cap: {url}"
|
|
63
|
+
)
|
|
64
|
+
handle.write(block)
|
|
65
|
+
os.replace(part, target)
|
|
66
|
+
finally:
|
|
67
|
+
part.unlink(missing_ok=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cache_path(cache: Path, url: str) -> Path:
|
|
71
|
+
"""Return the cache filename for `url` — a full-URL hash plus its basename.
|
|
72
|
+
|
|
73
|
+
Hashing the whole URL avoids collisions between different URLs that share a
|
|
74
|
+
basename (e.g. `.../2016/BlackMarble.tif` vs `.../2020/BlackMarble.tif`) and
|
|
75
|
+
handles a URL with an empty path.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
cache: The cache directory.
|
|
79
|
+
url: The source URL.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The path the download is cached at.
|
|
83
|
+
"""
|
|
84
|
+
basename = Path(urlparse(url).path).name or "download"
|
|
85
|
+
digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
|
|
86
|
+
return cache / f"{digest}-{basename}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_source(
|
|
90
|
+
source: Any,
|
|
91
|
+
cache_dir: Any,
|
|
92
|
+
timeout: float,
|
|
93
|
+
max_bytes: int = _DEFAULT_MAX_BYTES,
|
|
94
|
+
refresh: bool = False,
|
|
95
|
+
) -> Path:
|
|
96
|
+
"""Return a local path for `source`, downloading + caching a URL if needed.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
source: A local filesystem path (relative or absolute) or an http/https
|
|
100
|
+
URL.
|
|
101
|
+
cache_dir: Directory to cache a downloaded URL (default `_DEFAULT_CACHE`).
|
|
102
|
+
timeout: Per-request timeout in seconds for a download.
|
|
103
|
+
max_bytes: Maximum bytes to accept for a download.
|
|
104
|
+
refresh: When `True`, re-download a URL even if a cached copy exists
|
|
105
|
+
(cache reuse is otherwise size-only, so a changed remote is not
|
|
106
|
+
picked up).
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The path to the local (possibly just-cached) file.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
FileNotFoundError: When a local `source` does not exist.
|
|
113
|
+
"""
|
|
114
|
+
parsed = urlparse(str(source))
|
|
115
|
+
if parsed.scheme in ("http", "https"):
|
|
116
|
+
cache = Path(cache_dir) if cache_dir is not None else _DEFAULT_CACHE
|
|
117
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
target = _cache_path(cache, str(source))
|
|
119
|
+
if refresh or not (target.exists() and target.stat().st_size > 0):
|
|
120
|
+
_download(str(source), target, timeout, max_bytes)
|
|
121
|
+
return target
|
|
122
|
+
path = Path(source)
|
|
123
|
+
if not path.exists():
|
|
124
|
+
raise FileNotFoundError(f"static image not found: {path}")
|
|
125
|
+
return path
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def static_image(
|
|
129
|
+
source: Any,
|
|
130
|
+
*,
|
|
131
|
+
like: Any = None,
|
|
132
|
+
cache_dir: Any = None,
|
|
133
|
+
timeout: float = 60.0,
|
|
134
|
+
max_bytes: int = _DEFAULT_MAX_BYTES,
|
|
135
|
+
refresh: bool = False,
|
|
136
|
+
) -> Any:
|
|
137
|
+
"""Load a georeferenced image, caching a remote URL and warping to a grid.
|
|
138
|
+
|
|
139
|
+
Loads a georeferenced background image. `source` may be a local path
|
|
140
|
+
(relative or absolute) or an http/https URL; a URL is downloaded once and
|
|
141
|
+
cached under `cache_dir`. When `like` is given, the loaded image is warped
|
|
142
|
+
and cropped onto that dataset's grid (CRS + rows/columns + cell size) via
|
|
143
|
+
pyramids' `align`.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
source: A local filesystem path or an http/https URL to a georeferenced
|
|
147
|
+
raster (e.g. the Black Marble GeoTIFF).
|
|
148
|
+
like: A pyramids `Dataset` whose grid the image is warped/cropped to. When
|
|
149
|
+
`None`, the image is returned at its native grid.
|
|
150
|
+
cache_dir: Directory used to cache a downloaded URL. Defaults to
|
|
151
|
+
`~/.cache/pyramids-eo`. Ignored for a local `source`.
|
|
152
|
+
timeout: Per-request download timeout in seconds (default 60).
|
|
153
|
+
max_bytes: Cap on a URL download in bytes (default ~1 GB) — guards against
|
|
154
|
+
an oversized/hostile URL filling the cache.
|
|
155
|
+
refresh: When `True`, re-download a URL even if it is already cached
|
|
156
|
+
(the cache is otherwise reused whenever the file is present).
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
A pyramids `Dataset` — aligned to `like`'s grid when `like` is given,
|
|
160
|
+
otherwise the image at its native grid.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
FileNotFoundError: When a local `source` does not exist.
|
|
164
|
+
EOError: When a URL download exceeds `max_bytes`.
|
|
165
|
+
"""
|
|
166
|
+
path = _resolve_source(source, cache_dir, timeout, max_bytes, refresh)
|
|
167
|
+
|
|
168
|
+
from pyramids.dataset import Dataset
|
|
169
|
+
|
|
170
|
+
dataset = Dataset.read_file(str(path))
|
|
171
|
+
if like is not None:
|
|
172
|
+
dataset = dataset.align(like)
|
|
173
|
+
return dataset
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Solar-zenith-angle day/night blending.
|
|
2
|
+
|
|
3
|
+
`day_night_blend` cross-fades a *day* image and a *night* image by the
|
|
4
|
+
per-pixel solar zenith angle (SZA), producing the smooth twilight transition of
|
|
5
|
+
the `true_color_with_night_ir` look. The blend keys off the Sun's geometric
|
|
6
|
+
position (from `solar_zenith_angle`), not on how dark a pixel looks — which is
|
|
7
|
+
why an eclipse shadow is rendered as day, not night.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from pyramids_eo.composites._common import _as_array, _wrap_like
|
|
17
|
+
|
|
18
|
+
_MODES = ("day_night", "day_only", "night_only")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def day_weight(sza: Any, lim_low: float = 78.0, lim_high: float = 88.0) -> np.ndarray:
|
|
22
|
+
"""Per-pixel day weight in `[0, 1]` from the solar zenith angle.
|
|
23
|
+
|
|
24
|
+
The weight is 1 where the Sun is high (`sza <= lim_low`, full day), 0 where
|
|
25
|
+
it is low (`sza >= lim_high`, full night), and a smooth cos-space ramp
|
|
26
|
+
through the twilight band between them — the DayNightCompositor curve.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
sza: Solar zenith angle in degrees (scalar or array), e.g. from
|
|
30
|
+
`solar_zenith_angle`.
|
|
31
|
+
lim_low: SZA (degrees) at/below which it is fully day. Default 78.
|
|
32
|
+
lim_high: SZA (degrees) at/above which it is fully night. Default 88.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The day weight, same shape as `sza`, clipped to `[0, 1]`.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ValueError: When `lim_low >= lim_high`.
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
- Overhead Sun is full day, horizon is full night, midway is ~0.5:
|
|
42
|
+
```python
|
|
43
|
+
>>> import numpy as np
|
|
44
|
+
>>> from pyramids_eo.composites import day_weight
|
|
45
|
+
>>> day_weight(np.array([0.0, 83.0, 90.0])).round(2).tolist()
|
|
46
|
+
[1.0, 0.5, 0.0]
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
"""
|
|
50
|
+
if lim_low >= lim_high:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"lim_low ({lim_low}) must be < lim_high ({lim_high}) in SZA degrees"
|
|
53
|
+
)
|
|
54
|
+
low = np.cos(np.deg2rad(lim_low))
|
|
55
|
+
high = np.cos(np.deg2rad(lim_high))
|
|
56
|
+
coszen = np.cos(np.deg2rad(np.asarray(sza, dtype=float)))
|
|
57
|
+
weight = (coszen - min(low, high)) / abs(low - high)
|
|
58
|
+
return np.asarray(np.clip(weight, 0.0, 1.0), dtype=float)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def day_night_blend(
|
|
62
|
+
day: Any,
|
|
63
|
+
night: Any,
|
|
64
|
+
sza: Any,
|
|
65
|
+
*,
|
|
66
|
+
lim_low: float = 78.0,
|
|
67
|
+
lim_high: float = 88.0,
|
|
68
|
+
mode: str = "day_night",
|
|
69
|
+
) -> Any:
|
|
70
|
+
"""Cross-fade a day and a night image by solar zenith angle.
|
|
71
|
+
|
|
72
|
+
Computes a per-pixel day weight from `sza` (see `day_weight`) and mixes:
|
|
73
|
+
`day * weight + night * (1 - weight)`.
|
|
74
|
+
`day` / `night` may be `(H, W)` or `(band, H, W)` arrays, or pyramids
|
|
75
|
+
`Dataset` objects; the weight broadcasts across bands.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
day: The day image — array-like or a pyramids `Dataset`.
|
|
79
|
+
night: The night image — same shape/type family as `day`. Ignored when
|
|
80
|
+
`mode="day_only"`.
|
|
81
|
+
sza: Per-pixel solar zenith angle in degrees (`(H, W)`), from
|
|
82
|
+
`solar_zenith_angle`.
|
|
83
|
+
lim_low: SZA at/below which it is fully day (default 78).
|
|
84
|
+
lim_high: SZA at/above which it is fully night (default 88).
|
|
85
|
+
mode: `"day_night"` (blend, default), `"day_only"` (`day * weight`), or
|
|
86
|
+
`"night_only"` (`night * (1 - weight)`).
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The blended image. A pyramids `Dataset` (carrying `day`'s / `night`'s
|
|
90
|
+
geotransform + CRS) when either input is a `Dataset`, otherwise an
|
|
91
|
+
ndarray.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ValueError: When `mode` is unknown or `lim_low >= lim_high`.
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
- A day (1s) / night (0s) pair collapses to the day weight per pixel:
|
|
98
|
+
```python
|
|
99
|
+
>>> import numpy as np
|
|
100
|
+
>>> from pyramids_eo.composites import day_night_blend
|
|
101
|
+
>>> day = np.ones((2, 2))
|
|
102
|
+
>>> night = np.zeros((2, 2))
|
|
103
|
+
>>> sza = np.array([[0.0, 83.0], [88.0, 180.0]])
|
|
104
|
+
>>> day_night_blend(day, night, sza).round(2).tolist()
|
|
105
|
+
[[1.0, 0.5], [0.0, 0.0]]
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
"""
|
|
109
|
+
if mode not in _MODES:
|
|
110
|
+
raise ValueError(f"mode must be one of {_MODES}; got {mode!r}")
|
|
111
|
+
|
|
112
|
+
weight = day_weight(_as_array(sza), lim_low=lim_low, lim_high=lim_high)
|
|
113
|
+
day_arr = _as_array(day)
|
|
114
|
+
if weight.ndim == 2 and day_arr.ndim == 3:
|
|
115
|
+
weight = weight[np.newaxis, ...]
|
|
116
|
+
|
|
117
|
+
# np.where zeros each image's contribution where its weight is 0, so a NaN
|
|
118
|
+
# in a fully-weighted-out region (e.g. sun-angle-normalised day reflectance,
|
|
119
|
+
# which is NaN across the night side) does not leak through NaN * 0 = NaN.
|
|
120
|
+
if mode == "day_only":
|
|
121
|
+
out = np.where(weight > 0, day_arr * weight, 0.0)
|
|
122
|
+
elif mode == "night_only":
|
|
123
|
+
night_arr = _as_array(night)
|
|
124
|
+
out = np.where(weight < 1, night_arr * (1.0 - weight), 0.0)
|
|
125
|
+
else:
|
|
126
|
+
night_arr = _as_array(night)
|
|
127
|
+
day_term = np.where(weight > 0, day_arr * weight, 0.0)
|
|
128
|
+
night_term = np.where(weight < 1, night_arr * (1.0 - weight), 0.0)
|
|
129
|
+
out = day_term + night_term
|
|
130
|
+
# A NaN weight means the SZA (day/night geometry) is undefined, so keep the
|
|
131
|
+
# pixel masked as NaN rather than collapsing it to 0 — matching day_weight's
|
|
132
|
+
# NaN propagation (the weight-zeroing above only handles image-side NaN).
|
|
133
|
+
out = np.where(np.isnan(weight), np.nan, out)
|
|
134
|
+
return _wrap_like(out, day, night)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Solar geometry for day/night compositing.
|
|
2
|
+
|
|
3
|
+
`solar_zenith_angle` gives the per-pixel solar zenith angle (SZA) from a UTC
|
|
4
|
+
time and a lon/lat grid, computed directly with the NOAA solar-position
|
|
5
|
+
algorithm over NumPy. The SZA drives the day/night cross-fade
|
|
6
|
+
(`day_night_blend`),
|
|
7
|
+
which keys off the Sun's *geometric* position rather than how dark a pixel looks
|
|
8
|
+
(the property that renders an eclipse shadow as day, not night).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _to_utc(time: _dt.datetime) -> _dt.datetime:
|
|
20
|
+
"""Return `time` as a timezone-aware UTC datetime.
|
|
21
|
+
|
|
22
|
+
A naive datetime is assumed to already be UTC; an aware one is converted.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
time: The observation time.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The same instant expressed in UTC.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
TypeError: When `time` is not a `datetime.datetime`.
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(time, _dt.datetime):
|
|
34
|
+
raise TypeError("time must be a datetime.datetime")
|
|
35
|
+
if time.tzinfo is None:
|
|
36
|
+
return time.replace(tzinfo=_dt.UTC)
|
|
37
|
+
return time.astimezone(_dt.UTC)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def solar_zenith_angle(
|
|
41
|
+
time: _dt.datetime,
|
|
42
|
+
*,
|
|
43
|
+
lat: Any = None,
|
|
44
|
+
lon: Any = None,
|
|
45
|
+
grid: Any = None,
|
|
46
|
+
) -> np.ndarray:
|
|
47
|
+
"""Per-pixel solar zenith angle (degrees) for a UTC time and a lon/lat grid.
|
|
48
|
+
|
|
49
|
+
Computed directly with the NOAA solar-position algorithm. Provide the
|
|
50
|
+
geographic coordinates either as `lat`
|
|
51
|
+
and `lon` arrays/scalars, or as a `grid` (a pyramids `Dataset` in EPSG:4326,
|
|
52
|
+
whose `lat` / `lon` cell-centre axes are meshed to 2-D).
|
|
53
|
+
|
|
54
|
+
The returned angle is the zenith angle in **degrees** (0 = Sun overhead,
|
|
55
|
+
90 = on the horizon / terminator, 180 = antisolar). `day_night_blend` takes
|
|
56
|
+
this and applies `cos(deg2rad(...))` internally.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
time: Observation time. A naive datetime is treated as UTC; an aware one
|
|
60
|
+
is converted to UTC.
|
|
61
|
+
lat: Latitude(s) in degrees north — scalar or array. Mutually exclusive
|
|
62
|
+
with `grid`; must be paired with `lon`.
|
|
63
|
+
lon: Longitude(s) in degrees east — scalar or array, broadcast against
|
|
64
|
+
`lat`. Mutually exclusive with `grid`; must be paired with `lat`.
|
|
65
|
+
grid: A pyramids `Dataset` (EPSG:4326) supplying `lat` / `lon` cell-centre
|
|
66
|
+
axes. Mutually exclusive with `lat` / `lon`.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The solar zenith angle in degrees. Shape follows the broadcast of `lat`
|
|
70
|
+
and `lon`, or `(grid.rows, grid.columns)` for a `grid`.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ValueError: When neither `grid` nor both `lat` and `lon` are given, when
|
|
74
|
+
both are given, or when `grid` is not geographic (EPSG:4326).
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
- The Sun is nearly overhead at (0degN, 0degE) at equinox noon:
|
|
78
|
+
```python
|
|
79
|
+
>>> import datetime as dt
|
|
80
|
+
>>> from pyramids_eo.composites import solar_zenith_angle
|
|
81
|
+
>>> t = dt.datetime(2024, 3, 20, 12, 0, tzinfo=dt.timezone.utc)
|
|
82
|
+
>>> bool(solar_zenith_angle(t, lat=0.0, lon=0.0) < 5)
|
|
83
|
+
True
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
- The antisolar point is in deep night (SZA near 180deg):
|
|
87
|
+
```python
|
|
88
|
+
>>> bool(solar_zenith_angle(t, lat=0.0, lon=180.0) > 175)
|
|
89
|
+
True
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
"""
|
|
93
|
+
if grid is not None:
|
|
94
|
+
if lat is not None or lon is not None:
|
|
95
|
+
raise ValueError("pass either `grid` or (`lat`, `lon`), not both")
|
|
96
|
+
epsg = getattr(grid, "epsg", None)
|
|
97
|
+
if epsg is None or int(epsg) != 4326:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"grid must be geographic (EPSG:4326); got EPSG:{epsg}. A grid "
|
|
100
|
+
"with no EPSG (e.g. geostationary) is not lon/lat — reproject it "
|
|
101
|
+
"with to_crs(4326) first."
|
|
102
|
+
)
|
|
103
|
+
lon2d, lat2d = np.meshgrid(
|
|
104
|
+
np.asarray(grid.lon, dtype=float), np.asarray(grid.lat, dtype=float)
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
if lat is None or lon is None:
|
|
108
|
+
raise ValueError("provide `grid`, or both `lat` and `lon`")
|
|
109
|
+
lat2d, lon2d = np.broadcast_arrays(
|
|
110
|
+
np.asarray(lat, dtype=float), np.asarray(lon, dtype=float)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
utc = _to_utc(time)
|
|
114
|
+
day_of_year = utc.timetuple().tm_yday
|
|
115
|
+
hour = utc.hour + utc.minute / 60 + utc.second / 3600 + utc.microsecond / 3.6e9
|
|
116
|
+
|
|
117
|
+
# NOAA fractional-year angle (radians) and its harmonics.
|
|
118
|
+
gamma = 2.0 * np.pi / 365.0 * (day_of_year - 1 + (hour - 12) / 24)
|
|
119
|
+
# Equation of time (minutes) and solar declination (radians).
|
|
120
|
+
eqtime = 229.18 * (
|
|
121
|
+
0.000075
|
|
122
|
+
+ 0.001868 * np.cos(gamma)
|
|
123
|
+
- 0.032077 * np.sin(gamma)
|
|
124
|
+
- 0.014615 * np.cos(2 * gamma)
|
|
125
|
+
- 0.040849 * np.sin(2 * gamma)
|
|
126
|
+
)
|
|
127
|
+
decl = (
|
|
128
|
+
0.006918
|
|
129
|
+
- 0.399912 * np.cos(gamma)
|
|
130
|
+
+ 0.070257 * np.sin(gamma)
|
|
131
|
+
- 0.006758 * np.cos(2 * gamma)
|
|
132
|
+
+ 0.000907 * np.sin(2 * gamma)
|
|
133
|
+
- 0.002697 * np.cos(3 * gamma)
|
|
134
|
+
+ 0.00148 * np.sin(3 * gamma)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# True solar time (minutes) per pixel: UTC clock time + equation of time +
|
|
138
|
+
# 4 min per degree of east longitude (timezone offset is 0 for UTC).
|
|
139
|
+
true_solar_time = hour * 60 + eqtime + 4.0 * lon2d
|
|
140
|
+
hour_angle = np.deg2rad(true_solar_time / 4.0 - 180.0)
|
|
141
|
+
|
|
142
|
+
lat_rad = np.deg2rad(lat2d)
|
|
143
|
+
cos_zenith = np.sin(lat_rad) * np.sin(decl) + np.cos(lat_rad) * np.cos(
|
|
144
|
+
decl
|
|
145
|
+
) * np.cos(hour_angle)
|
|
146
|
+
return np.asarray(
|
|
147
|
+
np.rad2deg(np.arccos(np.clip(cos_zenith, -1.0, 1.0))), dtype=float
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cos_solar_zenith_angle(
|
|
152
|
+
time: _dt.datetime,
|
|
153
|
+
*,
|
|
154
|
+
lat: Any = None,
|
|
155
|
+
lon: Any = None,
|
|
156
|
+
grid: Any = None,
|
|
157
|
+
) -> np.ndarray:
|
|
158
|
+
"""Per-pixel cosine of the solar zenith angle.
|
|
159
|
+
|
|
160
|
+
A thin wrapper over `solar_zenith_angle` returning `cos(SZA)` directly — the
|
|
161
|
+
form the readers' and `radiance_to_reflectance`'s `cos_sza` arguments expect
|
|
162
|
+
(note `solar_zenith_angle` itself returns the angle in *degrees*). Same
|
|
163
|
+
arguments as `solar_zenith_angle`.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
time: Observation time (a naive datetime is treated as UTC).
|
|
167
|
+
lat: Latitude(s) in degrees north, paired with `lon`.
|
|
168
|
+
lon: Longitude(s) in degrees east, paired with `lat`.
|
|
169
|
+
grid: A pyramids `Dataset` grid (EPSG:4326), mutually exclusive with
|
|
170
|
+
`lat` / `lon`.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The cosine of the solar zenith angle, same shape as the coordinates.
|
|
174
|
+
"""
|
|
175
|
+
return np.cos(np.deg2rad(solar_zenith_angle(time, lat=lat, lon=lon, grid=grid)))
|