efast-phenocam-validation/phenology_timesat.py
Felix Delattre fa59122e3b foo:
2026-05-03 17:07:19 +02:00

738 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
PhenoCam GCC: green-up and green-down (50 % of seasonal amplitude) via TIMESAT.
Reads ``data/.../raw/phenocam/phenocam_gcc.json`` (or any path) and uses the
``timesat`` package (``timesat.tsfprocess``) with the same seasonal-threshold
meaning as the TIMESAT GUI: *startmethod* 1, *p_startcutoff* (0.5, 0.5) = 50 % of
the **per-season** amplitude above the local base. See the TIMESAT manual,
section 4.3 and row 3738 (season start method = seasonal amplitude).
**License:** the ``timesat`` PyPI wheel is under the TIMESAT Research License
(non-commercial research; see package metadata on PyPI).
PhenoCam time series: single-year acquisition writes
``phenocam_gcc.json`` (and ``phenocam_gcc.csv``). The three-year series used
for TIMESAT is stored separately as ``phenocam_gcc_3y.json`` in the same
folder (created on first use from the one-day summary API, then reused).
Importable: ``write_phenocam_phenology_for_site`` is called from ``run.py``;
the CLI entry point remains optional for ad-hoc runs.
**Saving results:** use ``-o path.json`` or ``--sidecar`` to write a JSON file
(see ``--help``). Sidecar mode writes ``phenocam_phenology.json`` (two dates
only) next to ``phenocam_gcc.json``.
``run_pipeline`` in ``run.py`` writes the same ``phenocam_phenology.json`` by
default when ``timesat`` is installed. GCC for TIMESAT uses ``phenocam_gcc_3y.json``
if present, otherwise the PhenoCam API for that site (listed in
``data/sites.geojson``; not a site list from the API). One-year
``phenocam_gcc.json`` on disk can still fill gaps when merged.
Use ``python phenology_timesat.py --all`` to batch every
``(sitename, season)`` from ``data/sites.geojson`` (``properties.sitename`` and
``properties.seasons``).
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from datetime import datetime, timedelta
from pathlib import Path
import numpy as np
import requests
PHENOCAM_API = "https://phenocam.nau.edu/api"
try:
import timesat as _timesat
except ImportError:
_timesat = None
NODATA = -9999.0
def load_phenocam_gcc(path: Path) -> dict[str, float]:
"""Return map YYYY-MM-DD -> greenness index from PhenoCam JSON list."""
with open(path) as f:
rows = json.load(f)
out: dict[str, float] = {}
for row in rows:
d = str(row.get("date", ""))[:10]
v = row.get("greenness_index")
if d and v is not None and np.isfinite(v):
out[d] = float(v)
return out
def _gcc_from_summary_row(row: dict, use_mean_fallback: bool) -> float | None:
"""Extract daily GCC from a one-day summary row (same rules as acquisition)."""
if not use_mean_fallback:
oflag = row.get("outlierflag_gcc_90")
if oflag is not None and str(oflag).strip() in ("1", "1.0"):
return None
raw = row.get("gcc_mean" if use_mean_fallback else "gcc_90")
if raw is None:
return None
text = str(raw).strip()
if not text or text.upper() == "NA":
return None
try:
val = float(text)
except ValueError:
return None
if val <= -9998.0:
return None
return val
def _phenocam_one_day_summary_csv_url(site_name: str) -> str | None:
"""Return URL of the one-day summary CSV for *site_name*, or None on failure."""
try:
url = f"{PHENOCAM_API}/roilists/"
params: dict | None = {"site": site_name}
rois: list[dict] = []
while url:
r = requests.get(url, params=params, timeout=30)
r.raise_for_status()
data = r.json()
rois.extend(
[roi for roi in data.get("results", []) if roi["site"] == site_name]
)
url = data.get("next")
params = None
if rois:
break
if not rois:
return None
return rois[0].get("one_day_summary") or None
except requests.RequestException:
return None
def _parse_phenocam_gcc_from_csv_text(
text: str, start_date: str, end_date: str
) -> dict[str, float]:
"""Map YYYY-MM-DD -> gcc for rows in [start_date, end_date] inclusive."""
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
lines = [line for line in text.split("\n") if line and not line.startswith("#")]
reader = csv.DictReader(lines)
fieldnames = reader.fieldnames or ()
use_mean_fallback = "gcc_90" not in fieldnames
out: dict[str, float] = {}
for row in reader:
try:
date_str = row.get("date")
if not date_str:
continue
date = datetime.strptime(date_str, "%Y-%m-%d")
if not (start_dt <= date <= end_dt):
continue
gcc = _gcc_from_summary_row(row, use_mean_fallback)
if gcc is not None:
out[date.date().isoformat()] = gcc
except (ValueError, KeyError):
continue
return out
def save_phenocam_gcc_json(path: Path, by_date: dict[str, float]) -> None:
"""Write the same list-of-objects format as :func:`acquisition_phenocam` GCC JSON."""
rows = [
{"date": d, "greenness_index": v}
for d, v in sorted(by_date.items(), key=lambda x: x[0])
]
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(rows, f, indent=2)
f.write("\n")
def fetch_phenocam_gcc_three_years_separately(
site_name: str, season: int
) -> dict[str, float]:
"""
Download PhenoCam one-day summary GCC for three **calendar** years
(``season-1`` … ``season+1``), independently of :mod:`acquisition_phenocam`.
Uses one HTTP GET of the full summary CSV, then **three** per-year
extractions (same logic as the acquisition CSV filter, three date windows).
"""
out: dict[str, float] = {}
csv_url = _phenocam_one_day_summary_csv_url(site_name)
if not csv_url:
print(
f"[PhenoCam phenology] No PhenoCam one-day summary URL for site {site_name!r}"
)
return out
try:
csv_r = requests.get(csv_url, timeout=30)
csv_r.raise_for_status()
except requests.RequestException as e:
print(f"[PhenoCam phenology] API CSV fetch failed: {e}")
return out
text = csv_r.text
for y in (season - 1, season, season + 1):
part = _parse_phenocam_gcc_from_csv_text(text, f"{y}-01-01", f"{y}-12-31")
out.update(part)
return out
def load_or_fetch_phenocam_gcc_3y(
site_name: str, season: int, gcc_3y_path: Path
) -> dict[str, float]:
"""
Use ``phenocam_gcc_3y.json`` on disk if it exists and parses; else fetch
three years from the PhenoCam one-day summary for *site_name* and save to
*gcc_3y_path*.
"""
if gcc_3y_path.is_file():
try:
cached = load_phenocam_gcc(gcc_3y_path)
except (OSError, json.JSONDecodeError):
cached = {}
if cached:
print(f"[PhenoCam phenology] Using {gcc_3y_path} ({len(cached)} values)")
return cached
out = fetch_phenocam_gcc_three_years_separately(site_name, season)
if not out:
return {}
save_phenocam_gcc_json(gcc_3y_path, out)
print(
f"[PhenoCam phenology] Fetched and wrote {gcc_3y_path} "
f"({len(out)} values for {season - 1}{season + 1})"
)
return out
def resolve_phenocam_gcc_for_timesat(
site_name: str, season: int, gcc_path: Path
) -> dict[str, float]:
"""
Load three-year series from ``phenocam_gcc_3y.json`` (or fetch once and
save there), merge with one-year ``gcc_path`` if present; three-year values
win on duplicate dates.
"""
gcc_3y = gcc_path.parent / "phenocam_gcc_3y.json"
by_3y = load_or_fetch_phenocam_gcc_3y(site_name, season, gcc_3y)
by_1y: dict[str, float] = {}
if gcc_path.is_file():
try:
by_1y = load_phenocam_gcc(gcc_path)
except (OSError, json.JSONDecodeError):
pass
if by_3y:
return {**by_1y, **by_3y}
return by_1y
def _day_count(calendar_year: int) -> int:
a = datetime(calendar_year, 1, 1)
b = datetime(calendar_year + 1, 1, 1)
return (b - a).days
def daily_profile_for_year(by_date: dict[str, float], calendar_year: int) -> np.ndarray:
"""
One value per day (length 365 or 366 for leap years). Gaps are filled by
linear interpolation in time along the year; if only one valid point exists,
that value is used for the whole year.
"""
n = _day_count(calendar_year)
raw = np.full(n, np.nan, dtype=np.float64)
for d in range(1, n + 1):
dt = datetime(calendar_year, 1, 1) + timedelta(days=d - 1)
key = dt.strftime("%Y-%m-%d")
if key in by_date:
raw[d - 1] = by_date[key]
valid = np.isfinite(raw) & (raw > 0.0)
if not np.any(valid):
raise ValueError(f"No valid GCC in JSON for calendar year {calendar_year}")
if np.sum(valid) == 1:
v = float(raw[valid][0])
return np.full(n, v, dtype=np.float32)
idx = np.arange(n, dtype=np.float64)
raw = np.interp(idx, idx[valid], raw[valid])
return raw.astype(np.float32)
def _gcc_profile_365_for_timesat(profile: np.ndarray) -> np.ndarray:
"""TIMESAT uses 365 days per season; drop Dec 31 on leap years."""
p = np.asarray(profile, dtype=np.float32).ravel()
if p.size == 366:
return p[:365]
if p.size == 365:
return p
raise ValueError(f"expected 365 or 366 daily values, got {p.size}")
def yyyydoy_to_iso(v: float) -> str:
x = int(round(float(v)))
y = x // 1000
doy = x - y * 1000
d = datetime(y, 1, 1) + timedelta(days=doy - 1)
return d.date().isoformat()
def build_yraw_three_years(
by_date: dict[str, float], y1: int, y2: int, y3: int
) -> tuple[np.ndarray, str]:
"""
Stack three calendar years of daily GCC (365 pts/year) for TIMESAT.
If each of *y1*, *y2*, *y3* has at least one valid GCC in *by_date* (after
per-year gap filling), returns their concatenation — **three real years**.
If any of those years cannot be built (e.g. single-year download only),
falls back to **replicating** the profile for *y2* three times (legacy
TIMESAT workaround).
"""
try:
p1 = _gcc_profile_365_for_timesat(daily_profile_for_year(by_date, y1))
p2 = _gcc_profile_365_for_timesat(daily_profile_for_year(by_date, y2))
p3 = _gcc_profile_365_for_timesat(daily_profile_for_year(by_date, y3))
yraw = np.concatenate([p1, p2, p3]).astype(np.float32, copy=False)
return yraw, "three_independent_years"
except ValueError:
p2 = _gcc_profile_365_for_timesat(daily_profile_for_year(by_date, y2))
yraw = np.tile(p2, 3)
return yraw, "single_year_replicated"
def run_timesat_phenology_from_yraw(
yraw: np.ndarray,
years_triplet: tuple[int, int, int],
*,
start_cutoff: tuple[float, float] = (0.5, 0.5),
smooth_window: float = 2.0,
p_ignoreday: int = 366,
) -> dict[str, str | float | None]:
"""
Run TIMESAT on a length ``365 * 3`` daily VI stack and calendar *years_triplet*
(YYYY, YYYY, YYYY) for the time vector. Middle year in the triplet is the
season whose SOS/EOS we report.
"""
yraw = np.asarray(yraw, dtype=np.float32).ravel()
y1, y2, y3 = years_triplet
nyear = 3
npt = 365 * nyear
if yraw.size != npt:
raise ValueError(f"yraw must have length {npt}, got {yraw.size}")
tlist: list[int] = []
for y in (y1, y2, y3):
t0 = datetime(y, 1, 1)
for d in range(365):
tlist.append(int((t0 + timedelta(days=d)).strftime("%Y%j")))
tv = np.array(tlist, dtype=np.int32)
if len(tv) != npt:
raise RuntimeError("internal: length mismatch")
vi = np.asfortranarray(yraw.reshape(1, 1, -1))
qa = np.asfortranarray(np.ones((1, 1, npt), dtype=np.float32))
lc = np.ones((1, 1), dtype=np.uint8)
landuse = np.ones(255, dtype=np.uint8)
p_out = np.arange(1, npt + 1, dtype=np.int32)
p_ylu = np.asfortranarray(np.array([0.0, 1.0], dtype=np.float64))
ci = 0
p_fitmethod = np.zeros(255, dtype=np.int32)
p_fitmethod[ci] = 1
p_smooth = np.zeros(255, dtype=np.float64)
p_smooth[ci] = float(smooth_window)
p_nenvi = np.zeros(255, dtype=np.int32)
p_nenvi[ci] = 1
p_wfact = np.zeros(255, dtype=np.float64)
p_wfact[ci] = 1.0
p_startmethod = np.zeros(255, dtype=np.int32)
p_startmethod[ci] = 1
p_startcutoff = np.zeros((255, 2), dtype=np.float64, order="F")
p_startcutoff[ci, :] = np.array(
[start_cutoff[0], start_cutoff[1]], dtype=np.float64
)
p_low = np.zeros(255, dtype=np.float64)
p_fillbase = np.zeros(255, dtype=np.int32)
p_seasonmethod = np.zeros(255, dtype=np.int32)
p_seasonmethod[ci] = 1
p_seapar = np.zeros(255, dtype=np.float64)
p_seapar[ci] = 1.0
if _timesat is None:
raise ImportError("Install the 'timesat' package: pip install timesat")
vpp, _vppqa, nseason, yfit, _yfitqa, _seasonfit, _tseq = _timesat.tsfprocess(
nyear,
vi,
qa,
tv,
lc,
1,
landuse,
p_out,
p_ignoreday,
p_ylu,
0,
p_fitmethod,
p_smooth,
NODATA,
45,
0,
p_nenvi,
p_wfact,
p_startmethod,
p_startcutoff,
p_low,
p_fillbase,
1,
p_seasonmethod,
p_seapar,
1,
1,
1,
npt,
len(p_out),
)
a = vpp[0, 0, :]
# three growing-season rows at indices 0, 13*2, 13*4 in the raw vector
middle_block = 2
off = 13 * middle_block
sosd = a[off + 0] if a.size > off + 0 else np.nan
sosv = a[off + 1] if a.size > off + 1 else np.nan
eosd = a[off + 3] if a.size > off + 3 else np.nan
eosv = a[off + 4] if a.size > off + 4 else np.nan
yfit_max = float(np.max(yfit)) if yfit.size else float("nan")
def pick(x: float) -> str | None:
if not np.isfinite(x) or x < 1.0e5 or x < 0:
return None
try:
return yyyydoy_to_iso(x)
except (OverflowError, ValueError):
return None
return {
"reference_calendar_year": y2,
"green_up_50pct_date": pick(sosd),
"green_up_50pct_fitted_gcc": float(sosv) if np.isfinite(sosv) else None,
"green_down_50pct_date": pick(eosd),
"green_down_50pct_fitted_gcc": float(eosv) if np.isfinite(eosv) else None,
"nseason": nseason[0, 0].tolist() if nseason.ndim >= 2 else [],
"yfit_max": yfit_max,
}
def run_timesat_phenology(
daily_profile: np.ndarray,
years_triplet: tuple[int, int, int],
*,
start_cutoff: tuple[float, float] = (0.5, 0.5),
smooth_window: float = 2.0,
p_ignoreday: int = 366,
) -> dict[str, str | float | None]:
"""
Back-compat: run TIMESAT on one years 365(366) profile **replicated** three times.
Prefer :func:`build_yraw_three_years` + :func:`run_timesat_phenology_from_yraw`.
"""
prof = np.asarray(daily_profile, dtype=np.float32).ravel()
if len(prof) not in (365, 366):
raise ValueError("daily_profile must have length 365 or 366")
if len(prof) == 366:
prof = prof[:365]
yraw = np.tile(prof, 3)
return run_timesat_phenology_from_yraw(
yraw,
years_triplet,
start_cutoff=start_cutoff,
smooth_window=smooth_window,
p_ignoreday=p_ignoreday,
)
def phenocam_gcc_path(site_name: str, season: int) -> Path:
return Path(f"data/{site_name}/{season}/raw/phenocam/phenocam_gcc.json")
def phenocam_gcc_3y_path(site_name: str, season: int) -> Path:
return Path(f"data/{site_name}/{season}/raw/phenocam/phenocam_gcc_3y.json")
def iter_sites_seasons_with_phenocam(
data_root: str | Path = "data",
) -> list[tuple[str, int]]:
"""``(site_name, season)`` for every ``phenocam_gcc.json`` under *data_root* (legacy)."""
root = Path(data_root)
if not root.is_dir():
return []
out: list[tuple[str, int]] = []
seen: set[tuple[str, int]] = set()
for p in sorted(root.glob("*/*/raw/phenocam/phenocam_gcc.json")):
rel = p.relative_to(root)
site, season_s = rel.parts[0], rel.parts[1]
if not season_s.isdigit():
continue
season = int(season_s)
key = (site, season)
if key not in seen:
seen.add(key)
out.append(key)
return out
def iter_sites_seasons_from_sites_geojson(
path: str | Path = "data/sites.geojson",
) -> list[tuple[str, int]]:
"""
``(sitename, season)`` from a GeoJSON FeatureCollection: each features
``properties.sitename`` and each key in ``properties.seasons`` (4-digit year).
"""
path = Path(path)
if not path.is_file():
return []
with open(path, encoding="utf-8") as f:
fc = json.load(f)
out: list[tuple[str, int]] = []
for feat in fc.get("features", []):
props = feat.get("properties") or {}
name = props.get("sitename")
seasons = props.get("seasons")
if not name or not isinstance(seasons, dict):
continue
for skey in sorted(seasons.keys()):
if skey.isdigit() and len(skey) == 4:
out.append((str(name), int(skey)))
return out
def write_phenocam_phenology_all(
*,
sites_geojson: str | Path | None = None,
data_root: str | Path = "data",
smooth_window: float = 2.0,
p_ignoreday: int = 366,
) -> int:
"""
Run :func:`write_phenocam_phenology_for_site` for every ``(site, season)`` in
*sites_geojson* (default: :file:`<data_root>/sites.geojson`), not a glob over
``data/``.
"""
geo = Path(
sites_geojson
if sites_geojson is not None
else Path(data_root) / "sites.geojson"
)
pairs = iter_sites_seasons_from_sites_geojson(geo)
if not pairs and geo.is_file():
print(
f"[PhenoCam phenology] No (sitename, season) entries in {geo} "
"(check properties.sitename and properties.seasons)."
)
elif not pairs:
print(f"[PhenoCam phenology] Missing or empty sites file: {geo}")
n = 0
for site, season in pairs:
print(f"=== {site} {season} ===")
write_phenocam_phenology_for_site(
site, season, smooth_window=smooth_window, p_ignoreday=p_ignoreday
)
n += 1
print(f"[PhenoCam phenology] Processed {n} site/season pair(s) from {geo}.")
return n
def phenocam_phenology_path(site_name: str, season: int) -> Path:
return Path(f"data/{site_name}/{season}/raw/phenocam/phenocam_phenology.json")
def write_phenocam_phenology_for_site(
site_name: str,
season: int,
*,
smooth_window: float = 2.0,
p_ignoreday: int = 366,
) -> None:
"""
If ``timesat`` is installed, build GCC from ``phenocam_gcc_3y.json`` (or fetch
three years once and save there), with optional one-year ``phenocam_gcc.json``,
then write
``phenocam_phenology.json`` in the same directory with
``green_up_50pct_date`` and ``green_down_50pct_date`` (ISO dates or null).
"""
if _timesat is None:
out = phenocam_phenology_path(site_name, season)
print(
f"[PhenoCam phenology] Skipped (no timesat); would write {out}. "
"pip install timesat"
)
return
gcc = phenocam_gcc_path(site_name, season)
try:
by_date = resolve_phenocam_gcc_for_timesat(site_name, season, gcc)
except OSError as e:
print(f"[PhenoCam phenology] Skipped: {e}")
return
if not by_date:
g3 = gcc.parent / "phenocam_gcc_3y.json"
print(
f"[PhenoCam phenology] No GCC ({gcc} and no data in {g3} after API); "
f"skipping {phenocam_phenology_path(site_name, season).name}."
)
return
try:
yraw, stack_mode = build_yraw_three_years(
by_date, season - 1, season, season + 1
)
except (OSError, ValueError) as e:
print(f"[PhenoCam phenology] Skipped: {e}")
return
out = run_timesat_phenology_from_yraw(
yraw,
(season - 1, season, season + 1),
smooth_window=smooth_window,
p_ignoreday=p_ignoreday,
)
record = {
"green_up_50pct_date": out.get("green_up_50pct_date"),
"green_down_50pct_date": out.get("green_down_50pct_date"),
}
out_path = phenocam_phenology_path(site_name, season)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(record, f, indent=2)
f.write("\n")
gup, gdn = record["green_up_50pct_date"], record["green_down_50pct_date"]
print(
f"[PhenoCam phenology] Wrote {out_path} (green-up {gup!r}, green-down {gdn!r}; "
f"TIMESAT input={stack_mode})"
)
def main() -> None:
ap = argparse.ArgumentParser(
description="TIMESAT 50 % seasonal-amplitude green-up / green-down for PhenoCam GCC JSON."
)
ap.add_argument(
"--all",
action="store_true",
help="Write phenocam for every (sitename, season) in the sites GeoJSON (see --sites-geojson).",
)
ap.add_argument(
"--data-root",
type=Path,
default=Path("data"),
help="Resolves default --sites-geojson to <data-root>/sites.geojson.",
)
ap.add_argument(
"--sites-geojson",
type=Path,
default=None,
help="For --all: path to data/sites.geojson (default: <data-root>/sites.geojson).",
)
ap.add_argument(
"gcc_json",
type=Path,
nargs="?",
default=Path("data/innsbruck/2024/raw/phenocam/phenocam_gcc.json"),
help="Path to phenocam_gcc.json (default: Innsbruck 2024 if present).",
)
ap.add_argument(
"--season",
type=int,
default=None,
help="Calendar year to build the daily GCC profile (default: infer from file path .../<year>/...).",
)
ap.add_argument(
"--savitzky-hw",
type=float,
default=2.0,
help="Half-width for fitmethod 1 (SavitzkyGolay); default 2.",
)
ap.add_argument(
"--p-ignoreday",
type=int,
default=366,
help="TIMESAT p_ignoreday (default 366).",
)
ap.add_argument(
"-o",
"--output",
type=Path,
default=None,
help="Write results to this JSON file (same schema as stdout, plus metadata).",
)
ap.add_argument(
"--sidecar",
action="store_true",
help="Save two-date JSON next to input as phenocam_phenology.json (implies -o).",
)
args = ap.parse_args()
if _timesat is None:
raise SystemExit(
"The 'timesat' package is required. Install with: pip install timesat"
)
if args.all:
write_phenocam_phenology_all(
sites_geojson=args.sites_geojson,
data_root=args.data_root,
smooth_window=args.savitzky_hw,
p_ignoreday=args.p_ignoreday,
)
return
path: Path = args.gcc_json
if not path.is_file():
raise SystemExit(f"Not a file: {path}")
season = args.season
if season is None:
for part in path.parts:
if part.isdigit() and len(part) == 4:
season = int(part)
break
if season is None:
season = datetime.now().year
by_date = load_phenocam_gcc(path)
yraw, stack_mode = build_yraw_three_years(by_date, season - 1, season, season + 1)
out = run_timesat_phenology_from_yraw(
yraw,
(season - 1, season, season + 1),
smooth_window=args.savitzky_hw,
p_ignoreday=args.p_ignoreday,
)
payload = {
**out,
"source_gcc_json": str(path.resolve()),
"profile_year": season,
"timesat_input": stack_mode,
"method": "TIMESAT tsfprocess; startmethod=1; p_startcutoff=[0.5,0.5] (50% seasonal amplitude)",
}
out_path = args.output
if args.sidecar:
out_path = path.parent / "phenocam_phenology.json"
if out_path is not None:
out_path.parent.mkdir(parents=True, exist_ok=True)
to_write = (
{
"green_up_50pct_date": out.get("green_up_50pct_date"),
"green_down_50pct_date": out.get("green_down_50pct_date"),
}
if args.sidecar
else payload
)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(to_write, f, indent=2)
f.write("\n")
print(f"Wrote {out_path}", file=sys.stderr)
print(json.dumps(payload, indent=2))
gup = out.get("green_up_50pct_date")
gdn = out.get("green_down_50pct_date")
if gup and gdn:
print(
f"Green-up (50 %): {gup} | Green-down (50 %): {gdn} "
f"(profile year {season}, TIMESAT reference year {out['reference_calendar_year']})"
)
if __name__ == "__main__":
main()