# Crop-monitoring — governed folia/0.1 recipe (ENGINE_PLAN §3 WS-4, ADR-0078).
#
# THE AUTHORED GOVERNED ARTIFACT. Per-field crop vigor + phenology from a Sentinel-2 NDVI time
# series: fit a harmonic model to each pixel's season, aggregate to each field. `folia run` binds
# this document through the bind.rs seam (AuthorRecipe -> BoundRecipe, parameters resolved,
# `{param:}` refs substituted) and executes the projected layer DAG through the SAME engine the
# pinned fixture runs — writing a named GeoParquet per-field table (`.folia/tables/<output>.parquet`).
#
# TWO ARTIFACTS (ENGINE_PLAN §6): this is the parameterized author recipe. The DE-PARAMETERIZED
# PINNED FIXTURE — the hermetic offline gate (`engine.crop-monitoring-table-e2e`) — lives in
# `products/sdk/folia-engine/tests/crop_monitoring_e2e.rs` (synthetic multi-scene COGs + GeoJSON
# zones, run cold/warm/--locked, matched to the corrected decimal-year oracle). The live Fresno/FTW
# run of THIS recipe is a separately-labelled, non-blocking, network-gated smoke test
# (`examples/reference_impl.py --real`).
#
# METHOD: harmonic (Fourier) time-series model of surface reflectance (Zhu & Woodcock 2014). The
# decimal-year time basis is normative (ENGINE_PLAN §8-H); `harmonic_fit` consumes the per-scene
# acquisition datetimes the `imagery_composite_scenes` bridge pins.

spec_version: folia/0.1
name: crop-monitoring
version: "0.1.0"
description: >
  Per-field crop vigor and phenology from a Sentinel-2 NDVI time series. For an AOI covered by
  field boundaries, compute NDVI for every clear pass, fit a harmonic model to each pixel's season,
  and aggregate the phenology to each field: how vigorous the crop is (annual amplitude), when it
  peaks (phase, day-of-year of maximum greenness), and its multi-year trend. The per-field table is
  the crop-monitoring product; the NDVI stack and amplitude/phase rasters are the "show your work"
  layers.

parameters:
  aoi_bbox:
    type: array
    items: number
    description: The AOI bounding box [w, s, e, n] in WGS84 lon/lat.
    default: [-119.90, 36.60, -119.86, 36.63]
    min_items: 4
    max_items: 4
  date_start:
    type: string
    description: Time-series start (>=2 full growing seasons for a stable harmonic fit).
    default: "2023-01-01"
  date_end:
    type: string
    description: Time-series end.
    default: "2024-12-31"
  fields_uri:
    type: string
    description: The field-boundary vector the NDVI phenology is aggregated into (GeoJSON/FTW).
    default: "local://fields.geojson"
  n_harmonics:
    type: number
    description: Harmonics in the seasonal model (2 = annual + semi-annual / double-cropping).
    default: 2
    minimum: 1
  fit_trend:
    type: boolean
    description: Fit a multi-year greening/browning trend per pixel.
    default: true
  period:
    type: number
    description: The harmonic reporting period in days (phase is reported as day-of-year).
    default: 365.25
    minimum: 1
  scene_limit:
    type: number
    description: Max scenes to fetch over the AOI + window.
    default: 24
    minimum: 1

