id: cal_val_scorecard
name: Cal/Val Scorecard
description: >
  Compute standard cal/val skill metrics from a match-up table (output of
  `cal_val_match`). Returns per-group records carrying ubRMSE, bias, R
  (Pearson correlation), anomR (anomaly correlation), and n_matchups.

  Metric definitions (per SMAP Cal/Val Plan and Entekhabi et al. 2010):
    bias  = mean(satellite - in_situ)
    RMSE  = sqrt(mean((satellite - in_situ)^2))
    ubRMSE = sqrt(RMSE^2 - bias^2)                       # bias-corrected
    R     = Pearson correlation of (satellite, in_situ)
    anomR = Pearson correlation of seasonal anomalies     # mean climatology removed

  SMAP CVS target: ubRMSE ≤ 0.04 m^3/m^3.

version: 0.1.0
category: evaluation
type: table-to-record
default_implementation: native

inputs:
  - name: matchups
    type: table
    description: >
      Match-up table from `cal_val_match`. Required columns:
      (station_id, in_situ_value, raster_value, provider_class).
      Optional: (in_situ_time, raster_time) for anomaly correlation.
    required: true

requires: {}

outputs:
  - name: scorecard
    type: record
    description: >
      List of records — one per group — each carrying:
      (group_keys..., ubRMSE, bias, R, anomR, n_matchups).
  - name: summary
    type: record
    description: >
      Single-record summary across all groups: total n_matchups, weighted-mean
      ubRMSE, pass/fail against the ubRMSE_target param.

params:
  group_by:
    type: list[string]
    default: ["station_id"]
    description: >
      Column(s) to group by. Common: [station_id], [station_id, provider_class],
      [provider_class], or [] for global single record.
  metrics:
    type: list[string]
    default: ["ubRMSE", "bias", "R", "anomR", "n_matchups"]
    description: >
      Metrics to compute. anomR requires time columns and ≥ 365 daily
      matchups per station; falls back to NaN otherwise.
  ubRMSE_target:
    type: number
    default: 0.04
    description: >
      Target threshold (same units as the variable; m^3/m^3 for SM). Used in
      the summary pass/fail count and pass/fail flag.

uncertainty:
  error_model: bounded
  factors:
    - name: small_sample_metric_variance
      description: >
        ubRMSE / R / anomR from <30 matchups have wide confidence intervals
        not reflected in the point estimate.
      severity: medium
      mitigations:
        - "Drop groups with n_matchups < min_n (default 30)."
        - "Bootstrap CIs (planned follow-up)."
    - name: seasonal_cycle_treatment
      description: >
        anomR depends on the climatology used to compute anomalies (mean,
        moving-window, harmonic). v0 uses a station-specific mean climatology
        of day-of-year; this overestimates skill when climatologies are short.
      severity: medium
  limitations:
    - "Bias variants (multiplicative, percentile-mapped) out of scope in v0."
    - "Uncertainty propagation from upstream `normalize_soil_moisture` / `cal_val_match` not formally tracked."
  methodology_reference: "products/insitu-harmonization/candidates/soil-moisture.md"

# backends: audited 2026-08-14 (defect 50). NOTHING dispatches this op: no arm in folia-engine
# `dispatch_op`, no `registerOp`/OP_TABLE entry in packages/compute, no `_BUILTIN_OP_MAP`
# key in folia/compute.py, no backend manifest. Declared EMPTY on purpose — an absent
# block would be indistinguishable from one nobody ever wrote.
# the declared `folia.domains.temporal.transforms.evaluation.cal_val_scorecard` does not exist and `_BUILTIN_OP_MAP` has no key for it.
backends: {}

display_hints:
  table:
    renderer: tanstack-table
    columns:
      - { field: station_id, label: Station }
      - { field: provider_class, label: Class }
      - { field: ubRMSE, label: "ubRMSE", format: ".3f" }
      - { field: bias, label: Bias, format: ".3f" }
      - { field: R, label: R, format: ".2f" }
      - { field: anomR, label: "anom-R", format: ".2f" }
      - { field: n_matchups, label: N }
    sortBy: ubRMSE
    sortOrder: asc
