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 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
+ )