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.
@@ -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)))