layers:

  # ── SOURCE: per-scene NDVI stack (the WS-0A imagery_composite_scenes bridge). ──
  # `params.scenes: true` selects the per-scene op (one NDVI band per acquisition), NOT the annual
  # composite — the stack `harmonic_fit` fits its season on. The `scenes` selector is consumed at
  # desugar time and never enters the op's effective params (ENGINE_PLAN §W0.1).
  compute/ndvi:
    type: tensor
    description: >
      Every clear S2 L2A pass over the AOI as one NDVI band per acquisition date — the scrubable
      greenness `[time,y,x]` tensor and the input to the harmonic fit (WS-0B: the scene bridge emits
      a labeled tensor, the acquisition instants ride its `time` axis).
    ref: "@esa/sentinel-2/l2a"
    params:
      scenes: true
      bands: [N, R]
      bbox: { param: aoi_bbox }
      temporal: [{ param: date_start }, { param: date_end }]
      limit: { param: scene_limit }

  # ── COMPUTE: per-pixel seasonal decomposition. ──
  compute/phenology:
    type: tensor
    description: >
      Per-pixel harmonic fit of the NDVI stack: amplitude (annual greenness swing = crop vigor),
      phase (day-of-year of peak greenness = crop calendar), trend (multi-year greening/browning),
      and rmse. Fit on the decimal-year basis (ENGINE_PLAN §8-H). WS-0B: emitted as a labeled
      `[band,y,x]` tensor whose descriptors carry the band names + linear/circular value spaces.
    compute:
      op: harmonic_fit
      inputs:
        time_series: { layer: compute/ndvi }
      params:
        n_harmonics: { param: n_harmonics }
        fit_trend: { param: fit_trend }
        period: { param: period }

  # ── SOURCE: the field boundaries (a `type: vector` GeoJSON/FTW layer). ──
  # The sanctioned source spelling (ADR-0074 D3): a data pointer, plus the read that consumes it.
  # `mechanism:` names the read outright instead of leaning on the pointer's suffix, because the
  # pointer is a PARAMETER — its bound value need not end in `.geojson`, and a source layer with
  # neither `params:` nor `mechanism:` is not a source read at all (LAYERS.md#source-spelling).
  fields:
    type: vector
    description: >
      The zones NDVI phenology is aggregated into. Any polygon set works (FTW, parcels, Overture);
      FTW is the canonical global default. A local GeoJSON is the runnable offline stand-in.
    uri: { param: fields_uri }
    mechanism: local-file

  # ── COMPUTE: the crop-monitoring product (one row per field). ──
  compute/field_stats:
    type: vector
    description: >
      One row per field with the mean/min/max amplitude, circular mean/std phase, mean trend, and
      mean rmse inside the field polygon. zonal_stats over the phenology TENSOR keyed by the fields.
      The band NAMES + linear/circular value spaces come from the phenology tensor's OWN descriptors
      (WS-0B — no positional band_contract): the `stats` map requests per-band statistics keyed by
      those names, and phase's circular accumulator + period ride the value, so a linear mean can no
      longer be applied by mistake near the 0/365 day-of-year wrap.
    compute:
      op: zonal_stats
      inputs:
        raster: { layer: compute/phenology }
        zones: { layer: fields }
      params:
        stats:
          amplitude: [mean, min, max]
          phase: [mean, std]
          trend: [mean]
          rmse: [mean]

outputs:
  field_stats:
    layer: compute/field_stats
    value_kind: vector
    description: Per-field crop vigor + phenology — THE product (written as GeoParquet).
  ndvi_stack:
    layer: compute/ndvi
    value_kind: tensor
    description: The NDVI per-scene tensor `[time,y,x]` behind the fit (show-your-work).
  phenology:
    layer: compute/phenology
    value_kind: tensor
    description: The fitted amplitude/phase/trend/rmse phenology tensor `[band,y,x]` (show-your-work).

# Non-executable metadata (excluded from the BoundRecipe projection) rides `extensions`. Extension
# keys are namespaced `owner/name` (FRC schema); Folia catalog metadata lives under `folia/*`.
extensions:
  folia/category: time-series-trends
  folia/themes: [agriculture, imagery]
  folia/family: crop-phenology-monitoring
  folia/cites_papers: ["zhu-2014"]
  folia/license:
    recipe: Apache-2.0
    output_dataset: CC-BY-4.0
    upstream_data:
      - { source: sentinel-2-l2a, license: usgs-public-domain }
  # White-space provenance: commons/graph/WHITESPACE.md (crop-monitoring, 53 companies). Related:
  # index-timeseries (the source->index skeleton this extends); snow-cover-fsc (cryosphere sibling).
