efast-phenocam-validation/gap_validation/phenology_offsets.py
Felix Delattre bfd5d73dff foo
2026-05-31 15:54:05 +02:00

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