name: crop-monitoring-sar-fused
version: "0.1"
status: draft                          # compute graph VALIDATED synthetically via
                                       # examples/reference_impl.py; engine-wiring
                                       # pending. See PROVENANCE.
category: time-series-trends
themes: [agriculture, imagery, sar]
family: crop-phenology-monitoring
cites_papers: ["veloso-2017"]          # Veloso et al. 2017 — Sentinel-1 SAR backscatter
                                       # as proxy for crop biomass / growth stage;
                                       # the empirical grounding for VH/VV ratio in
                                       # crop monitoring.
description: >
  Cloud-gap-resilient crop monitoring by fusing Sentinel-1 SAR (VV/VH) with
  the Sentinel-2 NDVI time series. SAR backscatter is weather-independent:
  where optical scenes are cloud-contaminated, S1 VH temporal features (mean,
  std, p10/p90) and the VH/VV ratio carry canopy-structure and canopy-moisture
  signal that tracks crop growth stages independently of cloud cover.

  The fusion is honest gap-filling: on dates where S2 passes a cloud gate the
  NDVI observation anchors the harmonic_fit; on cloudy dates the SAR-derived
  canopy proxy fills the temporal slot so the season is not truncated. Per-field
  zonal_stats then aggregate the fused phenology signal to one row per field —
  the same crop-monitoring product table as the optical-only recipe, but with
  continuous temporal coverage through cloudy periods (Central Valley foggiest
  months: November–February).

  This is the reliability upgrade that commercial crop-monitoring products use
  (Indigo Ag, Granular, Proagrica). The SAR leg does not require a trained
  model — VH mean and VH/VV ratio are direct empirical proxies (Veloso 2017).
  The recipe deliberately keeps the fusion simple enough to inspect: every input
  to every merged date is recorded in the provenance block.

# ─────────────────────────────────────────────────────────────────────────────
# PROVENANCE (why this recipe exists)
#   Authored 2026-07-16 to fill the SAR-optical fusion gap identified when the
#   optical-only crop-monitoring recipe (crop-monitoring) was closed: the Fresno
#   AOI enters the "Tule fog" season from November through February, dropping
#   usable S2 acquisitions to near zero. SAR bridging this gap is the exact
#   technique commercial precision-ag platforms use. Folia already has the
#   required ops (sar_temporal_features, sar_log_ratio, harmonic_fit,
#   zonal_stats); the recipe composes them.
#
#   The parent recipe (crop-monitoring) uses the same AOI, field boundaries,
#   and phenology chain — this recipe EXTENDS it by adding the S1 source leg
#   and gap-fill merge step. When the engine wires both recipes, the field_stats
#   output schema is identical so downstream dashboards work unchanged.
#
# STATUS: draft. The op composition follows each op's documented signature
#   (sar_temporal_features.yaml, sar_log_ratio.yaml, harmonic_fit, zonal_stats)
#   but the recipe has NOT been run through the engine. Validate with
#   `folia run` on the example AOI, reconcile any param/shape mismatches,
#   add a validations/ block, then drop status: draft.
# ─────────────────────────────────────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# PARAMETERIZATION
#   settings.s1_orbit_dir:  filter to a single orbit direction (asc/desc) so
#                           look-angle geometry is consistent across scenes.
#   settings.s1_mode:       IW (10 m default) vs EW (40 m ocean); IW for ag.
#   settings.min_s1_scenes: minimum baseline scenes for sar_temporal_features
#                           — below ~10 the std estimate is unreliable.
#   settings.cloud_gap_fill_window_days: cloudy S2 dates within this window of
#                           a valid S1 scene get a SAR-proxy NDVI filling.
#   settings.n_harmonics / fit_trend: the phenology model (same as crop-monitoring).
#   inputs.fields:          field-boundary vector (same FTW default).
# ─────────────────────────────────────────────────────────────────────────────

