Skip to content

Moisture-aware LAI phenology (LAIType = 2) via Design C (#1292)#1316

Draft
sunt05 wants to merge 17 commits intomasterfrom
sunt05/gh1292-lai-moisture
Draft

Moisture-aware LAI phenology (LAIType = 2) via Design C (#1292)#1316
sunt05 wants to merge 17 commits intomasterfrom
sunt05/gh1292-lai-moisture

Conversation

@sunt05
Copy link
Copy Markdown

@sunt05 sunt05 commented Apr 19, 2026

Closes #1292

Adds a new LAIType = 2 pathway that modulates thermal-driven phenology with soil-moisture state for rainfall-driven sites, via a Jarvis-style water-stress factor on delta_GDD plus a CLM5-style persistence latch on a tau-day running mean of relative soil water w = 1 - SMD / SMDcap.

Six new per-vegetation-surface parameters (w_wilt, w_opt, f_shape, w_on, w_off, tau_w) are threaded through the Fortran type, the Rust/C bridge (LAI_PRM + PHENOLOGY_STATE schema bumped to v2), and the SuPy data model; shipped defaults degrade gracefully to thermal-only behaviour so well-watered sites remain bit-identical to LAIType = 0.

Ships with a parameter-sweep calibration tool (scripts/verify/moisture_phenology_sweep.py --all --dry-start), a two-branch drought demo on the London sample that isolates the moisture control from the thermal driver, and an internal design note at dev-ref/design-notes/gh1292-moisture-phenology.md with literature review and the A/B/C/D trade-off analysis.

FLUXNET calibration of the sensitive trio (w_wilt/w_opt/w_off) and the optional SDD water-stress senescence term are deferred to follow-ups (roadmap in Appendix A).

Test plan

  • make dev clean rebuild (Fortran + Rust + Python wheel)
  • cargo test --lib --features physics lai phenology — 13/13 pass
  • pytest test/test_phenology_lai_type_scaffolding.py test/data_model/test_laiparams_moisture_fields.py test/test_moisture_phenology_sweep.py test/test_moisture_phenology_site.py test/core/test_laimethod.py
  • make test-smoke — 9/9 pass
  • LAIType = 2 bit-identical to LAIType = 0 on the bundled London sample under defaults
  • Aggressive thresholds + dry-start reproduces expected strict LAI reduction and spring green-up delay

sunt05 added 13 commits April 17, 2026 07:45
Adds dev-ref/design-notes/ for issue-scoped developer design records,
plus the first entry: a design proposal for moisture-aware phenology
targeting rainfall-driven sites where the purely thermal GDD/SDD
scheme collapses to near-zero seasonality (evidence from AU-ASM vs
US-MMS in the prior FLUXNET2015 fits). Reviews CLM5, Noah-MP, JULES,
ORCHIDEE, LPJ, ISBA and crop-model precedents; recommends a hybrid
Jarvis-style stress factor plus CLM5-style persistence trigger behind
a new LAIType=2 switch. Also proposes validation against FLUXNET2015
dryland and monsoon sites using the existing Mathematica LAIModel
fitting pipeline. No code changes.
Introduces a new LAIType = 2 path for vegetation phenology and threads
the plumbing end-to-end without changing numerical behaviour. The new
branch mirrors LAIType = 0 exactly (bit-identical on the sample run);
the Design C Jarvis + CLM5-persistence-trigger numerics land in PR2.

Fortran physics
  - LAI_PRM gains six moisture parameters (w_wilt, w_opt, f_shape,
    w_on, w_off, tau_w). Thresholds operate on dimensionless relative
    soil water w = 1 - smd/smdcap; tau_w is in days.
  - PHENOLOGY_STATE gains three per-veg-surface slots (wbar_id,
    w_id_prev, leaf_on_permitted).
  - update_GDDLAI signature extended with previous-day SMD (mm), SMD
    capacity (mm), the six moisture parameters and the three state
    arrays. LAIType == 2 branch is a no-op with TODO(PR2) markers;
    the LAI power-law now explicitly routes LAIType == 2 through the
    original (LAIType == 0) curve so scaffolding is bit-identical.

Rust/C bridge (schema v1 -> v2)
  - LAI_PRM flat length 11 -> 17 in lai.rs and c_api/lai.f95.
  - PHENOLOGY_STATE flat length 76 -> 85 in phenology.rs,
    c_api/phenology.f95, and c_api/driver.f95:104 aggregate; driver.f95
    pack_phenology_state extended for the three new arrays.
  - Embedded LAI slices widen LC_DECTR_PRM 61 -> 67, LC_EVETR_PRM
    57 -> 63, LC_GRASS_PRM 55 -> 61; shadow types, pack/unpack, and
    Rust slice offsets updated on both sides.
  - yaml_config.rs honours the six new lai.* keys when overriding.

Python data model
  - LAIParams exposes six optional FlexibleRefValue(float) fields and a
    laitype=2-only validator that checks w_opt > w_wilt and
    w_off < w_on and logs unset moisture fields.
  - to_df_state / from_df_state serialise the new columns with safe
    defaults so legacy YAMLs keep loading.
  - checker_rules_indiv.json adds six grid-level entries so run_supy's
    check_state() does not reject the new df_state columns.

Tests and diagnostics
  - test/data_model/test_laiparams_moisture_fields.py: four tests
    covering default load, explicit round-trip, validator scoping, and
    checker-rule coverage.
  - test/test_phenology_lai_type_scaffolding.py: bit-identity of
    LAIType = 2 vs LAIType = 0 on the bundled sample (the sample
    defaults to laitype=1, so the test mutates the initial state
    explicitly), plus a no-NaN sanity run for laitype=1.
  - scripts/verify/moisture_phenology_site.py: standalone diagnostic
    harness for the eventual FLUXNET2015 comparison (AU-ASM by
    default); reads from the local FLUXNET archive, caches under
    .context/gh1292/<site>/, emits lai_timeseries.png, metrics.json
    and summary.txt. Informational in PR1 (V0 == V2); becomes the
    scientific acceptance harness in PR2.

Design record
  - dev-ref/design-notes/gh1292-moisture-phenology.md (from commit
    2a019ba) captures the full design; this PR implements the PR1
    slice described there.
Replaces the PR1 no-op in update_GDDLAI LAItype=2 branch with the
Design C hybrid scheme from the internal design note: a Jarvis-style
water-stress factor on delta_GDD plus a CLM5-style persistence latch
on the tau_w-day running mean of relative soil water.

Fortran physics
  - Jarvis f_w: f_w = 0 when w <= w_wilt, f_w = 1 when w >= w_opt, and
    a piecewise-power curve on the intermediate interval.
  - CLM5 latch: leaf_on_permitted flips off when wbar_id drops below
    w_off and flips back on when wbar_id rises above w_on while the
    thermal companion delta_GDD > 0 is satisfied. When the latch is
    closed, delta_GDD is zeroed out.
  - Seed wbar_id from the first measured w (detected when w_id_prev
    and wbar_id are both still at their zero defaults) so well-watered
    sites do not spend tau_w days ramping up from zero.
  - PHENOLOGY_STATE.leaf_on_permitted default flipped from false to
    true across Fortran, Rust bridge, and C shadow so mid-year starts
    and wet-site configurations do not wait for the persistence
    trigger before accumulating GDD.

Tests
  - test_phenology_lai_type_scaffolding.py rewritten for PR2
    semantics: bit-identity now holds in the well-watered regime
    (default thresholds on the Swindon sample), aggressive Jarvis
    thresholds strictly reduce mean LAI, and depleting soilstore_surf
    to 10 mm on day 1 demonstrably delays spring green-up over the
    first 60 days.
  - Legacy laitype=1 sanity check preserved.

CHANGELOG entry added; Rust bridge unit tests (lai, phenology) still
pass after the default flip.
Introduces a parameter-sweep tool, documents the calibration
methodology, and runs a sensitivity demo on the bundled Swindon
sample. Does not attempt a full per-IGBP FLUXNET fit (that requires
dedicated per-site SUEWS configurations outside this repo) and does
not add the optional additive moisture-stress SDD term (deferred
until calibration data motivates it).

scripts/verify/moisture_phenology_sweep.py
  - Scans one of the six moisture parameters (w_wilt, w_opt, f_shape,
    w_on, w_off, tau_w) over user-supplied or default value lists,
    reruns SUEWS per value, reports mean LAI, RMSE vs LAIType=0
    baseline, seasonal amplitude, green-up DOY, and emits a dual-axis
    sensitivity PNG alongside the JSON payload.
  - --all flag scans all six parameters sequentially.
  - --dry-start depletes vegetation soilstore_surf to 10 mm on day 1
    so the moisture gate engages at otherwise well-watered samples
    (Swindon sample is UK-suburban).
  - Pairwise-validator co-adjustment keeps w_opt > w_wilt and
    w_on > w_off satisfied during extreme sweep values.
  - Outputs land in .context/gh1292/<site>/sweep_<param>.{json,png}.

dev-ref/design-notes/gh1292-moisture-phenology.md
  - New Appendix A covering: sweep-tool usage (A.1); Swindon
    sensitivity results and the "w_wilt, w_opt, w_off" sensitive-trio
    finding (A.2); FLUXNET2015 calibration roadmap with primary
    dryland/monsoon tier and temperate control tier and acceptance
    bars from section 8 (A.3); status of the optional SDD
    senescence term as deferred (A.4).

test/test_moisture_phenology_sweep.py
  - Slow-tagged CLI smoke test that runs a one-point sweep and
    validates the JSON schema (parameter name, baseline mean LAI,
    five required fields per point).

CHANGELOG entry added for 18 Apr.
Introduces a lightweight system for archiving dashboards and narratives
that accompany scientific PRs:

- New `docs/source/development/` tree exposed via the main toctree, with
  a landing page explaining the dev-notes convention.
- Sphinx `html_extra_path` now includes `docs/source/_extra/`, so per-PR
  dashboards placed under `_extra/dev-notes/<slug>/` are copied verbatim
  into the built site at `/dev-notes/<slug>/`.
- `scripts/suews/build_dev_note.py` classifies assets as light (copied
  in-repo) or heavy (staged for a GitHub Release tagged
  `dev-notes-<slug>`), rewrites heavy-asset URLs in the source
  dashboard, and emits the release commands for the operator.
- Adds `.github/pull_request_template.md` with a check-box classifier
  (Bug / Refactor / Docs / Tooling / Scientific) and a scientific-PR
  sub-checklist that asks for tests, a dev-note dashboard, and the
  associated `docs/source/development/<slug>.rst` narrative.
- Extends the audit-pr skill with a dedicated Step 3 "Scientific PR
  dev-note demo" block mirroring the template: reviewers verify that
  scientific PRs ship with a runnable dashboard under
  `docs/source/_extra/dev-notes/<slug>/` and a summary page in
  `docs/source/development/`, and that the dev-notes toctree has been
  updated.
Fills the newly introduced dev-notes slot for the moisture-aware LAI
phenology work:

- New dashboard + narrative
    - `docs/source/development/gh-1292-lai-moisture.rst` summarises the
      problem, Design C mechanism, three-PR landing, and links to the
      full internal design note.
    - `docs/source/_extra/dev-notes/gh-1292-lai-moisture/dashboard.html`
      is the self-contained demo (hero metrics, four-panel drought
      demo, Design C equations, sensitivity summary, commit timeline).
- Two publication-quality demo figures with a unified panel-label
  convention — left edge aligned with the y-axis-label column, two
  scenarios (solo letter vs labelled title) sharing one helper:
    - `scripts/verify/moisture_phenology_drought_demo.py` — 4-panel
      London drought scenario showing the two control branches; solo
      `A)`/`B)`/`C)`/`D)` labels.
    - `scripts/verify/moisture_phenology_sensitivity_summary.py` —
      two-panel sensitivity ranking + dose-response; labelled-title
      `A)`/`B)` that concatenate the panel description.
- `moisture_phenology_sweep.py` and the two tests now default to the
  London sample (Ward et al. 2016 KCL benchmark); the bundled sample
  config was never Swindon.
- Design-note Appendix A updated with the same London-vs-Swindon fix
  and the sensitivity-trio finding (`w_wilt`, `w_opt`, `w_off`).
- CHANGELOG entries for PR2 and PR3 rewrite "Swindon" → "London" for
  the same reason.
Rolls back 3a272c8 and the GH-1292 dashboard artefacts from 5991fb4.
Keeps the scientific substance (verify scripts, tests, CHANGELOG,
design note) but drops the archival layer — evidence for scientific
PRs lives in GitHub PR comments and linked verify scripts + figures,
not in a parallel RST/dashboard pipeline.
…ecklist"

Rolls back 9583f47. Scientific PRs do not need a dedicated review
track or a dashboard-delivery requirement — ordinary PR description,
review, and comments are the right venue for evidence and discussion.
Drops the PR template along with the audit-pr Step 3 dev-note section.
The previous PR2 code used 0.0 as the "unset" marker for `wbar_id` and
`w_id_prev` on first activation, but 0.0 is a physically valid value of
dimensionless relative soil water (completely dry). A site that began a
timestep with `w = 0` would wrongly be re-seeded every step instead of
advancing the running mean.

Switch the unset sentinel to -1.0 (outside the physical [0, 1] range
that `w` is clamped to) and check `< 0.0` in `update_GDDLAI`. Applies
consistently across:

- `PHENOLOGY_STATE%wbar_id`, `%w_id_prev` in `suews_type_vegetation.f95`
- `update_GDDLAI` seed-detection in `suews_phys_dailystate.f95`
- The `phenology_state_shadow` defaults in the Rust/C bridge
  (`suews_bridge/c_api/phenology.f95` + `suews_bridge/src/phenology.rs`)
Rewrites `scripts/verify/moisture_phenology_site.py` so the forcing is
parsed via SuPy's `read_forcing()` utility (preserving subhourly
minute resolution instead of the ad-hoc Y%j%H reconstruction), and so
`run_scenario()` takes the forcing and run window as explicit
arguments rather than silently picking them up from the sample config.

Adds `test/test_moisture_phenology_site.py` covering:
- `load_forcing()` preserves 30-min timestamps and filters by year
  correctly using the new SuPy parser
- `run_scenario()` threads the caller-supplied forcing and date window
  through to SuPy rather than swallowing them
@sunt05
Copy link
Copy Markdown
Author

sunt05 commented Apr 19, 2026

Evidence that moisture-aware LAI (LAIType = 2) works

Three commits on sunt05/gh1292-lai-moisture land the Design C scheme from the internal design note (dev-ref/design-notes/gh1292-moisture-phenology.md):

  • 02b90f927 PR1 — data model + bridge scaffolding, LAIType = 2 no-op branch bit-identical to LAIType = 0
  • ff80f8767 PR2 — activated Design C numerics: Jarvis water-stress factor on delta_GDD + CLM5-style persistence latch on wbar_id
  • 6368af507 PR3 — parameter-sweep calibration tool, dry-start flag, sensitivity ranking

1. Two control branches act independently

Four-panel figure on the bundled London sample. Baseline forcing (blue) kept intact; drought forcing (red) zeroes rainfall over DOY 30–130 so SUEWS's own water balance drives SMD up while leaving the temperature forcing untouched. Demo-only tightened thresholds (w_wilt = 0.65, w_opt = 0.95) so the Jarvis curve engages in the SMD range the drought produces on London's 150 mm soil store — the ground-truth physics at well-watered sites degrades gracefully to LAIType = 0 under the shipped defaults.

moisture_control.png

Panel A) — Moisture input. Baseline vs drought rainfall forcing.

