added gap validation.

This commit is contained in:
Felix Delattre 2026-05-17 15:55:15 +02:00
parent 374be6865d
commit 740249115b
12 changed files with 997 additions and 116 deletions

View file

@ -9,7 +9,13 @@ import sys
from datetime import datetime
from pathlib import Path
from gap_validation.calendar import load_manifest, validation_dir, write_manifest
from gap_validation.calendar import (
DEFAULT_GAP_LENGTHS,
TRANSITIONS,
load_manifest,
validation_dir,
write_manifest,
)
from gap_validation.fusion_masked import (
production_fusion_path,
run_masked_fusion_one_date,
@ -65,6 +71,19 @@ def _git_rev() -> str | None:
return None
def _filter_entries(
entries: list[dict],
gap_days_filter: list[int] | None,
transition_filter: list[str] | None,
) -> list[dict]:
out = entries
if gap_days_filter:
out = [e for e in out if e.get("gap_days") in gap_days_filter]
if transition_filter:
out = [e for e in out if e.get("transition") in transition_filter]
return out
def run_validation(
site_name: str,
season: int,
@ -77,7 +96,10 @@ def run_validation(
skip_fusion: bool,
write_manifest_only: bool,
gap_days_filter: list[int] | None,
transition_filter: list[str] | None,
s2_calendar_strategy: str,
manifest_gap_lengths: tuple[int, ...] = DEFAULT_GAP_LENGTHS,
manifest_transitions: tuple[str, ...] = TRANSITIONS,
) -> Path:
base = Path(f"data/{site_name}/{season}")
vdir = validation_dir(site_name, season)
@ -85,24 +107,31 @@ def run_validation(
if not skip_manifest:
write_manifest(
site_name, season, site_position, s2_calendar_strategy=s2_calendar_strategy
site_name,
season,
site_position,
s2_calendar_strategy=s2_calendar_strategy,
gap_lengths=manifest_gap_lengths,
transitions=manifest_transitions,
)
if write_manifest_only:
return vdir / "gap_manifest.json"
manifest = load_manifest(site_name, season)
entries = manifest["entries"]
if gap_days_filter:
entries = [e for e in entries if e.get("gap_days") in gap_days_filter]
entries = _filter_entries(manifest["entries"], gap_days_filter, transition_filter)
results: list[dict] = []
for entry in entries:
gap_days = entry["gap_days"]
transition = entry.get("transition", "green_up")
pred = entry["prediction_date"]
w0 = entry["window_start"]
w1 = entry["window_end"]
fn = entry.get("withheld_s2_filename")
if not fn:
results.append(
{
"transition": transition,
"gap_days": gap_days,
"error": "no_withheld_s2_filename",
"entry": entry,
@ -114,6 +143,7 @@ def run_validation(
if not wh_ymd:
results.append(
{
"transition": transition,
"gap_days": gap_days,
"error": "could_not_parse_withheld_yyyymmdd",
"withheld_s2_filename": fn,
@ -125,20 +155,33 @@ def run_validation(
)
fusion_out = validation_fusion_dir(
site_name, season, gap_days, strategy, sigma, mode
site_name, season, gap_days, transition, strategy, sigma, mode
)
if not skip_fusion:
run_masked_fusion_one_date(
season,
site_position,
site_name,
strategy,
sigma,
mode,
pred,
wh_ymd,
fusion_out,
)
try:
run_masked_fusion_one_date(
season,
site_position,
site_name,
strategy,
sigma,
mode,
pred,
w0,
w1,
wh_ymd,
fusion_out,
)
except RuntimeError as e:
results.append(
{
"transition": transition,
"gap_days": gap_days,
"error": str(e),
"entry": entry,
}
)
continue
fused_gap = _fused_file(fusion_out, mode, ymd)
prod = production_fusion_path(season, site_name, strategy, sigma, mode, ymd)
@ -146,6 +189,7 @@ def run_validation(
if wh_path is None or not fused_gap.is_file():
results.append(
{
"transition": transition,
"gap_days": gap_days,
"prediction_date": pred,
"withheld_s2_filename": fn,
@ -165,14 +209,17 @@ def run_validation(
fused_gap,
prod if prod.is_file() else None,
mode,
whittaker_context=(base, strategy, pred, withheld_iso),
whittaker_context=(base, strategy, pred, withheld_iso, w0, w1),
)
fusion_nse = (spatial.get("gap") or {}).get("nse_s2")
wh_nse = (spatial.get("whittaker") or {}).get("nse_s2")
results.append(
{
"transition": transition,
"gap_days": gap_days,
"prediction_date": pred,
"window_start": w0,
"window_end": w1,
"withheld_s2_filename": fn,
"scenario": {
"strategy": strategy,
@ -186,6 +233,7 @@ def run_validation(
},
"spatial": spatial,
"whittaker_crossover_row": {
"transition": transition,
"gap_days": gap_days,
"nse_s2_fusion": fusion_nse,
"nse_s2_whittaker": wh_nse,
@ -206,15 +254,15 @@ def run_validation(
"command_line": sys.argv,
"git_commit": _git_rev(),
"manifest": str(vdir / "gap_manifest.json"),
"gap_withheld_images": str(vdir / "gap_withheld_images.json"),
"results": results,
"whittaker_crossover": {
scenario: {
"metric": "nse_s2_spatial_vs_withheld_s2_gcc",
"whittaker_definition": (
"Whittaker λ=400 d² on cloud-screened S2 GCC from s2_preselection.json; "
"withheld acquisition removed from the fit; prediction is a spatially constant "
"field at the smoothed GCC(prediction_date), compared to withheld S2 GCC on the "
"same valid mask as fusion (aligned with baseline.s2_whittaker_lambda400 spirit)."
"all S2 dates in the gap window and the withheld acquisition removed; "
"prediction is a spatially constant field at smoothed GCC(prediction_date)."
),
"first_gap_days_fusion_nse_below_whittaker": first_gap_where_fusion_below_whittaker(
crossover_rows,
@ -250,6 +298,12 @@ def main() -> None:
metavar="N",
help="Restrict to gap length(s); repeatable (default: all manifest lengths).",
)
ap.add_argument(
"--transition",
choices=list(TRANSITIONS),
action="append",
help="Restrict to transition(s); repeatable (default: all in manifest).",
)
ap.add_argument("--skip-manifest", action="store_true")
ap.add_argument(
"--skip-fusion",
@ -259,7 +313,7 @@ def main() -> None:
ap.add_argument(
"--write-manifest-only",
action="store_true",
help="Write gap_manifest.json and exit (no EFAST).",
help="Write gap_manifest.json + gap_withheld_images.json and exit.",
)
ap.add_argument(
"--s2-calendar-strategy",
@ -270,6 +324,8 @@ def main() -> None:
args = ap.parse_args()
sigma_kw = 30 if args.sigma == 30 else None
site_position = (args.lat, args.lon)
gap_filter = args.gap_days if args.gap_days else None
trans_filter = args.transition if args.transition else None
out = run_validation(
args.site,
args.season,
@ -280,7 +336,8 @@ def main() -> None:
skip_manifest=args.skip_manifest,
skip_fusion=args.skip_fusion,
write_manifest_only=args.write_manifest_only,
gap_days_filter=args.gap_days,
gap_days_filter=gap_filter,
transition_filter=trans_filter,
s2_calendar_strategy=args.s2_calendar_strategy,
)
print(out)