name: horizon-shade
version: "0.1"
category: features-objects        # pattern: terrain-analysis → features-objects shelf
themes: [terrain]
family: terrain-corrected-solar-shade
cites_papers: ["steger-2022-horayzon"]   # reproduces HORAYZON horizon/SVF ray-tracing (Steger et al. 2022)
license: CC-BY-4.0
description: >
  Per-pixel × hour × month terrain shade fraction over an AOI from any DEM
  (default @usgs/3dep/10m). HORAYZON-style: at every pixel walk 360 rays out
  to 10 km, take the max horizon angle per azimuth bin, and integrate against
  a NOAA closed-form solar ephemeris over a 15-min-sampled day at the 15th
  of each month. Output is a single multi-band COG (12 months × N daylight
  hours), uint8 % shaded.

  The horizon array is time-invariant — once computed it answers "is the
  sun blocked here?" for any (date, time) by indexing the solar azimuth bin
  and comparing solar elevation to the local horizon angle. The recipe
  produces the per-(hour, month) integrated fraction; downstream consumers
  who need instant-shade at an arbitrary time can sample the COG band that
  matches their target (hour, month) pair, or rerun against a finer time
  grid by overriding `hours` / `samples_per_hour`.

  Validated against the trail-aligned reference at demos/lake-blanche-shade/:
  the recipe-driven raster sampled along the Lake Blanche trail points
  reproduces the published per-point shade fractions (Jun 21 mean within
  ±3 percentage points). See examples/slc-90min.yaml for the SLC instance
  and ../products/explore/shared/derived/horizon-shade/README.md for the
  cross-check record.

# ─────────────────────────────────────────────────────────────────────────────
# OUTPUT STRUCTURE — Decision: (b) single multi-band COG.
#
# Why (b) over (a) 168 separate COGs:
#   - one open + one HTTP HEAD per query, not 168.
#   - shared spatial overviews + tiling scheme; tile fetches a (y, x) window
#     across all (hour, month) bins in one read.
#   - the horizon walk is the expensive step; once we have it, emitting all
#     168 bands in one file is the same compute cost as 168 separate files
#     but ~10× smaller on disk thanks to COG compression of adjacent bands
#     with similar shade values.
#
# Why (b) over (c) per-pixel parquet LUT:
#   - parquet keyed by (pixel_id, hour, month) loses the natural raster
#     viewport — every query becomes a join. The shade COG renders as a
#     map layer directly; the LUT does not.
#   - parquet wins only when downstream consumers want vector aggregations
#     (mean shade over polygon X) more than they want maps. For ramble.earth
#     the map IS the primary surface, so COG.
#
# Band layout: band index = month_idx * len(hours) + hour_idx, both 0-based.
#   month_idx = months.index(month), hour_idx = hours.index(hour).
#   Stored in COG tags: BAND_LAYOUT = "month_idx*len(hours)+hour_idx",
#   MONTHS = "1,2,...,12", HOURS = "6,7,...,19".
# ─────────────────────────────────────────────────────────────────────────────

settings:
  # Horizon walk
  max_distance_m: 10000            # 10 km — captures far ridges; beyond
                                    # this rarely adds anything in mountain
                                    # AOIs. Reduce for prairie / lowland AOIs.
  ray_step_m: 10                   # match DEM resolution. At 10 m the walk
                                    # is 1000 steps per ray; at 30 m bump to
                                    # 30 to keep the same far-field reach.
  n_az: 360                        # 1° azimuth bins. Coarser = faster but
                                    # the per-bin sun-blocked answer
                                    # quantizes at the same resolution.

  # Time grid
  hours: [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]  # 14 daylight
                                                                # hours, local
  months: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]              # all 12
  samples_per_hour: 4              # 15-min sub-hour samples averaged into
                                    # each (hour, month) bin. Smooths the
                                    # sun-crosses-ridge step transition.
  ephemeris_year: 2026             # NOAA closed-form is year-stable to <0.05°
                                    # for solar position; pick any non-leap year.
  refraction_deg: 0.55             # atmospheric refraction correction at the
                                    # horizon — sun appears slightly higher
                                    # than its geometric position.

  # Output
  output_dtype: uint8              # shade fraction × 100, [0, 100], nodata 255.
  output_nodata: 255

