lstnet 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.
- lstnet/__init__.py +43 -0
- lstnet/config.py +47 -0
- lstnet/dayornight.py +23 -0
- lstnet/ground_lst.py +127 -0
- lstnet/gui.py +616 -0
- lstnet/io/__init__.py +6 -0
- lstnet/io/aster_ged.py +341 -0
- lstnet/io/base.py +58 -0
- lstnet/io/emissivity.py +144 -0
- lstnet/io/hiwater.py +191 -0
- lstnet/io/modis.py +462 -0
- lstnet/io/pku.py +268 -0
- lstnet/io/raster.py +36 -0
- lstnet/io/surfrad.py +182 -0
- lstnet/mcp_server.py +131 -0
- lstnet/models.py +68 -0
- lstnet/plotting.py +52 -0
- lstnet/qc.py +38 -0
- lstnet/sites.py +71 -0
- lstnet/stats.py +68 -0
- lstnet/validation.py +105 -0
- lstnet-0.1.0.dist-info/METADATA +166 -0
- lstnet-0.1.0.dist-info/RECORD +26 -0
- lstnet-0.1.0.dist-info/WHEEL +4 -0
- lstnet-0.1.0.dist-info/entry_points.txt +3 -0
- lstnet-0.1.0.dist-info/licenses/LICENSE +21 -0
lstnet/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""lstnet: pure-Python land surface temperature library.
|
|
2
|
+
|
|
3
|
+
Ground-truth LST production (Plan 1a) + MODIS daily emissivity (Plan 1b) +
|
|
4
|
+
validation engine (Plan 1c). Public API for computing ground-truth LST at
|
|
5
|
+
satellite overpass times and validating it against retrieved LST.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from lstnet.dayornight import dayornight
|
|
10
|
+
from lstnet.ground_lst import SIGMA, compute_ground_lst, lst_from_radiance
|
|
11
|
+
from lstnet.io.aster_ged import AsterGEDEmissivity
|
|
12
|
+
from lstnet.io.emissivity import FixedEmissivity, ModisDailyEmissivity
|
|
13
|
+
from lstnet.models import (
|
|
14
|
+
GroundLST,
|
|
15
|
+
RetrievedLST,
|
|
16
|
+
Site,
|
|
17
|
+
ValidationPair,
|
|
18
|
+
ValidationResult,
|
|
19
|
+
ValidationStats,
|
|
20
|
+
)
|
|
21
|
+
from lstnet.sites import SITES, by_network, get_site
|
|
22
|
+
from lstnet.validation import TableRetrievedLST, validate
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SIGMA",
|
|
26
|
+
"compute_ground_lst",
|
|
27
|
+
"lst_from_radiance",
|
|
28
|
+
"FixedEmissivity",
|
|
29
|
+
"ModisDailyEmissivity",
|
|
30
|
+
"AsterGEDEmissivity",
|
|
31
|
+
"dayornight",
|
|
32
|
+
"GroundLST",
|
|
33
|
+
"Site",
|
|
34
|
+
"SITES",
|
|
35
|
+
"by_network",
|
|
36
|
+
"get_site",
|
|
37
|
+
"validate",
|
|
38
|
+
"TableRetrievedLST",
|
|
39
|
+
"RetrievedLST",
|
|
40
|
+
"ValidationPair",
|
|
41
|
+
"ValidationResult",
|
|
42
|
+
"ValidationStats",
|
|
43
|
+
]
|
lstnet/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Package-level path resolution and configuration helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def package_root() -> Path:
|
|
10
|
+
"""Return the resolved directory containing the ``lstnet`` package.
|
|
11
|
+
|
|
12
|
+
Resolves to the ``src/lstnet`` directory in a source checkout and to the
|
|
13
|
+
installed package directory in an installed environment.
|
|
14
|
+
"""
|
|
15
|
+
return Path(__file__).resolve().parent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def project_root() -> Path:
|
|
19
|
+
"""Return the resolved repository root (CWD-independent).
|
|
20
|
+
|
|
21
|
+
``package_root()`` is ``src/lstnet``; two ``.parent`` calls walk up to
|
|
22
|
+
``src/`` then the repo root. Used as the anchor for default ``data_dir``
|
|
23
|
+
paths so readers do not depend on the current working directory.
|
|
24
|
+
"""
|
|
25
|
+
return package_root().parent.parent
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def earthdata_credentials() -> tuple[str, str]:
|
|
29
|
+
"""Return NASA Earthdata Login ``(username, password)`` from the environment.
|
|
30
|
+
|
|
31
|
+
Reads ``EARTHDATA_USERNAME`` and ``EARTHDATA_PASSWORD``. Credentials must
|
|
32
|
+
NEVER be hardcoded — the legacy app stored them in plain source, which is a
|
|
33
|
+
defect this refactor removes. Raise ``RuntimeError`` with setup guidance if
|
|
34
|
+
either is missing.
|
|
35
|
+
|
|
36
|
+
(``earthaccess`` also honours ``~/.netrc`` / its own persisted login; this
|
|
37
|
+
helper is the explicit, documented path for programmatic use.)
|
|
38
|
+
"""
|
|
39
|
+
username = os.environ.get("EARTHDATA_USERNAME")
|
|
40
|
+
password = os.environ.get("EARTHDATA_PASSWORD")
|
|
41
|
+
if not username or not password:
|
|
42
|
+
raise RuntimeError(
|
|
43
|
+
"NASA Earthdata credentials not found. Set EARTHDATA_USERNAME and "
|
|
44
|
+
"EARTHDATA_PASSWORD in the environment (or configure ~/.netrc)."
|
|
45
|
+
)
|
|
46
|
+
return username, password
|
|
47
|
+
|
lstnet/dayornight.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Day/night determination for a satellite overpass (port of methods/DayorNight.py).
|
|
2
|
+
|
|
3
|
+
Uses ``astral`` sun elevation: above horizon (elevation > 0) -> ``'Day'`` else
|
|
4
|
+
``'Night'``. This is robust to the UTC date-boundary quirk that astral's
|
|
5
|
+
sunrise/sunset events exhibit at western longitudes, and handles polar
|
|
6
|
+
night/day without special-casing.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
from astral import LocationInfo
|
|
13
|
+
from astral.sun import elevation
|
|
14
|
+
|
|
15
|
+
from lstnet.models import Site
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def dayornight(site: Site, overpass_time: datetime) -> str:
|
|
19
|
+
"""Return ``'Day'`` or ``'Night'`` for ``overpass_time`` (UTC) at ``site``."""
|
|
20
|
+
if overpass_time.tzinfo is None:
|
|
21
|
+
overpass_time = overpass_time.replace(tzinfo=timezone.utc)
|
|
22
|
+
observer = LocationInfo("site", "region", "UTC", site.lat, site.lon).observer
|
|
23
|
+
return "Day" if elevation(observer, overpass_time) > 0 else "Night"
|
lstnet/ground_lst.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Ground-truth LST physics.
|
|
2
|
+
|
|
3
|
+
Inverts the surface longwave radiance balance to recover surface temperature:
|
|
4
|
+
L_up = emiss * sigma * T^4 + (1 - emiss) * L_down
|
|
5
|
+
T = ((L_up - (1 - emiss) * L_down) / (emiss * sigma)) ** 0.25
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
from lstnet.dayornight import dayornight
|
|
13
|
+
from lstnet.io.base import EmissivitySource, NetworkReader
|
|
14
|
+
from lstnet.models import GroundLST, Site
|
|
15
|
+
from lstnet.qc import QC_NO_DATA, QC_OK, QC_STD_ERROR, QC_TIME_ERROR, decide_qc
|
|
16
|
+
|
|
17
|
+
# Stefan-Boltzmann constant (W m^-2 K^-4). Unified across networks — the legacy
|
|
18
|
+
# code used 5.67e-8 (SURFRAD) and 5.6697e-8 (HiWATER) inconsistently.
|
|
19
|
+
SIGMA = 5.670374e-8
|
|
20
|
+
|
|
21
|
+
_OVERPASS_FORMAT = "%Y%m%d%H%M"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def lst_from_radiance(
|
|
25
|
+
l_up: float, l_down: float, emiss: float, sigma: float = SIGMA
|
|
26
|
+
) -> float:
|
|
27
|
+
"""Convert up/down longwave radiance + emissivity to LST in Kelvin.
|
|
28
|
+
|
|
29
|
+
``l_up``/``l_down`` are upwelling/downwelling longwave radiation (W/m^2).
|
|
30
|
+
Raises :class:`ValueError` on non-physical input.
|
|
31
|
+
"""
|
|
32
|
+
if not 0.0 < emiss <= 1.0:
|
|
33
|
+
raise ValueError(f"emissivity must be in (0, 1], got {emiss}")
|
|
34
|
+
reflected = (1.0 - emiss) * l_down
|
|
35
|
+
net = l_up - reflected
|
|
36
|
+
if net <= 0:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"non-physical input: l_up ({l_up}) must exceed (1-emiss)*l_down ({reflected})"
|
|
39
|
+
)
|
|
40
|
+
return (net / (emiss * sigma)) ** 0.25
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_overpass_time(s: str) -> datetime:
|
|
44
|
+
"""Parse a 12-digit ``YYYYMMDDHHMM`` stamp into an aware UTC datetime.
|
|
45
|
+
|
|
46
|
+
Raises :class:`ValueError` if ``s`` is not a 12-digit string or does not
|
|
47
|
+
form a valid calendar datetime.
|
|
48
|
+
"""
|
|
49
|
+
if not isinstance(s, str) or len(s) != 12 or not s.isdigit():
|
|
50
|
+
raise ValueError(f"overpass_time must be 12 digits YYYYMMDDHHMM, got {s!r}")
|
|
51
|
+
return datetime.strptime(s, _OVERPASS_FORMAT).replace(tzinfo=timezone.utc)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compute_ground_lst(
|
|
55
|
+
site: Site,
|
|
56
|
+
overpass_time: datetime | str,
|
|
57
|
+
emiss_src: EmissivitySource,
|
|
58
|
+
reader: NetworkReader,
|
|
59
|
+
window_minutes: int = 0,
|
|
60
|
+
) -> GroundLST:
|
|
61
|
+
"""Orchestrate reader + emissivity + physics + QC into one :class:`GroundLST`.
|
|
62
|
+
|
|
63
|
+
- ``overpass_time`` as ``str`` is parsed via :func:`parse_overpass_time`;
|
|
64
|
+
an unparseable stamp yields a ``TimeError`` result (no exception).
|
|
65
|
+
- ``day_or_night`` is computed via :func:`lstnet.dayornight.dayornight`
|
|
66
|
+
(astral sun elevation); ``"Unknown"`` only on a TimeError result.
|
|
67
|
+
- Non-``OK`` reader windows propagate their status verbatim as the qc_flag.
|
|
68
|
+
- Non-physical samples (``lst_from_radiance`` raises) are skipped; the
|
|
69
|
+
remaining samples feed :func:`lstnet.qc.decide_qc`.
|
|
70
|
+
|
|
71
|
+
``window_minutes`` default ``0`` is a **sentinel**: each reader uses its
|
|
72
|
+
native legacy window — SURFRAD applies ``range(row-5+step, row+6-step)``
|
|
73
|
+
(±4 min for step=1 / 9 samples, ±2 min for step=3 / 5 samples), PKU uses
|
|
74
|
+
its per-interval (1/2/3-min) branch, HiWATER uses the 2 nearest samples.
|
|
75
|
+
A network-agnostic uniform window cannot reproduce all three legacy
|
|
76
|
+
retrievals, so the default delegates. Pass an explicit positive value to
|
|
77
|
+
override (SURFRAD honors it; PKU/HiWATER ignore it by design).
|
|
78
|
+
"""
|
|
79
|
+
day_or_night = "Unknown"
|
|
80
|
+
|
|
81
|
+
if isinstance(overpass_time, str):
|
|
82
|
+
try:
|
|
83
|
+
overpass_time = parse_overpass_time(overpass_time)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return GroundLST(
|
|
86
|
+
overpass_time=datetime(1, 1, 1, tzinfo=timezone.utc),
|
|
87
|
+
site=site,
|
|
88
|
+
lst_k=math.nan,
|
|
89
|
+
emissivity=math.nan,
|
|
90
|
+
day_or_night=day_or_night,
|
|
91
|
+
qc_flag=QC_TIME_ERROR,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
day_or_night = dayornight(site, overpass_time)
|
|
95
|
+
emiss = emiss_src.emissivity(site, overpass_time, day_or_night)
|
|
96
|
+
window = reader.read_radiation(site, overpass_time, window_minutes)
|
|
97
|
+
|
|
98
|
+
if window.status != QC_OK:
|
|
99
|
+
return GroundLST(
|
|
100
|
+
overpass_time=overpass_time,
|
|
101
|
+
site=site,
|
|
102
|
+
lst_k=math.nan,
|
|
103
|
+
emissivity=emiss,
|
|
104
|
+
day_or_night=day_or_night,
|
|
105
|
+
qc_flag=window.status,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
lst_samples: list[float] = []
|
|
109
|
+
for sample in window.samples:
|
|
110
|
+
try:
|
|
111
|
+
lst_samples.append(
|
|
112
|
+
lst_from_radiance(sample.l_up, sample.l_down, emiss)
|
|
113
|
+
)
|
|
114
|
+
except ValueError:
|
|
115
|
+
# Non-physical sample (e.g. l_up <= (1-emiss)*l_down) — skip.
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
avg, flag = decide_qc(lst_samples)
|
|
119
|
+
lst_k = avg if avg is not None else math.nan
|
|
120
|
+
return GroundLST(
|
|
121
|
+
overpass_time=overpass_time,
|
|
122
|
+
site=site,
|
|
123
|
+
lst_k=lst_k,
|
|
124
|
+
emissivity=emiss,
|
|
125
|
+
day_or_night=day_or_night,
|
|
126
|
+
qc_flag=flag,
|
|
127
|
+
)
|