settings:
  # ── SAR acquisition filter ──────────────────────────────────────────────
  s1_orbit_dir: ascending              # IW acquisitions over California ~ 6am local
  s1_mode: IW                          # Interferometric Wide Swath, 10 m
  s1_polarizations: [VV, VH]
  min_s1_scenes: 10                    # minimum baseline for sar_temporal_features
  cloud_gap_fill_window_days: 6        # S1 repeat cadence; gap-fill only adjacent dates

  # ── S2 cloud gate (same as crop-monitoring) ──────────────────────────────
  max_bad_fraction: 0.30
  bad_scl_classes: [0, 1, 3, 8, 9, 10]
  reflectance_offset: 1000             # >=04.00 BOA DN offset; scale cancels in ND

  # ── Phenology model (same as crop-monitoring) ────────────────────────────
  index:
    slug: ndvi
    expression: "((B08 - reflectance_offset) - (B04 - reflectance_offset)) / ((B08 - reflectance_offset) + (B04 - reflectance_offset))"
    bands: [B04, B08]
    range: [-1, 1]
    colormap: greens

  n_harmonics: 2                       # annual + semi-annual (double-cropping)
  fit_trend: true
  period: 365.25

  field_stats: [mean, std, min, max]   # per-field aggregation of each phenology band

  # ── SAR-proxy calibration ────────────────────────────────────────────────
  # VH mean backscatter maps empirically to NDVI range 0.2–0.8 for C3/C4 crops
  # (Veloso 2017, Table 2). Simple linear rescale from dB to NDVI-range:
  #   ndvi_proxy = (vh_db - vh_min_db) / (vh_max_db - vh_min_db) * (ndvi_hi - ndvi_lo) + ndvi_lo
  sar_proxy_vh_min_db: -25.0           # bare soil / fallow lower bound (IW VH, dB)
  sar_proxy_vh_max_db: -8.0            # dense canopy upper bound
  sar_proxy_ndvi_lo:  0.15
  sar_proxy_ndvi_hi:  0.80

inputs:
  aoi:
    name: San Joaquin Valley cropland block — Fresno County
    bbox: [-120.05, 36.55, -119.85, 36.72]
    crs_target: EPSG:32611
    mgrs_tile: "11SKB"
    notes: >
      Same AOI as crop-monitoring. Fresno County enters a persistent Tule fog
      season November–February, clearing usable S2 scenes to near zero. S1
      IW ascending passes cover this tile ~06:00 local time, weather-independent.

  fields:
    name: Fields of the World — field boundaries (AOI subset)
    uri: "@ftw/global-field-boundaries"
    confidence_attr: confidence
    geometry: registry/recipes/crop-monitoring/examples/fresno-fields.geojson

  date_range:
    start: "2023-01-01"
    end:   "2026-06-12"

  collection_s2:
    uri: "@esa/sentinel-2/l2a"
    assets: [B04, B08, SCL]

  collection_s1:
    uri: "@esa/sentinel-1/grd"
    stac_endpoint: "https://planetarycomputer.microsoft.com/api/stac/v1"
    assets: [VV, VH]
    filter:
      platform: sentinel-1             # accepts 1a/1b/1c
      sat:orbit_state: "${settings.s1_orbit_dir}"
      s1:instrument_mode: "${settings.s1_mode}"