inputs:
  aoi:
    name: SLC 90-minute drive envelope
    feature: null                    # examples override this
    geometry: null                   # or this
    bbox: [-113.0, 40.0, -111.0, 41.5]   # fallback when feature/geometry absent
    crs_target: EPSG:32612             # UTM 12N

  dem:
    uri: "@usgs/3dep/10m"            # CONUS only. Non-US AOIs swap to
                                      # @esa/copernicus-dem/glo-30.
    asset: data
    utc_offset_hours: -7             # for solar-local-time bin labels.
                                      # MST year-round in Utah (no DST).

layers:

  # ── SOURCE ──────────────────────────────────────────────────────────────────

  source/dem:
    type: raster
    uri: "${inputs.dem.uri}"
    description: >
      DEM tiles intersecting the AOI bbox, mosaicked then reprojected to
      `crs_target` at native resolution. Pads the AOI by `max_distance_m`
      so horizon rays don't run off the edge.
    params:
      aoi: { feature: "${inputs.aoi.feature}", geometry: "${inputs.aoi.geometry}", bbox: "${inputs.aoi.bbox}" }
      pad_m: "${settings.max_distance_m}"
      target_crs: "${inputs.aoi.crs_target}"
      asset: "${inputs.dem.asset}"

  # ── COMPUTE ─────────────────────────────────────────────────────────────────

  compute/horizon-grid:
    type: raster
    description: >
      Per-pixel 360° horizon angles. For every DEM pixel, walk `n_az` rays
      out to `max_distance_m` in `ray_step_m` increments, log the maximum
      angle subtended by terrain along each ray. Time-invariant. Returned as
      `(H, W, n_az)` float32. This is the show-your-work cache — every
      shade answer reduces to "is the sun's az/el above horizon[az]?".
    compute:
      op: terrain_horizon_grid
      inputs:
        dem: { layer: source/dem }
      params:
        max_distance_m: "${settings.max_distance_m}"
        ray_step_m: "${settings.ray_step_m}"
        n_az: "${settings.n_az}"

  compute/sky-view-factor:
    type: raster
    description: >
      Diffuse-sky openness per pixel from the horizon grid: SVF = mean over
      azimuth of (1 − sin h_az). 1.0 = open sky, 0.0 = fully enclosed.
      Used downstream for clear-sky diffuse irradiance and as a single-band
      "how much sky does this pixel see?" companion to the time-resolved
      shade COG.
    compute:
      op: terrain_sky_view_factor
      inputs:
        horizons: { layer: compute/horizon-grid }

  # ── RESULT ──────────────────────────────────────────────────────────────────

  result/shade-fraction:
    type: raster
    description: >
      Per-pixel × hour × month mean shade fraction. For each (month, hour)
      bin: sample `samples_per_hour` sub-hour solar positions at the 15th
      of the month, evaluate against each pixel's horizon profile, and
      average. Output is a single multi-band COG, `len(months) × len(hours)`
      bands (default 168 = 12 × 14), uint8 % shaded with nodata 255. Band
      layout in COG tags.
    compute:
      op: solar_shade_fraction_hour_month
      inputs:
        horizons: { layer: compute/horizon-grid }
      params:
        hours: "${settings.hours}"
        months: "${settings.months}"
        samples_per_hour: "${settings.samples_per_hour}"
        utc_offset_hours: "${inputs.dem.utc_offset_hours}"
        year: "${settings.ephemeris_year}"
        refraction_deg: "${settings.refraction_deg}"
        output_dtype: "${settings.output_dtype}"
        output_nodata: "${settings.output_nodata}"

outputs:
  primary:
    - result/shade-fraction          # multi-band COG, the headline artifact
    - compute/sky-view-factor        # single-band COG, diffuse-sky companion
  show_your_work:
    - compute/horizon-grid           # (H, W, n_az) float32 — the inspectable
                                      # cache; ~1.4 KB per pixel.
  provenance:
    - dem_item_ids                   # STAC item IDs of the source DEM tiles

# ─────────────────────────────────────────────────────────────────────────────
# Reference implementation (the validated script this recipe declares):
#   demos/lake-blanche-shade/compute_shade.py  (trail-aligned reference)
#   research/index-explorer/scripts/run_horizon_shade.py  (raster runner)
#
# Family note (2026-08-24): retagged from the proposed `horizon-shade-hour-month`
# to the existing `terrain-corrected-solar-shade` family, whose evidence already
# cites demos/lake-blanche-shade (the reference this recipe validates against).
# Pattern `terrain-analysis` is unchanged, so the `features-objects` shelf
# encoded in `category:` above still derives correctly.
# ─────────────────────────────────────────────────────────────────────────────