Panel B) — Moisture state. Daily grass-surface SMD. Drought pushes SMD from ~35 mm outside the window to ~59 mm inside; dashed lines mark the SMD equivalents of w_wilt and w_opt.

Panel C) — Thermal state. Daily mean air temperature (left axis) and grass GDD / SDD accumulators (right axis) — identical between the two LAIType runs. Any LAI divergence in Panel D is therefore purely the moisture control, not a thermal artefact.

Panel D) — LAI outcome. LAIType = 0 (thermal-only, black) rises through DOY 60–95 as GDD crosses BaseT_GDD. LAIType = 2 (moisture-aware, orange) sits at LAImin throughout P2 because the CLM5 latch is closed (wbar_id < w_off) — GDD accumulates but delta_GDD is gated to zero. At DOY 130 rain returns, the latch reopens, and LAIType = 2 catches up through P3. Both schemes senesce normally under SDD through P4.

Numbers (summary JSON):

  • lai_mean_inside_drought: LAIType = 0 → 2.79 m² m⁻²; LAIType = 2 → 1.36 m² m⁻² (the LAImin floor)
  • Gap: 1.43 m² m⁻² inside the drought window
  • lai_max: both reach the same summer plateau once moisture recovers

Reproducer: uv run python scripts/verify/moisture_phenology_drought_demo.py