layers:

  # ── SOURCE — OPTICAL ─────────────────────────────────────────────────────

  source/s2:
    type: raster_timeseries
    uri: "@esa/sentinel-2/l2a"
    description: >
      Every S2 L2A pass over the AOI, cloud-gated at the AOI level before
      NDVI bands are read. Cloud-contaminated dates become NaN gaps in the
      NDVI stack — those are the slots the SAR leg fills.
    params:
      aoi: { bbox: "${inputs.aoi.bbox}" }
      datetime: "${inputs.date_range.start}/${inputs.date_range.end}"
      assets: "${inputs.collection_s2.assets}"
      dedup_by: acquisition_date
      aoi_cloud_gate:
        scl_asset: SCL
        bad_classes: "${settings.bad_scl_classes}"
        max_bad_fraction: "${settings.max_bad_fraction}"

  # ── SOURCE — SAR ─────────────────────────────────────────────────────────

  source/s1:
    type: raster_timeseries
    uri: "@esa/sentinel-1/grd"
    description: >
      Sentinel-1 GRD IW ascending-pass acquisitions over the AOI for the
      full date range. ~6-day repeat cadence. VV + VH polarizations.
      All scenes are on the same relative orbit (ascending, same look angle)
      to make temporal comparisons valid.
    params:
      aoi: { bbox: "${inputs.aoi.bbox}" }
      datetime: "${inputs.date_range.start}/${inputs.date_range.end}"
      assets: "${inputs.collection_s1.assets}"
      dedup_by: acquisition_date
      query:
        "sat:orbit_state": { eq: "${settings.s1_orbit_dir}" }
        "s1:instrument_mode": { eq: "${settings.s1_mode}" }

  # ── COMPUTE — OPTICAL ────────────────────────────────────────────────────

  compute/ndvi:
    type: raster_timeseries
    description: >
      NDVI per cloud-clear S2 scene. This is the primary optical time series;
      gaps (cloudy dates) are represented as NaN. The harmonic fit will be
      dominated by these observations where available.
    compute:
      op: raster_calc
      inputs:
        data: { layer: source/s2 }
      params:
        expression: "${settings.index.expression}"
        reflectance_offset: "${settings.reflectance_offset}"
        output_band: ndvi

  # ── COMPUTE — SAR FEATURES ───────────────────────────────────────────────

  compute/s1-db:
    type: raster_timeseries
    description: >
      VH and VV backscatter converted to dB scale (10*log10(linear)).
      Linear SAR amplitude from MPC sentinel-1-grd COGs; dB scale is
      the standard for both sar_temporal_features and sar_log_ratio inputs.
    compute:
      op: raster_calc
      inputs:
        data: { layer: source/s1 }
      params:
        expression: "10.0 * log10(VH + 1e-10)"
        output_band: vh_db
        extra_bands:
          vv_db: "10.0 * log10(VV + 1e-10)"

  compute/s1-temporal:
    type: raster
    description: >
      SAR temporal baseline features over the full date range: mean, std, p10,
      p90 of the VH dB stack. Four-band COG. The mean is the crop-state signal;
      std tracks seasonal variability (high std = vigorous growing season cycle);
      p10/p90 bracket the bare-soil/peak-canopy range.
    compute:
      op: sar_temporal_features
      inputs:
        stack: { layer: compute/s1-db }
      params: {}

  compute/s1-vhvv-ratio:
    type: raster_timeseries
    description: >
      VH/VV ratio per S1 acquisition (dB difference = VH_dB - VV_dB).
      This cross-polarization ratio is the empirical Veloso 2017 crop proxy:
      dense canopy depolarizes, raising VH relative to VV. Range: typically
      -10 to 0 dB for agricultural scenes.
    compute:
      op: raster_calc
      inputs:
        data: { layer: compute/s1-db }
      params:
        expression: "vh_db - vv_db"
        output_band: vhvv_ratio_db

  # ── COMPUTE — SAR NDVI PROXY ─────────────────────────────────────────────

  compute/s1-ndvi-proxy:
    type: raster_timeseries
    description: >
      VH mean backscatter rescaled to an NDVI-range proxy [0.15, 0.80].
      This is NOT a predicted NDVI — it is a direct linear rescaling of
      the empirical VH dB range for C3/C4 crops (Veloso 2017, Table 2).
      It is labelled "proxy" in the provenance block and plotted with a
      dashed line in the dashboard to distinguish it from observed NDVI.
      The rescaling constants live in settings so they can be tuned per
      crop type or region.
    compute:
      op: raster_calc
      inputs:
        data: { layer: compute/s1-db }
      params:
        expression: >
          clamp(
            (vh_db - sar_proxy_vh_min_db) / (sar_proxy_vh_max_db - sar_proxy_vh_min_db)
            * (sar_proxy_ndvi_hi - sar_proxy_ndvi_lo) + sar_proxy_ndvi_lo,
            sar_proxy_ndvi_lo, sar_proxy_ndvi_hi
          )
        sar_proxy_vh_min_db: "${settings.sar_proxy_vh_min_db}"
        sar_proxy_vh_max_db: "${settings.sar_proxy_vh_max_db}"
        sar_proxy_ndvi_lo: "${settings.sar_proxy_ndvi_lo}"
        sar_proxy_ndvi_hi: "${settings.sar_proxy_ndvi_hi}"
        output_band: ndvi_proxy_sar

  # ── COMPUTE — FUSED TIME SERIES ─────────────────────────────────────────

  compute/ndvi-fused:
    type: raster_timeseries
    description: >
      Gap-filled NDVI stack. On dates where S2 is clear, the observed NDVI
      is used unchanged. On dates where S2 is cloudy (NaN gap) and an S1
      acquisition exists within ±settings.cloud_gap_fill_window_days, the
      SAR NDVI proxy fills the slot. The source of each date (s2_obs /
      sar_proxy / no_data) is written to the provenance band.
      This is the temporal_reduce "fill" pattern: primary=compute/ndvi,
      fill=compute/s1-ndvi-proxy, fill_window_days=6.
    compute:
      op: temporal_reduce
      inputs:
        primary:   { layer: compute/ndvi }
        fill:      { layer: compute/s1-ndvi-proxy }
      params:
        reducer: fill_gaps
        fill_window_days: "${settings.cloud_gap_fill_window_days}"
        output_band: ndvi_fused
        provenance_band: fill_source    # 0=s2_obs, 1=sar_proxy, 2=no_data

  # ── COMPUTE — PHENOLOGY ON FUSED SERIES ─────────────────────────────────

  compute/phenology:
    type: raster
    description: >
      Per-pixel seasonal decomposition of the fused NDVI stack. harmonic_fit
      on the gap-filled series produces amplitude, phase, and trend bands
      that are no longer biased toward the cloud-free season (summer). The
      fused stack includes winter/spring acquisitions from SAR proxy, giving
      the harmonic fit the full crop calendar — critical for double-cropping
      detection and off-season cover-crop monitoring.
    compute:
      op: harmonic_fit
      inputs:
        collection: { layer: compute/ndvi-fused }
      params:
        n_harmonics: "${settings.n_harmonics}"
        fit_trend: "${settings.fit_trend}"
        period: "${settings.period}"

  compute/field_stats:
    type: table
    description: >
      The crop-monitoring product: one row per field with mean/std/min/max of
      amplitude, phase, and trend from the fused phenology. Schema-compatible
      with the optical-only crop-monitoring recipe — the same dashboards and
      exports work unchanged. The "data_source_fraction" column (share of dates
      filled by SAR proxy vs S2) is added as a quality indicator.
    compute:
      op: zonal_stats
      inputs:
        raster: { layer: compute/phenology }
        zones:  { feature: "${inputs.fields.geometry}" }
      params:
        stats: "${settings.field_stats}"
        extra_stats:
          - { band: fill_source, stat: fraction_sar_proxy, value: 1 }

