# catalog/operations/analysis/harmonic_fit.yaml
#
# Harmonic-regression phenology. MIGRATED 2026-07-18 (ENGINE_PLAN.md §3 W0.4) to describe the
# ACTUAL engine contract, and re-baselined 2026-07-20 (review round 6, WS-0B) to the durable
# LABELED TENSOR output: the Rust arm (folia-engine harmonic_fit_op ->
# folia_compute_core::harmonic_ops::harmonic_fit_flat) accepts ONE stacked (t,y,x) upstream (the
# scene bridge's `[time,y,x]` tensor OR a plain annual raster) plus a per-scene time axis and
# returns ONE labeled tensor `[band,y,x]` ordered amplitude,phase,[trend],rmse with governed band
# descriptors (unit + linear/circular value space + phase period) — NOT an unlabeled raster, and
# NOT a raster-collection with several separate outputs.
#
# TIME BASIS (ENGINE_PLAN.md §8-H, NORMATIVE): the fit is on DECIMAL YEARS (`year + doy/365.25`)
# with a fundamental period of exactly 1 year. `period` is the phase-REPORTING period in DAYS
# (default 365.25) — it only scales the reported phase into day-of-year units; it is NOT the
# kernel time unit. This matches the Rust/GEE-parallel contract, and the corrected Python oracle
# (registry/recipes/crop-monitoring/examples/reference_impl.py).

id: harmonic_fit
# ADR-0036 D6 prefix-drop: the governed prefixed name resolves to the ONE identity. Existing
# `builtin:analysis_harmonic_regression@1` alias keys converge ONCE onto builtin:harmonic_fit@1
# (an enumerated migration convergence — see tests/harmonic_identity.rs — not a second op).
aliases: [analysis_harmonic_regression]
name: Harmonic Time Series Fitting
description: >
  Fit a harmonic-regression model to a per-pixel temporal stack and emit the first-harmonic
  phenology bands. Model: y(t) = a0 + a1*t + sum_k[cos_k*cos(2*pi*k*t) + sin_k*sin(2*pi*k*t)],
  fit on a DECIMAL-YEAR axis (fundamental period = 1 year). Reports first-harmonic amplitude,
  phase (day-of-year of the seasonal peak, scaled by `period` days), optional linear trend
  (per year), and RMSE. Based on EEFA Book Ch. 18.
version: 2.0.0
# ADR-0065 D4. @0 emitted an unlabeled Raster [band,y,x]; @1 (greenfield re-baseline 2026-07-20,
# review round 6) emits the durable LABELED Data::Tensor [band,y,x] — the public value encoding
# changed (Raster -> Tensor), so the identity MUST move. The fit numerics are unchanged.
op_identity: builtin:harmonic_fit@1
category: analysis
type: raster-to-tensor
default_implementation: native