2. Which parameters actually matter

Sensitivity sweep over all six Design C parameters on the London dry-start scenario. Top panel ranks max |mean-LAI deviation from baseline| per parameter against a 0.02 m² m⁻² noise floor; bottom panel shows the full dose-response curves.

sensitivity_summary.png

A) — Sensitivity ranking. Three orange bars (w_wilt, w_opt, w_off) cross the noise floor; three grey bars (f_shape, w_on, tau_w) sit at zero. The sensitive trio is where calibration effort should go.

B) — Dose-response. Tightening the sensitive trio pulls mean annual LAI below baseline; the silent trio tracks the baseline within noise across their full sweep ranges.

Reproducer: uv run python scripts/verify/moisture_phenology_sweep.py --all --dry-start then uv run python scripts/verify/moisture_phenology_sensitivity_summary.py.

What's in and what's next

  • In this branch: scaffolding (PR1), activated numerics (PR2), calibration scaffolding (PR3). Default thresholds are conservative (w_wilt = 0.15, w_opt = 0.40, w_on = 0.35, w_off = 0.20) so existing well-watered sites degrade to thermal-only behaviour bit-for-bit.
  • Not yet: real FLUXNET2015 calibration of the sensitive trio at dryland / monsoon sites (AU-ASM, US-SRG, US-MMS, US-Ton — roadmap in Appendix A.3 of the design note), and the optional SDD water-stress senescence term (deferred, rationale in Appendix A.4).

