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 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
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m lstcalc``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
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))