163 lines
5.2 KiB
Python
163 lines
5.2 KiB
Python
"""TIMESAT transition dates on gap-degraded fusion series vs PhenoCam reference."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from fusion_phenology import timesat_transitions_from_by_date
|
|
from phenology_timesat import phenocam_phenology_path
|
|
|
|
from gap_validation.batch_spatial import (
|
|
PRIMARY_SEASON,
|
|
_best_from_metrics,
|
|
_parse_scenario,
|
|
_resolve_workflows,
|
|
_site_positions,
|
|
)
|
|
from gap_validation.calendar import load_manifest, validation_dir
|
|
from gap_validation.temporal_pc import _fusion_gcc_timeseries
|
|
|
|
|
|
def _day_offset(iso_a: str | None, iso_b: str | None) -> int | None:
|
|
if not iso_a or not iso_b:
|
|
return None
|
|
try:
|
|
a = datetime.strptime(iso_a[:10], "%Y-%m-%d").date()
|
|
b = datetime.strptime(iso_b[:10], "%Y-%m-%d").date()
|
|
return abs((a - b).days)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _timesat_transitions(by_date: dict[str, float], season: int) -> dict[str, str | None]:
|
|
out = timesat_transitions_from_by_date(by_date, season)
|
|
return {
|
|
"green_up": out.get("green_up_50pct_date"),
|
|
"green_down": out.get("green_down_50pct_date"),
|
|
}
|
|
|
|
|
|
def _temporal_fusion_dir(
|
|
site: str, season: int, gap_days: int, transition: str, scenario_key: str
|
|
) -> Path:
|
|
strategy, sigma, mode = _parse_scenario(scenario_key)
|
|
sig = 30 if sigma == 30 else 20
|
|
return (
|
|
validation_dir(site, season)
|
|
/ "temporal"
|
|
/ f"gap_{gap_days}_{transition}"
|
|
/ f"{strategy}_sigma{sig}_{mode}"
|
|
)
|
|
|
|
|
|
def compute_offsets_for_site(
|
|
site: str,
|
|
season: int,
|
|
site_position: tuple[float, float],
|
|
*,
|
|
workflow: str = "bti",
|
|
gap_days_list: tuple[int, ...] = (15, 30),
|
|
) -> list[dict]:
|
|
base = Path(f"data/{site}/{season}")
|
|
metrics_path = base / "metrics.json"
|
|
scenario_key = _best_from_metrics(metrics_path, workflow)
|
|
if not scenario_key:
|
|
return []
|
|
ref_path = phenocam_phenology_path(site, season)
|
|
reference = (
|
|
json.loads(ref_path.read_text(encoding="utf-8")) if ref_path.is_file() else {}
|
|
)
|
|
manifest = load_manifest(site, season)
|
|
rows: list[dict] = []
|
|
for entry in manifest["entries"]:
|
|
gd = entry.get("gap_days")
|
|
tr = entry.get("transition")
|
|
if gd not in gap_days_list or tr not in ("green_up", "green_down"):
|
|
continue
|
|
fusion_dir = _temporal_fusion_dir(site, season, gd, tr, scenario_key)
|
|
if not fusion_dir.is_dir():
|
|
continue
|
|
_, _, mode = _parse_scenario(scenario_key)
|
|
ts = _fusion_gcc_timeseries(fusion_dir, site_position, mode)
|
|
if len(ts) < 10:
|
|
continue
|
|
fused = _timesat_transitions(ts, season)
|
|
ref_key = (
|
|
"green_up_50pct_date"
|
|
if tr == "green_up"
|
|
else "green_down_50pct_date"
|
|
)
|
|
ref_date = reference.get(ref_key)
|
|
fused_date = fused.get("green_up" if tr == "green_up" else "green_down")
|
|
rows.append(
|
|
{
|
|
"site_name": site,
|
|
"season": season,
|
|
"transition": tr,
|
|
"gap_days": gd,
|
|
"scenario": scenario_key,
|
|
"reference_date": ref_date,
|
|
"fused_date": fused_date,
|
|
"abs_day_offset": _day_offset(fused_date, ref_date),
|
|
"window_start": entry.get("window_start"),
|
|
"window_end": entry.get("window_end"),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def write_phenology_offsets(
|
|
site: str,
|
|
season: int,
|
|
site_position: tuple[float, float],
|
|
*,
|
|
workflow: str = "bti",
|
|
gap_days_list: tuple[int, ...] = (15, 30),
|
|
) -> Path:
|
|
rows = compute_offsets_for_site(
|
|
site, season, site_position, workflow=workflow, gap_days_list=gap_days_list
|
|
)
|
|
vdir = validation_dir(site, season)
|
|
payload = {
|
|
"site_name": site,
|
|
"season": season,
|
|
"workflow": workflow,
|
|
"records": rows,
|
|
}
|
|
out = vdir / f"gap_phenology_offsets_{workflow}.json"
|
|
out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
if workflow == "bti":
|
|
# Legacy alias for backward-compatible readers.
|
|
(vdir / "gap_phenology_offsets.json").write_text(
|
|
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
return out
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description="Gap fusion TIMESAT offsets vs PhenoCam.")
|
|
ap.add_argument("--data-dir", type=Path, default=Path("data"))
|
|
ap.add_argument("--sites-geojson", type=Path, default=Path("data/sites.geojson"))
|
|
ap.add_argument(
|
|
"--workflow",
|
|
choices=["bti", "itb", "both"],
|
|
default="both",
|
|
help="Fusion workflow(s) (default: both best BtI and best ItB).",
|
|
)
|
|
args = ap.parse_args()
|
|
positions = _site_positions(args.sites_geojson)
|
|
workflows = _resolve_workflows(args.workflow)
|
|
for site, season in sorted(PRIMARY_SEASON.items()):
|
|
pos = positions.get(site)
|
|
if not pos:
|
|
continue
|
|
for workflow in workflows:
|
|
p = write_phenology_offsets(site, season, pos, workflow=workflow)
|
|
print(p)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|