Full internal design note (problem statement, literature review of how other LSMs do this, Design A/B/C/D trade-off, acceptance criteria) at dev-ref/design-notes/gh1292-moisture-phenology.md.

sunt05 added 2 commits April 19, 2026 22:16
Adds Step 3 description-rigour criteria to audit-pr skill: scientific
PRs must expose methodology, scientific decisions, and quantitative
results in the PR body or thread, not in a sidecar artefact. Provides
triggers, required content, and handling for thin descriptions.
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 19, 2026

CI Build Plan

Changed Files

Fortran source (2 files)

  • src/suews/src/suews_phys_dailystate.f95
  • src/suews/src/suews_type_vegetation.f95

Rust bridge (12 files)

  • src/suews_bridge/c_api/driver.f95
  • src/suews_bridge/c_api/lai.f95
  • src/suews_bridge/c_api/lc_dectr_prm.f95
  • src/suews_bridge/c_api/lc_evetr_prm.f95
  • src/suews_bridge/c_api/lc_grass_prm.f95
  • src/suews_bridge/c_api/phenology.f95
  • src/suews_bridge/src/lai.rs
  • src/suews_bridge/src/lc_dectr_prm.rs
  • src/suews_bridge/src/lc_evetr_prm.rs
  • src/suews_bridge/src/lc_grass_prm.rs
  • src/suews_bridge/src/phenology.rs
  • src/suews_bridge/src/yaml_config.rs

