control_nsidc.py

← Back to workflow

Controller for NSIDC daily and monthly Cloud Run jobs.


"""
control_nsidc.py

Controller for NSIDC Sea Ice Concentration CDR processing.

Reads config.yaml and routes to the daily or monthly worker based on MODE.

MODE=daily (default):
    - If START_DATE/END_DATE are set: processes that exact date range.
    - Otherwise: scrapes NSIDC for the latest year, processes all files
      missing from GCS (lookback_days buffer applied).

MODE=monthly:
    - If START_DATE/END_DATE are set: processes all months in that range,
      bypassing the guardrail.
    - Otherwise (Smart Discovery): finds the latest monthly file in GCS
      per pole, targets the next month, applies the GCS guardrail.

Optional env var overrides:
    START_DATE=YYYY-MM-DD
    END_DATE=YYYY-MM-DD
    GCS_BUCKET=...          overrides gcs.bucket in config
    LOG_LEVEL=...           overrides logging.level in config
    MODE=daily|monthly      selects processing stream
"""

import datetime as dt
import logging
import os
import shutil
import sys
from pathlib import Path

import yaml

from make_nsidc_daily_v6 import process_daily, get_latest_year_dates
from make_nsidc_monthly_v6 import process_monthly, get_smart_discovery_months

# ── Config ────────────────────────────────────────────────────────────────────
CONFIG_PATH = Path("/app/config/config.yaml")


def load_config(path: Path = CONFIG_PATH) -> dict:
    """Load the NSIDC workflow YAML configuration file."""
    with open(path) as f:
        return yaml.safe_load(f)


def setup_logging(cfg: dict) -> None:
    """Configure root logging from config, with optional LOG_LEVEL override."""
    log_cfg   = cfg["logging"]
    level_str = os.environ.get("LOG_LEVEL", log_cfg["level"])
    level     = getattr(logging, level_str.upper(), logging.INFO)
    logging.basicConfig(level=level, format=log_cfg["format"])


# ── Date helpers ──────────────────────────────────────────────────────────────

def _parse_date_env(var: str) -> dt.date | None:
    """Parse an optional YYYY-MM-DD environment variable into a date."""
    log = logging.getLogger(__name__)
    raw = os.environ.get(var, "").strip()
    if not raw:
        return None
    try:
        return dt.datetime.strptime(raw, "%Y-%m-%d").date()
    except ValueError:
        log.error("Invalid %s=%r — expected YYYY-MM-DD, ignoring.", var, raw)
        return None


def build_date_range_daily(cfg: dict) -> list[str]:
    """Return YYYYMMDD strings for the configured window, or [] to trigger latest-mode."""
    start = _parse_date_env("START_DATE")
    end   = _parse_date_env("END_DATE")
    if start and end:
        if start > end:
            raise ValueError(f"START_DATE ({start}) is after END_DATE ({end})")
        days = (end - start).days + 1
        return [(start + dt.timedelta(days=i)).strftime("%Y%m%d") for i in range(days)]
    elif start or end:
        raise ValueError("Provide both START_DATE and END_DATE, or neither.")
    # Fall back to lookback window
    lookback = cfg["processing"]["lookback_days"]
    today    = dt.date.today()
    return [
        (today - dt.timedelta(days=i)).strftime("%Y%m%d")
        for i in range(lookback + 1)
    ]


def build_month_range(cfg: dict) -> tuple[list[dt.date], bool]:
    """
    Return (target_months, skip_guardrail).
    skip_guardrail=True when explicit dates are provided (manual backfill).
    """
    start = _parse_date_env("START_DATE")
    end   = _parse_date_env("END_DATE")
    if start and end:
        months = []
        curr = start.replace(day=1)
        while curr <= end.replace(day=1):
            months.append(curr)
            y, m = (curr.year + 1, 1) if curr.month == 12 else (curr.year, curr.month + 1)
            curr = dt.date(y, m, 1)
        return months, True   # bypass guardrail for explicit backfills
    return [], False          # signal: use Smart Discovery


# ── Entry point ───────────────────────────────────────────────────────────────

def main() -> None:
    """Route daily or monthly NSIDC processing based on runtime MODE."""
    cfg = load_config()
    setup_logging(cfg)
    log = logging.getLogger(__name__)

    mode = os.environ.get("MODE", "daily").strip().lower()
    if mode not in {"daily", "monthly"}:
        log.error("Unsupported MODE=%r. Use 'daily' or 'monthly'.", mode)
        sys.exit(1)

    # Allow GCS bucket override at runtime without rebuilding the image
    if os.environ.get("GCS_BUCKET"):
        cfg["gcs"]["bucket"] = os.environ["GCS_BUCKET"]

    log.info("NSIDC CDR controller started (MODE=%s)", mode)

    work_dir = Path(cfg["work"]["dir"])
    work_dir.mkdir(parents=True, exist_ok=True)
    errors: list[str] = []

    try:
        if mode == "daily":
            date_strs = build_date_range_daily(cfg)
            log.info("Daily mode: %d date(s) to check (%s → %s)", len(date_strs), date_strs[0], date_strs[-1])
            errors = process_daily(date_strs=date_strs, cfg=cfg, work_dir=work_dir)

        else:  # monthly
            target_months, skip_guardrail = build_month_range(cfg)
            if not target_months:
                log.info("Monthly mode: running Smart Discovery")
                target_months = get_smart_discovery_months(cfg)
            else:
                log.info(
                    "Monthly mode: explicit range %s → %s (guardrail bypassed)",
                    target_months[0], target_months[-1],
                )
            if not target_months:
                log.info("No monthly targets identified — nothing to do.")
            else:
                errors = process_monthly(
                    target_months=target_months,
                    cfg=cfg,
                    work_dir=work_dir,
                    skip_guardrail=skip_guardrail,
                )

    finally:
        try:
            shutil.rmtree(work_dir, ignore_errors=True)
            log.info("Removed work directory: %s", work_dir)
        except Exception as exc:
            log.warning("Could not remove %s: %s", work_dir, exc)

    if errors:
        log.error("Completed with %d error(s):\n  %s", len(errors), "\n  ".join(errors))
        sys.exit(1)

    log.info("NSIDC CDR processing complete (MODE=%s).", mode)
    sys.exit(0)


if __name__ == "__main__":
    main()