inputs:
  # ONE stacked (t,y,x) upstream, one band per observation (per-scene): the scene bridge's
  # `[time,y,x]` tensor (imagery_composite_scenes@2) OR a plain annual raster. The per-scene time
  # axis rides `dates`/`times` (below), or is read from a scene-bridge tensor's `time` axis
  # coordinates (WS-0B — the replacement for the WS-0A `layer_scene_axes` side map).
  - name: time_series
    type: tensor|raster
    aliases: [data, collection]
    description: >
      A single (t,y,x) stack — one band per observation date. A scene-bridge `[time,y,x]` tensor
      supplies both the pixel stack AND the per-scene time basis (its `time` coordinates); a plain
      annual raster takes the band-year fallback. NaN observations are excluded per pixel. Sub-annual
      (many scenes per season) stacks REQUIRE a real per-scene time axis (dates/times, or the
      scene-bridge tensor's `time` coordinates); a genuinely-annual stack may fall back to band years.
    required: true

# Structural: harmonic regression on any per-pixel time series. requires: omitted.

outputs:
  # ONE labeled tensor `[band,y,x]`. Band order is CONDITIONAL: amplitude, phase, [trend], rmse —
  # the trend band is present only when fit_trend is true. The band descriptors (unit + linear/
  # circular value space + phase period) travel ON the value, so a downstream consumer
  # (zonal_stats) reads them WITHOUT a positional band_contract (WS-0B retirement).
  - name: phenology
    type: tensor
    format: ftn1
    dtype: float32
    band_order:
      # Conditional order (trend only when fit_trend); each band's value space is declared so a
      # downstream circular-statistic consumer (phase) never applies linear reducers. These ARE
      # the tensor band descriptors emitted on the value.
      - { name: amplitude, value_space: linear, unit: index }
      - { name: phase, value_space: circular, unit: days, period: 365.25 }
      - { name: trend, value_space: linear, unit: index_per_year, conditional: fit_trend }
      - { name: rmse, value_space: linear, unit: index }
    description: >
      First-harmonic phenology bands as a labeled tensor `[band,y,x]`, band-major in order
      amplitude, phase, [trend], rmse. amplitude = |(cos_1, sin_1)|; phase = day-of-year of the
      seasonal peak in [0, period) (a circular band on `period`); trend = linear slope per YEAR
      (only when fit_trend); rmse over the observed values. NaN where a pixel has too few valid
      observations to solve.

params:
  dates:
    type: array
    description: >
      Per-scene acquisition dates (ISO "YYYY-MM-DD"), one per band — converted to decimal years.
      STRONGLY preferred for sub-annual phenology. Mutually exclusive with `times`.
  times:
    type: array
    description: >
      Per-scene time axis as DECIMAL YEARS (e.g. 2023.34), one per band. Mutually exclusive with
      `dates`. Used directly as the fit axis.
  n_harmonics:
    type: integer
    default: 2
    min: 1
    max: 6
    description: >
      Number of harmonic terms. 1 = annual, 2 = + semi-annual, 3 = + tri-annual. More harmonics
      capture finer temporal detail but require more observations.
  fit_trend:
    type: boolean
    default: true
    description: >
      Include a linear trend term (slope per YEAR). Adds a `trend` band. Captures long-term
      monotonic change (greening/browning across years).
  period:
    type: number
    default: 365.25
    description: >
      The phase-REPORTING period in DAYS (default 365.25). It scales the reported phase into
      day-of-year units ONLY; the fit itself is on the 1-year decimal-year fundamental (§8-H).
  start_year:
    type: integer
    default: 2000
    description: >
      Fallback band-year labels used ONLY when no dates/times/scene-axis are supplied (a
      genuinely-annual stack). Inert — and dropped from identity — when a real axis is present.

# ADR-0065 D2 defaults are resolved before hashing: n_harmonics/fit_trend/period normalize their
# defaults OUT (so an omitted and an explicit default converge); start_year normalizes out on the
# no-axis fallback and is dropped entirely when dates/times make it inert. The chosen axis
# (dates/times) stays key material. Enforced by tests/harmonic_identity.rs.

cache_policy:
  ttl_days: 90
  invalidate_on: [source_update]

# backends: audited 2026-08-14 (defect 50). A key means a runtime that DISPATCHES this op —
# folia-engine `dispatch_op` (products/sdk/folia-engine/src/lib.rs), a `registerOp`/OP_TABLE
# entry in packages/compute, `_BUILTIN_OP_MAP` in folia/compute.py, or a backend manifest
# (folia/backends/*/backend.yaml).
backends:
  rust-engine:
    op: harmonic_fit
    function: harmonic_fit_op
    dispatch: products/sdk/folia-engine/src/lib.rs:865
  rust-wasm:
    function: stats_ops::harmonic_fit
    handle_function: h_harmonic_fit
  js:
    function: harmonic_fit
  python:
    function: geo.harmonic.harmonic_fit
    dispatch: folia/compute.py _BUILTIN_OP_MAP

display_hints:
  map:
    renderer: maplibre
    type: raster
    palette: sequential
    colors: ["#ffffcc", "#41b6c4", "#253494"]
    labels: [low, medium, high]
  chart:
    renderer: observable-plot
    chartType: line
    description: >
      Overlay original observations with the fitted harmonic curve to visualize goodness of fit.

ui:
  icon: activity
  color: "#1B5E20"