Python source (3 files)

  • src/supy/_check.py
  • src/supy/checker_rules_indiv.json
  • src/supy/data_model/core/site.py

Tests (4 files)

  • test/data_model/test_laiparams_moisture_fields.py
  • test/test_moisture_phenology_site.py
  • test/test_moisture_phenology_sweep.py
  • test/test_phenology_lai_type_scaffolding.py

Documentation (2 files)

  • CHANGELOG.md
  • src/supy/data_model/core/site.py

Build Configuration

Configuration
Platforms Linux x86_64, macOS ARM64, Windows x64
Python 3.9
Test tier core (physics + smoke)
PR status Draft (reduced matrix)

Rationale

  • Fortran source changed -> multiplatform build required
  • Rust bridge changed -> multiplatform build required
  • Python source changed -> single-platform build
  • Test files changed -> validation build

Updated by CI on each push. See path-filters.yml for category definitions.

@github-actions
Copy link
Copy Markdown

Preview Deployed

Content Preview URL
Site https://suews.io/preview/pr-1316/
Docs https://suews.io/preview/pr-1316/docs/

Note

This preview is ephemeral. It will be lost when:

  • Another PR with site/ or docs/ changes is pushed
  • Changes are merged to master
  • A manual workflow dispatch runs

To restore, push any commit to this PR.

Adds module-level `pytestmark` to the four GH-1292 test files so the
`scripts/lint/check_test_markers.py` CI lint (gh#1300) passes. Data-model
and script-harness tests get `api`; the SUEWSSimulation-driven LAI
scaffolding test gets `physics`.
@suegrimmond
Copy link
Copy Markdown

There should be four LAI approaches already - - so need to understand how new method is 2 - where are the rest?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LAI parameterisation: add moisture-aware phenology for rainfall-driven sites

2 participants