lstcalc 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.
- lstcalc/__init__.py +117 -0
- lstcalc/__main__.py +7 -0
- lstcalc/_version.py +1 -0
- lstcalc/cli.py +212 -0
- lstcalc/emissivity.py +138 -0
- lstcalc/errors.py +17 -0
- lstcalc/io.py +90 -0
- lstcalc/lst.py +158 -0
- lstcalc/mtl.py +228 -0
- lstcalc/pipeline.py +159 -0
- lstcalc/py.typed +0 -0
- lstcalc/radiometry.py +199 -0
- lstcalc/uhi.py +212 -0
- lstcalc-0.1.0.dist-info/METADATA +229 -0
- lstcalc-0.1.0.dist-info/RECORD +19 -0
- lstcalc-0.1.0.dist-info/WHEEL +5 -0
- lstcalc-0.1.0.dist-info/entry_points.txt +2 -0
- lstcalc-0.1.0.dist-info/licenses/LICENSE +21 -0
- lstcalc-0.1.0.dist-info/top_level.txt +1 -0
lstcalc/__init__.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""lstcalc: land surface temperature and urban heat island metrics from Landsat 8/9.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from lstcalc import parse_mtl, lst_from_level1, suhi_intensity
|
|
7
|
+
|
|
8
|
+
mtl = parse_mtl(open("LC08_..._MTL.txt").read())
|
|
9
|
+
result = lst_from_level1(b10, mtl, red_dn=b4, nir_dn=b5, water_vapour=1.2)
|
|
10
|
+
print(np.nanmean(result.lst_celsius))
|
|
11
|
+
print(suhi_intensity(result.lst, urban_mask, rural_mask).intensity)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
from .emissivity import (
|
|
16
|
+
EmissivityParams,
|
|
17
|
+
emissivity_ndvi_threshold,
|
|
18
|
+
fractional_vegetation_cover,
|
|
19
|
+
ndvi,
|
|
20
|
+
)
|
|
21
|
+
from .errors import LstcalcError, MetadataError, OptionalDependencyError
|
|
22
|
+
from .lst import (
|
|
23
|
+
B_GAMMA_TIRS1,
|
|
24
|
+
B_GAMMA_TIRS2,
|
|
25
|
+
PLANCK_C2_UM_K,
|
|
26
|
+
SC_COEFFS_TIRS1,
|
|
27
|
+
WAVELENGTH_TIRS1_UM,
|
|
28
|
+
WAVELENGTH_TIRS2_UM,
|
|
29
|
+
atmospheric_functions,
|
|
30
|
+
atmospheric_functions_from_parameters,
|
|
31
|
+
planck_lst,
|
|
32
|
+
single_channel_lst,
|
|
33
|
+
)
|
|
34
|
+
from .mtl import MTL, parse_mtl, read_mtl
|
|
35
|
+
from .pipeline import METHODS, LSTResult, lst_from_level1, lst_from_level2
|
|
36
|
+
from .radiometry import (
|
|
37
|
+
L2_SR_ADD,
|
|
38
|
+
L2_SR_MULT,
|
|
39
|
+
L2_ST_ADD,
|
|
40
|
+
L2_ST_MULT,
|
|
41
|
+
S2_BOA_ADD_OFFSET,
|
|
42
|
+
S2_QUANTIFICATION_VALUE,
|
|
43
|
+
THERMAL_CONSTANTS,
|
|
44
|
+
block_mean,
|
|
45
|
+
brightness_temperature,
|
|
46
|
+
celsius_to_kelvin,
|
|
47
|
+
dn_to_radiance,
|
|
48
|
+
dn_to_toa_reflectance,
|
|
49
|
+
kelvin_to_celsius,
|
|
50
|
+
l2_surface_reflectance,
|
|
51
|
+
l2_surface_temperature,
|
|
52
|
+
sentinel2_reflectance,
|
|
53
|
+
)
|
|
54
|
+
from .uhi import (
|
|
55
|
+
UTFVI_LABELS,
|
|
56
|
+
UTFVI_THRESHOLDS,
|
|
57
|
+
SUHIResult,
|
|
58
|
+
ZoneStats,
|
|
59
|
+
classify_utfvi,
|
|
60
|
+
hotspots,
|
|
61
|
+
suhi_intensity,
|
|
62
|
+
utfvi,
|
|
63
|
+
zonal_statistics,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"B_GAMMA_TIRS1",
|
|
68
|
+
"B_GAMMA_TIRS2",
|
|
69
|
+
"L2_SR_ADD",
|
|
70
|
+
"L2_SR_MULT",
|
|
71
|
+
"L2_ST_ADD",
|
|
72
|
+
"L2_ST_MULT",
|
|
73
|
+
"METHODS",
|
|
74
|
+
"MTL",
|
|
75
|
+
"PLANCK_C2_UM_K",
|
|
76
|
+
"S2_BOA_ADD_OFFSET",
|
|
77
|
+
"S2_QUANTIFICATION_VALUE",
|
|
78
|
+
"SC_COEFFS_TIRS1",
|
|
79
|
+
"THERMAL_CONSTANTS",
|
|
80
|
+
"UTFVI_LABELS",
|
|
81
|
+
"UTFVI_THRESHOLDS",
|
|
82
|
+
"WAVELENGTH_TIRS1_UM",
|
|
83
|
+
"WAVELENGTH_TIRS2_UM",
|
|
84
|
+
"EmissivityParams",
|
|
85
|
+
"LSTResult",
|
|
86
|
+
"LstcalcError",
|
|
87
|
+
"MetadataError",
|
|
88
|
+
"OptionalDependencyError",
|
|
89
|
+
"SUHIResult",
|
|
90
|
+
"ZoneStats",
|
|
91
|
+
"__version__",
|
|
92
|
+
"atmospheric_functions",
|
|
93
|
+
"atmospheric_functions_from_parameters",
|
|
94
|
+
"block_mean",
|
|
95
|
+
"brightness_temperature",
|
|
96
|
+
"celsius_to_kelvin",
|
|
97
|
+
"classify_utfvi",
|
|
98
|
+
"dn_to_radiance",
|
|
99
|
+
"dn_to_toa_reflectance",
|
|
100
|
+
"emissivity_ndvi_threshold",
|
|
101
|
+
"fractional_vegetation_cover",
|
|
102
|
+
"hotspots",
|
|
103
|
+
"kelvin_to_celsius",
|
|
104
|
+
"l2_surface_reflectance",
|
|
105
|
+
"l2_surface_temperature",
|
|
106
|
+
"lst_from_level1",
|
|
107
|
+
"lst_from_level2",
|
|
108
|
+
"ndvi",
|
|
109
|
+
"parse_mtl",
|
|
110
|
+
"planck_lst",
|
|
111
|
+
"read_mtl",
|
|
112
|
+
"sentinel2_reflectance",
|
|
113
|
+
"single_channel_lst",
|
|
114
|
+
"suhi_intensity",
|
|
115
|
+
"utfvi",
|
|
116
|
+
"zonal_statistics",
|
|
117
|
+
]
|
lstcalc/__main__.py
ADDED
lstcalc/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
lstcalc/cli.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Command-line interface: ``lstcalc mtl|scene|suhi|utfvi|zonal``.
|
|
2
|
+
|
|
3
|
+
``mtl`` works with the core install; the raster commands need ``lstcalc[raster]``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import csv
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from . import io as rio
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .errors import LstcalcError
|
|
20
|
+
from .mtl import read_mtl
|
|
21
|
+
from .pipeline import lst_from_level1, lst_from_level2
|
|
22
|
+
from .uhi import classify_utfvi, hotspots, suhi_intensity, utfvi, zonal_statistics
|
|
23
|
+
|
|
24
|
+
__all__ = ["build_parser", "main"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _stats(values: np.ndarray) -> Dict[str, Any]:
|
|
28
|
+
finite = values[np.isfinite(values)]
|
|
29
|
+
if finite.size == 0:
|
|
30
|
+
return {"count": 0}
|
|
31
|
+
return {
|
|
32
|
+
"count": int(finite.size),
|
|
33
|
+
"mean": round(float(finite.mean()), 4),
|
|
34
|
+
"std": round(float(finite.std()), 4),
|
|
35
|
+
"min": round(float(finite.min()), 4),
|
|
36
|
+
"max": round(float(finite.max()), 4),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cmd_mtl(args: argparse.Namespace) -> int:
|
|
41
|
+
print(json.dumps(read_mtl(args.path).summary(), indent=2))
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_method(args: argparse.Namespace, folder: Path, mtl: Any) -> str:
|
|
46
|
+
if args.method != "auto":
|
|
47
|
+
return str(args.method)
|
|
48
|
+
try:
|
|
49
|
+
rio.find_band_file(folder, "ST_B10", mtl)
|
|
50
|
+
return "level2"
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
pass
|
|
53
|
+
return "single_channel" if args.water_vapour is not None else "planck"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cmd_scene(args: argparse.Namespace) -> int:
|
|
57
|
+
folder = Path(args.folder)
|
|
58
|
+
mtl = rio.load_scene_mtl(folder)
|
|
59
|
+
method = _resolve_method(args, folder, mtl)
|
|
60
|
+
extra: Dict[str, Any] = {}
|
|
61
|
+
if method == "level2":
|
|
62
|
+
st, profile = rio.read_band(rio.find_band_file(folder, "ST_B10", mtl))
|
|
63
|
+
lst = lst_from_level2(st, mtl)
|
|
64
|
+
else:
|
|
65
|
+
thermal, profile = rio.read_band(rio.find_band_file(folder, f"B{args.band}", mtl))
|
|
66
|
+
red, _ = rio.read_band(rio.find_band_file(folder, "B4", mtl))
|
|
67
|
+
ndvi = None
|
|
68
|
+
nir = None
|
|
69
|
+
if args.ndvi:
|
|
70
|
+
ndvi, _ = rio.read_band(args.ndvi)
|
|
71
|
+
else:
|
|
72
|
+
nir, _ = rio.read_band(rio.find_band_file(folder, "B5", mtl))
|
|
73
|
+
result = lst_from_level1(
|
|
74
|
+
thermal,
|
|
75
|
+
mtl,
|
|
76
|
+
red_dn=red,
|
|
77
|
+
nir_dn=nir,
|
|
78
|
+
ndvi=ndvi,
|
|
79
|
+
method=method,
|
|
80
|
+
water_vapour=args.water_vapour,
|
|
81
|
+
thermal_band=args.band,
|
|
82
|
+
)
|
|
83
|
+
lst = result.lst
|
|
84
|
+
if args.emissivity_out:
|
|
85
|
+
rio.write_band(args.emissivity_out, result.emissivity, profile)
|
|
86
|
+
extra["emissivity_out"] = str(args.emissivity_out)
|
|
87
|
+
unit = "K"
|
|
88
|
+
if args.celsius:
|
|
89
|
+
lst = lst - 273.15
|
|
90
|
+
unit = "degC"
|
|
91
|
+
rio.write_band(args.out, lst, profile)
|
|
92
|
+
report = {"method": method, "unit": unit, "out": str(args.out), **_stats(lst), **extra}
|
|
93
|
+
print(json.dumps(report, indent=2))
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _cmd_suhi(args: argparse.Namespace) -> int:
|
|
98
|
+
lst, _ = rio.read_band(args.lst)
|
|
99
|
+
urban, _ = rio.read_band(args.urban)
|
|
100
|
+
rural, _ = rio.read_band(args.rural)
|
|
101
|
+
lst = np.asarray(lst, dtype=np.float64)
|
|
102
|
+
if args.nodata is not None:
|
|
103
|
+
lst[lst == args.nodata] = np.nan
|
|
104
|
+
print(json.dumps(suhi_intensity(lst, urban > 0, rural > 0).to_dict(), indent=2))
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _cmd_utfvi(args: argparse.Namespace) -> int:
|
|
109
|
+
lst, profile = rio.read_band(args.lst)
|
|
110
|
+
lst = np.asarray(lst, dtype=np.float64)
|
|
111
|
+
if args.nodata is not None:
|
|
112
|
+
lst[lst == args.nodata] = np.nan
|
|
113
|
+
if args.celsius_input:
|
|
114
|
+
lst = lst + 273.15
|
|
115
|
+
index = utfvi(lst)
|
|
116
|
+
classes = classify_utfvi(index).astype(np.float64)
|
|
117
|
+
classes[classes < 0] = np.nan
|
|
118
|
+
rio.write_band(args.out, classes, profile)
|
|
119
|
+
report: Dict[str, Any] = {"out": str(args.out), "utfvi": _stats(index)}
|
|
120
|
+
counts = {int(c): int((classes == c).sum()) for c in range(6)}
|
|
121
|
+
report["class_counts"] = counts
|
|
122
|
+
if args.hotspots_out:
|
|
123
|
+
hot = hotspots(lst, k=args.k).astype(np.float64)
|
|
124
|
+
rio.write_band(args.hotspots_out, hot, profile)
|
|
125
|
+
report["hotspot_pixels"] = int(hot.sum())
|
|
126
|
+
print(json.dumps(report, indent=2))
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _cmd_zonal(args: argparse.Namespace) -> int:
|
|
131
|
+
values, _ = rio.read_band(args.values)
|
|
132
|
+
labels, _ = rio.read_band(args.labels)
|
|
133
|
+
values = np.asarray(values, dtype=np.float64)
|
|
134
|
+
if args.nodata is not None:
|
|
135
|
+
values[values == args.nodata] = np.nan
|
|
136
|
+
stats = zonal_statistics(values, labels)
|
|
137
|
+
rows: List[Dict[str, Any]] = [s.to_dict() for s in stats.values()]
|
|
138
|
+
if args.csv:
|
|
139
|
+
with open(args.csv, "w", newline="", encoding="utf-8") as fh:
|
|
140
|
+
writer = csv.DictWriter(fh, fieldnames=["label", "count", "mean", "std", "min", "max"])
|
|
141
|
+
writer.writeheader()
|
|
142
|
+
writer.writerows(rows)
|
|
143
|
+
print(json.dumps(rows, indent=2))
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
148
|
+
"""Build the argument parser."""
|
|
149
|
+
parser = argparse.ArgumentParser(
|
|
150
|
+
prog="lstcalc",
|
|
151
|
+
description="Land surface temperature and urban heat island metrics from Landsat 8/9.",
|
|
152
|
+
)
|
|
153
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
154
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
155
|
+
|
|
156
|
+
p = sub.add_parser("mtl", help="print LST-relevant coefficients from an MTL file as JSON")
|
|
157
|
+
p.add_argument("path", type=Path)
|
|
158
|
+
p.set_defaults(func=_cmd_mtl)
|
|
159
|
+
|
|
160
|
+
p = sub.add_parser("scene", help="compute LST for a Landsat scene folder (needs rasterio)")
|
|
161
|
+
p.add_argument("folder", type=Path)
|
|
162
|
+
p.add_argument("--out", type=Path, required=True, help="output GeoTIFF")
|
|
163
|
+
p.add_argument(
|
|
164
|
+
"--method",
|
|
165
|
+
choices=("auto", "single_channel", "planck", "level2"),
|
|
166
|
+
default="auto",
|
|
167
|
+
help="auto: level2 if ST_B10 exists, else single_channel with --water-vapour, else planck",
|
|
168
|
+
)
|
|
169
|
+
p.add_argument("--water-vapour", type=float, default=None, help="g cm-2")
|
|
170
|
+
p.add_argument("--band", type=int, choices=(10, 11), default=10)
|
|
171
|
+
p.add_argument("--ndvi", type=Path, default=None, help="external NDVI raster, same grid")
|
|
172
|
+
p.add_argument("--emissivity-out", type=Path, default=None)
|
|
173
|
+
p.add_argument("--celsius", action="store_true", help="write degrees Celsius")
|
|
174
|
+
p.set_defaults(func=_cmd_scene)
|
|
175
|
+
|
|
176
|
+
p = sub.add_parser("suhi", help="SUHI intensity from LST and urban/rural mask rasters")
|
|
177
|
+
p.add_argument("lst", type=Path)
|
|
178
|
+
p.add_argument("--urban", type=Path, required=True)
|
|
179
|
+
p.add_argument("--rural", type=Path, required=True)
|
|
180
|
+
p.add_argument("--nodata", type=float, default=None)
|
|
181
|
+
p.set_defaults(func=_cmd_suhi)
|
|
182
|
+
|
|
183
|
+
p = sub.add_parser("utfvi", help="UTFVI classes and optional hot-spot map")
|
|
184
|
+
p.add_argument("lst", type=Path)
|
|
185
|
+
p.add_argument("--out", type=Path, required=True)
|
|
186
|
+
p.add_argument("--hotspots-out", type=Path, default=None)
|
|
187
|
+
p.add_argument("--k", type=float, default=2.0, help="hot spot: mean + k * std")
|
|
188
|
+
p.add_argument("--celsius-input", action="store_true", help="input LST is in degC")
|
|
189
|
+
p.add_argument("--nodata", type=float, default=None)
|
|
190
|
+
p.set_defaults(func=_cmd_utfvi)
|
|
191
|
+
|
|
192
|
+
p = sub.add_parser("zonal", help="zonal statistics of a raster by a label raster")
|
|
193
|
+
p.add_argument("values", type=Path)
|
|
194
|
+
p.add_argument("--labels", type=Path, required=True)
|
|
195
|
+
p.add_argument("--csv", type=Path, default=None)
|
|
196
|
+
p.add_argument("--nodata", type=float, default=None)
|
|
197
|
+
p.set_defaults(func=_cmd_zonal)
|
|
198
|
+
return parser
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
202
|
+
"""Entry point for the ``lstcalc`` console script."""
|
|
203
|
+
args = build_parser().parse_args(argv)
|
|
204
|
+
try:
|
|
205
|
+
return int(args.func(args))
|
|
206
|
+
except (LstcalcError, FileNotFoundError, ValueError) as exc:
|
|
207
|
+
print(f"lstcalc: error: {exc}", file=sys.stderr)
|
|
208
|
+
return 2
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__": # pragma: no cover
|
|
212
|
+
sys.exit(main())
|
lstcalc/emissivity.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""NDVI, fractional vegetation cover and land surface emissivity (NDVI threshold method).
|
|
2
|
+
|
|
3
|
+
Primary source (checked against the paper's text):
|
|
4
|
+
Sobrino, J. A., Jimenez-Munoz, J. C., & Paolini, L. (2004). Land surface temperature
|
|
5
|
+
retrieval from LANDSAT TM 5. *Remote Sensing of Environment*, 90(4), 434-440.
|
|
6
|
+
https://doi.org/10.1016/j.rse.2004.02.003
|
|
7
|
+
|
|
8
|
+
* NDVI < NDVI_s (0.2): bare soil, emissivity from red reflectance (the paper gives no
|
|
9
|
+
coefficients for this branch; see :class:`EmissivityParams`).
|
|
10
|
+
* NDVI > NDVI_v (0.5): fully vegetated, emissivity 0.99.
|
|
11
|
+
* otherwise: ``e = e_v * Pv + e_s * (1 - Pv) + de`` with
|
|
12
|
+
``de = (1 - e_s) * (1 - Pv) * F * e_v`` and ``F = 0.55`` (eqs. 8 and 10), and
|
|
13
|
+
``Pv = ((NDVI - NDVI_min) / (NDVI_max - NDVI_min)) ** 2`` (Carlson & Ripley 1997, eq. 9).
|
|
14
|
+
* With ``e_s = 0.97``, ``e_v = 0.99`` this reduces to ``e = 0.004 * Pv + 0.986`` (eq. 13).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Optional, Tuple
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from numpy.typing import ArrayLike, NDArray
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"EmissivityParams",
|
|
27
|
+
"emissivity_ndvi_threshold",
|
|
28
|
+
"fractional_vegetation_cover",
|
|
29
|
+
"ndvi",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
FloatArray = NDArray[np.float64]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ndvi(nir: ArrayLike, red: ArrayLike) -> FloatArray:
|
|
36
|
+
"""Normalised difference vegetation index ``(NIR - Red) / (NIR + Red)``.
|
|
37
|
+
|
|
38
|
+
Works with Landsat 8/9 (B5, B4) or Sentinel-2 (B8, B4) reflectances. Pixels where
|
|
39
|
+
``NIR + Red == 0`` or where an input is NaN return NaN.
|
|
40
|
+
"""
|
|
41
|
+
n = np.asarray(nir, dtype=np.float64)
|
|
42
|
+
r = np.asarray(red, dtype=np.float64)
|
|
43
|
+
total = n + r
|
|
44
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
45
|
+
out: FloatArray = np.where(total != 0, (n - r) / total, np.nan)
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fractional_vegetation_cover(
|
|
50
|
+
ndvi_values: ArrayLike, ndvi_soil: float = 0.2, ndvi_veg: float = 0.5
|
|
51
|
+
) -> FloatArray:
|
|
52
|
+
"""Proportion of vegetation ``Pv = ((NDVI - NDVI_s) / (NDVI_v - NDVI_s))**2``, clipped to [0, 1].
|
|
53
|
+
|
|
54
|
+
Defaults follow Sobrino et al. (2004): ``NDVI_s = 0.2``, ``NDVI_v = 0.5``. Scene-based
|
|
55
|
+
thresholds (e.g. NDVI percentiles) can be passed instead.
|
|
56
|
+
"""
|
|
57
|
+
if ndvi_veg <= ndvi_soil:
|
|
58
|
+
raise ValueError("ndvi_veg must be greater than ndvi_soil")
|
|
59
|
+
v = np.asarray(ndvi_values, dtype=np.float64)
|
|
60
|
+
scaled = np.clip((v - ndvi_soil) / (ndvi_veg - ndvi_soil), 0.0, 1.0)
|
|
61
|
+
out: FloatArray = scaled**2
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class EmissivityParams:
|
|
67
|
+
"""Parameters of the NDVI threshold method. All values are overridable.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
ndvi_soil: NDVI below which a pixel is bare soil (Sobrino et al. 2004: 0.2).
|
|
71
|
+
ndvi_veg: NDVI above which a pixel is fully vegetated (Sobrino et al. 2004: 0.5).
|
|
72
|
+
emissivity_soil: Soil emissivity ``e_s`` (Sobrino et al. 2004, TM6: 0.97).
|
|
73
|
+
emissivity_veg: Vegetation emissivity ``e_v`` (Sobrino et al. 2004, TM6: 0.99).
|
|
74
|
+
shape_factor: Geometrical factor ``F`` of the cavity term (Sobrino et al. 2004: 0.55).
|
|
75
|
+
soil_red_coeffs: Optional ``(a, b)`` so that bare-soil emissivity is
|
|
76
|
+
``a + b * rho_red``. When ``None`` (default) bare soil gets ``emissivity_soil``.
|
|
77
|
+
A commonly quoted TM6 pair is ``(0.979, -0.035)`` (Sobrino et al. 2008, IEEE
|
|
78
|
+
TGRS 46(2)); it is NOT verified in this release and not used by default.
|
|
79
|
+
water_emissivity: If given, pixels with ``NDVI < water_ndvi`` get this emissivity
|
|
80
|
+
(0.991 is often used for water; NOT verified, so not applied by default).
|
|
81
|
+
water_ndvi: NDVI threshold for water when ``water_emissivity`` is set.
|
|
82
|
+
|
|
83
|
+
The defaults reproduce the TM6 calibration of the paper. For Landsat 8/9 TIRS band 10
|
|
84
|
+
users often keep these values; supply band-specific emissivities if you have them.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
ndvi_soil: float = 0.2
|
|
88
|
+
ndvi_veg: float = 0.5
|
|
89
|
+
emissivity_soil: float = 0.97
|
|
90
|
+
emissivity_veg: float = 0.99
|
|
91
|
+
shape_factor: float = 0.55
|
|
92
|
+
soil_red_coeffs: Optional[Tuple[float, float]] = None
|
|
93
|
+
water_emissivity: Optional[float] = None
|
|
94
|
+
water_ndvi: float = 0.0
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def mixed_coeffs(self) -> Tuple[float, float]:
|
|
98
|
+
"""``(m, n)`` of ``e = m * Pv + n`` for mixed pixels (Sobrino et al. 2004, eq. 12)."""
|
|
99
|
+
es, ev, f = self.emissivity_soil, self.emissivity_veg, self.shape_factor
|
|
100
|
+
cavity = (1.0 - es) * f * ev
|
|
101
|
+
return ev - es - cavity, es + cavity
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def emissivity_ndvi_threshold(
|
|
105
|
+
ndvi_values: ArrayLike,
|
|
106
|
+
red: Optional[ArrayLike] = None,
|
|
107
|
+
params: Optional[EmissivityParams] = None,
|
|
108
|
+
) -> FloatArray:
|
|
109
|
+
"""Land surface emissivity by the NDVI threshold method of Sobrino et al. (2004).
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
ndvi_values: NDVI array.
|
|
113
|
+
red: Red reflectance, needed only when ``params.soil_red_coeffs`` is set.
|
|
114
|
+
params: Method parameters; defaults to :class:`EmissivityParams()`.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Emissivity array; NaN where NDVI is NaN.
|
|
118
|
+
"""
|
|
119
|
+
p = params or EmissivityParams()
|
|
120
|
+
v = np.asarray(ndvi_values, dtype=np.float64)
|
|
121
|
+
pv = fractional_vegetation_cover(v, p.ndvi_soil, p.ndvi_veg)
|
|
122
|
+
m, n = p.mixed_coeffs
|
|
123
|
+
eps = m * pv + n
|
|
124
|
+
|
|
125
|
+
soil = v < p.ndvi_soil
|
|
126
|
+
if p.soil_red_coeffs is not None:
|
|
127
|
+
if red is None:
|
|
128
|
+
raise ValueError("red reflectance is required when soil_red_coeffs is set")
|
|
129
|
+
a, b = p.soil_red_coeffs
|
|
130
|
+
r = np.broadcast_to(np.asarray(red, dtype=np.float64), v.shape)
|
|
131
|
+
eps = np.where(soil, a + b * r, eps)
|
|
132
|
+
else:
|
|
133
|
+
eps = np.where(soil, p.emissivity_soil, eps)
|
|
134
|
+
eps = np.where(v > p.ndvi_veg, p.emissivity_veg, eps)
|
|
135
|
+
if p.water_emissivity is not None:
|
|
136
|
+
eps = np.where(v < p.water_ndvi, p.water_emissivity, eps)
|
|
137
|
+
out: FloatArray = np.where(np.isnan(v), np.nan, eps)
|
|
138
|
+
return out
|
lstcalc/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exceptions raised by lstcalc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["LstcalcError", "MetadataError", "OptionalDependencyError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LstcalcError(Exception):
|
|
9
|
+
"""Base class for all lstcalc errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MetadataError(LstcalcError, ValueError):
|
|
13
|
+
"""An MTL file is malformed or lacks a required key."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OptionalDependencyError(LstcalcError, ImportError):
|
|
17
|
+
"""An optional dependency (such as rasterio) is required but not installed."""
|
lstcalc/io.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Optional raster I/O through rasterio (``pip install "lstcalc[raster]"``).
|
|
2
|
+
|
|
3
|
+
The core library never imports rasterio; only these helpers and the ``scene`` / ``suhi`` /
|
|
4
|
+
``zonal`` CLI commands need it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy.typing import NDArray
|
|
14
|
+
|
|
15
|
+
from .errors import OptionalDependencyError
|
|
16
|
+
from .mtl import MTL, read_mtl
|
|
17
|
+
|
|
18
|
+
__all__ = ["find_band_file", "find_mtl", "read_band", "write_band"]
|
|
19
|
+
|
|
20
|
+
PathLike = Union[str, Path]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _rasterio() -> Any:
|
|
24
|
+
try:
|
|
25
|
+
import rasterio
|
|
26
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
27
|
+
raise OptionalDependencyError(
|
|
28
|
+
'raster I/O needs rasterio: pip install "lstcalc[raster]"'
|
|
29
|
+
) from exc
|
|
30
|
+
return rasterio
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_band(path: PathLike, band: int = 1) -> Tuple[NDArray[Any], Dict[str, Any]]:
|
|
34
|
+
"""Read one band; returns ``(array, profile)`` where ``profile`` is rasterio's profile."""
|
|
35
|
+
rasterio = _rasterio()
|
|
36
|
+
with rasterio.open(path) as src:
|
|
37
|
+
return src.read(band), dict(src.profile)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_band(
|
|
41
|
+
path: PathLike,
|
|
42
|
+
array: NDArray[Any],
|
|
43
|
+
profile: Dict[str, Any],
|
|
44
|
+
nodata: Optional[float] = float("nan"),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Write a single-band float32 GeoTIFF using a template ``profile``."""
|
|
47
|
+
rasterio = _rasterio()
|
|
48
|
+
prof = dict(profile)
|
|
49
|
+
prof.update(driver="GTiff", count=1, dtype="float32", nodata=nodata)
|
|
50
|
+
data = np.asarray(array, dtype=np.float32)
|
|
51
|
+
with rasterio.open(path, "w", **prof) as dst:
|
|
52
|
+
dst.write(data, 1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_mtl(folder: PathLike) -> Path:
|
|
56
|
+
"""Locate the ``*_MTL.txt`` (preferred) or ``*_MTL.json`` file in a scene folder."""
|
|
57
|
+
root = Path(folder)
|
|
58
|
+
for pattern in ("*_MTL.txt", "*_MTL.json", "*MTL*.txt", "*MTL*.json"):
|
|
59
|
+
hits = sorted(root.glob(pattern))
|
|
60
|
+
if hits:
|
|
61
|
+
return hits[0]
|
|
62
|
+
raise FileNotFoundError(f"no MTL file found in {root}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def find_band_file(folder: PathLike, band: str, mtl: Optional[MTL] = None) -> Path:
|
|
66
|
+
"""Locate a band file such as ``"B10"``, ``"B4"``, ``"ST_B10"`` or ``"SR_B4"``.
|
|
67
|
+
|
|
68
|
+
The MTL ``FILE_NAME_BAND_*`` entry is used first, then a ``*_<band>.TIF`` glob.
|
|
69
|
+
"""
|
|
70
|
+
root = Path(folder)
|
|
71
|
+
if mtl is not None:
|
|
72
|
+
key = band[1:] if band.startswith("B") else band
|
|
73
|
+
name = mtl.file_name(key)
|
|
74
|
+
if name and (root / name).exists():
|
|
75
|
+
return root / name
|
|
76
|
+
candidates: List[Path] = []
|
|
77
|
+
for ext in ("TIF", "tif", "TIFF", "tiff"):
|
|
78
|
+
candidates.extend(root.glob(f"*_{band}.{ext}"))
|
|
79
|
+
if band.upper().startswith("B"):
|
|
80
|
+
# "*_B10.TIF" must not pick up Level-2 "*_ST_B10.TIF" / "*_SR_B4.TIF" files.
|
|
81
|
+
level2 = (f"_ST_{band}".upper(), f"_SR_{band}".upper())
|
|
82
|
+
candidates = [p for p in candidates if not p.stem.upper().endswith(level2)]
|
|
83
|
+
if not candidates:
|
|
84
|
+
raise FileNotFoundError(f"no file for band {band} in {root}")
|
|
85
|
+
return sorted(set(candidates))[0]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_scene_mtl(folder: PathLike) -> MTL:
|
|
89
|
+
"""Find and parse the scene MTL file."""
|
|
90
|
+
return read_mtl(find_mtl(folder))
|