outputs:
  primary:
    - compute/field_stats             # geojson — per-field fused phenology (THE product)
  show_your_work:
    - compute/ndvi                    # zarr (t,y,x) — optical NDVI only (gaps visible)
    - compute/s1-ndvi-proxy           # zarr (t,y,x) — SAR NDVI proxy (every S1 date)
    - compute/ndvi-fused              # zarr (t,y,x) — merged gap-filled series
    - compute/phenology               # cog (amplitude, phase, trend) — fitted season
    - compute/s1-temporal             # cog (mean, std, p10, p90) — SAR baseline
  provenance:
    - source_scene_ids                # STAC item ids for both S2 and S1 timeseries

# ── DASHBOARD ───────────────────────────────────────────────────────────────
dashboard:
  primary_layer:
    name: "Fused crop vigor (SAR + S2 amplitude)"
    colormap: YlGn                    # pale-yellow (fallow) to dark-green (vigorous)
    stat: mean_amplitude              # per-field mean seasonal amplitude from harmonic_fit
    unit: "NDVI amplitude (0-1)"
  patterns:
    - slug: time-scrub-availability
      slot: temporal
      layer: compute/ndvi-fused
      notes: >
        The fused time series scrubber — shows both the optical NDVI (solid line)
        and the SAR proxy fills (dashed, labeled). The cloud-gap periods become
        visible as transitions from solid to dashed.
    - slug: pixel-trajectory-climatology
      slot: temporal
      layer: compute/ndvi-fused
      notes: >
        Click a field pixel to see its full fused season with the harmonic fit.
        The provenance band colors each observation: blue = S2, orange = SAR proxy.
    - slug: choropleth-ranked
      slot: recipe-manipulation
      layer: compute/field_stats
      notes: Fields colored by amplitude; table ranks by vigor descending.
    - slug: legend-as-histogram
      slot: recipe-manipulation
      layer: compute/field_stats

license:
  recipe: Apache-2.0
  output_dataset: CC-BY-4.0
  upstream_data:
    - { source: sentinel-2-l2a, license: usgs-public-domain }
    - { source: sentinel-1-grd, license: copernicus-open }

# Related: crop-monitoring (optical-only parent recipe — same AOI, same phenology
#   chain, no SAR); pakistan-floods-2022 (the other SAR recipe using sar_log_ratio
#   and sar_temporal_features). White-space provenance: commercial precision-ag
#   products (Indigo Ag, Granular, Proagrica) all use SAR-optical fusion for
#   cloud-gap resilience — this recipe makes the technique open and inspectable.
