Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/AGENT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ Key constraints:
- **Spin-up restart**: cold-start `init.nc` has zero hydrometeors. Ensemble generation runs 24h unperturbed first, then perturbs from the restart.
- **PyCECT minimum members**: ensemble size must be >= number of output variables (~48). Default: 200.
- **Time slice**: `run-perturb-mpas` invokes `trim_history.py` with `--tslice (Time.size - 1)`, producing files with exactly **one** time dimension (`Time=1`). PyCECT (`pyCECT.py` and `pyEnsSumMPAS.py`) is then called with `--tslice 0` — the only valid index in a single-slice file. There is no `ECT_TSLICE` knob in `ci-config.env`; changing the effective slice would require editing `run-perturb-mpas` (not config). Slice 0 in a cold-start (untrimmed) run is the unintegrated initial state, which is why we trim before validation.
- ECT parameters and paths in `.github/ci-config.env` (`ECT_*`, `ECT_EXCLUDED_VARS`, `PYCECT_TAG`); the ECT release tag itself is derived at runtime from `Registry.xml` via the `mpas-version` composite action.
- Per-case ECT parameters (archive, durations, perturbation, summary/restart names, excluded variables) are in `.github/ect-cases.yml`; PyCECT tag/commit stay in `.github/ci-config.env` (`PYCECT_*`); the ECT release tag itself is derived at runtime from `Registry.xml`.

## Shell Scripting Notes

Expand All @@ -233,3 +233,11 @@ Workflows accept `mpas-repository` and `mpas-ref` inputs for testing upstream MP

- **NVHPC+OpenMPI**: model exits 134 (SIGABRT) on GA runners with 4 ranks. MPICH works. Caller workflows and reusable CPU/GPU jobs mark this combination `continue-on-error` until resolved.
- **NVHPC/Intel MPI F08 bindings**: broken with hpcdev MPI libraries. Both use `MPAS_MPI_F08=0` workaround.

### ECT cases (`.github/ect-cases.yml`)

One table lists the ECT cases: `default` (used when a workflow's `ect-case` input is empty), `defaults` (shared values) and `cases`
(per-case fields such as `summary-file`, `restart-file`, `resolution`, durations, perturbation; quote durations and magnitudes, YAML reads
unquoted `1_00:00:00` as a number). Each workflow resolves its case once in its config job (`resolve-ect-case` action) and passes the
values on as inputs. Run a case by typing its name into the `ect-case` input of `ect-test.yml` (or `ect-ensemble-gen.yml` to build its
reference). A new case needs its own summary and restart assets in the ECT release. Results record the case in an `ect-case` column.
54 changes: 54 additions & 0 deletions .github/actions/resolve-ect-case/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: 'Resolve ECT case'
description: >
Resolve one ECT case from .github/ect-cases.yml (defaults merged with the case) into explicit outputs. Run it in a workflow's
config job (the repository must be checked out) and pass the outputs on as inputs; nothing is read from the environment.

inputs:
ect-case:
description: 'ECT case name (a key under `cases` in .github/ect-cases.yml). Empty = the table default.'
required: false
default: ''

outputs:
case:
description: 'Resolved case name'
value: ${{ steps.resolve.outputs.case }}
resolution:
description: 'Test archive name (<resolution>.tar.gz, RELEASE_TESTDATA_<RES> in ci-config.env)'
value: ${{ steps.resolve.outputs.resolution }}
summary-file:
description: 'Ensemble summary release asset name'
value: ${{ steps.resolve.outputs.summary-file }}
restart-file:
description: 'Spin-up restart release asset name (stored gzipped)'
value: ${{ steps.resolve.outputs.restart-file }}
spinup-duration:
description: 'Spin-up duration (config_run_duration format)'
value: ${{ steps.resolve.outputs.spinup-duration }}
run-duration:
description: 'Duration of each ensemble/test member (config_run_duration format)'
value: ${{ steps.resolve.outputs.run-duration }}
ensemble-size:
description: 'Reference ensemble size'
value: ${{ steps.resolve.outputs.ensemble-size }}
perturb-variable:
description: 'Perturbed initial-condition variable'
value: ${{ steps.resolve.outputs.perturb-variable }}
perturb-magnitude:
description: 'Perturbation magnitude'
value: ${{ steps.resolve.outputs.perturb-magnitude }}
excluded-vars:
description: 'Path (relative to the repo root) of the excluded-variables file'
value: ${{ steps.resolve.outputs.excluded-vars }}

runs:
using: 'composite'
steps:
- name: Resolve ECT case
id: resolve
shell: bash
env:
ECT_CASE_INPUT: ${{ inputs.ect-case }}
run: |
python3 -c "import yaml" 2>/dev/null || python3 -m pip install --quiet pyyaml
python3 "${GITHUB_WORKSPACE}/.github/scripts/resolve-ect-case.py" --case "${ECT_CASE_INPUT}" | tee -a "${GITHUB_OUTPUT}"
18 changes: 15 additions & 3 deletions .github/actions/run-perturb-mpas/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ inputs:
description: 'MPI implementation (openmpi, mpich)'
required: false
default: 'openmpi'
excluded-vars:
description: 'Path (relative to the repo root) of the file listing history variables to drop before PyCECT (from resolve-ect-case)'
required: true
perturb-magnitude:
description: 'Magnitude of the initial-condition perturbation (from resolve-ect-case)'
required: true
gpu-aware-mpi:
description: >
Use GPU-aware MPI halo exchanges (config_gpu_aware_mpi = .true. in each member's namelist plus the MPI
Expand Down Expand Up @@ -124,8 +130,14 @@ runs:
fi

EXCLUDE_FILE=""
if [ -n "${ECT_EXCLUDED_VARS}" ]; then
EXCLUDE_FILE="${GITHUB_WORKSPACE}/${ECT_EXCLUDED_VARS}"
EXCLUDED_VARS="${{ inputs.excluded-vars }}"
PERTURB_MAGNITUDE="${{ inputs.perturb-magnitude }}"
if [ -z "${PERTURB_MAGNITUDE}" ]; then
echo "::error::perturb-magnitude is required (resolve it with the resolve-ect-case action)"
exit 1
fi
if [ -n "${EXCLUDED_VARS}" ]; then
EXCLUDE_FILE="${GITHUB_WORKSPACE}/${EXCLUDED_VARS}"
fi

mkdir -p "${OUTDIR}"
Expand Down Expand Up @@ -173,7 +185,7 @@ runs:

echo "[$(date +%H:%M:%S)] Perturbing theta (seed=${MEMBER})..."
python3 ${GITHUB_WORKSPACE}/.github/actions/run-perturb-mpas/perturb_theta.py \
"${PERTURB_FILE}" --seed ${MEMBER} --magnitude ${ECT_PERTURB_MAGNITUDE}
"${PERTURB_FILE}" --seed ${MEMBER} --magnitude ${PERTURB_MAGNITUDE}

if [ "${VERBOSE}" = "true" ]; then
echo " Restart MD5: $(md5sum "${PERTURB_FILE}" | cut -d' ' -f1)"
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/run-perturb-mpas/trim_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
areaTriangle) to compute global means. These static variables are
preserved even though they lack a Time dimension.

The ECT exclusion list path is configured via ECT_EXCLUDED_VARS in ci-config.env
The ECT exclusion list path comes from the ECT case (excluded-vars in .github/ect-cases.yml)
and passed via --exclude-file.

Usage:
Expand Down
16 changes: 15 additions & 1 deletion .github/actions/validate-ect/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ inputs:
ect-tag:
description: 'ECT data release tag (from the mpas-version action, e.g. ect-v8.4)'
required: true
summary-file:
description: 'Ensemble summary release asset name for the ECT case (from resolve-ect-case)'
required: true
ect-case:
description: "Resolved ECT case name (from resolve-ect-case). Recorded as the 'ect-case' column of the result."
required: false
default: ''
dimensions:
description: 'Multi-line key=value pairs describing this test combination (written into result file)'
required: false
Expand Down Expand Up @@ -44,7 +51,11 @@ runs:

# ECT release tag is passed in, not stored in ci-config.env.
# See .github/actions/mpas-version.
echo "summary-file=${ECT_SUMMARY_FILE}" >> $GITHUB_OUTPUT
if [ -z "${{ inputs.summary-file }}" ]; then
echo "::error::summary-file is required (resolve it with the resolve-ect-case action)"
exit 1
fi
echo "summary-file=${{ inputs.summary-file }}" >> $GITHUB_OUTPUT
echo "pycect-tag=${PYCECT_TAG}" >> $GITHUB_OUTPUT
echo "pycect-commit=${PYCECT_COMMIT}" >> $GITHUB_OUTPUT
echo "release-tag=${{ inputs.ect-tag }}" >> $GITHUB_OUTPUT
Expand Down Expand Up @@ -207,4 +218,7 @@ runs:
if [ -n "${DIMS}" ]; then
echo "${DIMS}"
fi
if [ -n "${{ inputs.ect-case }}" ]; then
echo "ect-case=${{ inputs.ect-case }}"
fi
} > ect-result.txt
12 changes: 3 additions & 9 deletions .github/ci-config.env
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,11 @@ RELEASE_TESTDATA_120KM=testdata-120km-v2


# ── ECT configuration ────────────────────────────
# Parameters for the Ensemble Consistency Test (PyCECT).
# These are CI-specific settings not carried in the model namelist.
# Per-case ECT settings (test archive, physics namelist overrides, run lengths, perturbation, summary/restart asset names,
# excluded variables) live in ONE table, .github/ect-cases.yml, resolved by the .github/actions/resolve-ect-case action.
# Only tool-level settings stay here.
# Reference: Price-Broncucia et al. (2025), doi:10.5194/gmd-18-2349-2025

ECT_RESOLUTION=120km
ECT_ENSEMBLE_SIZE=200
ECT_PERTURB_MAGNITUDE=1e-14
ECT_PERTURB_VARIABLE=theta
ECT_SUMMARY_FILE=mpas_ect_summary_120km.nc
ECT_RESTART_FILE=120km-spinup-restart.nc
ECT_EXCLUDED_VARS=.github/data/ect_excluded_vars.txt
PYCECT_TAG=3.3.1
PYCECT_COMMIT=b3c36a9d72ee211f396d1bc7078f6d5466916b0a

Expand Down
35 changes: 35 additions & 0 deletions .github/ect-cases.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# ECT cases: one table, read by .github/actions/resolve-ect-case.
#
# A case = one ECT configuration (test archive, run lengths, perturbation, and the names of its
# ensemble-summary and spin-up-restart release assets). Fields not set on a case come from `defaults`. Nothing here overrides
# ci-config.env: containers, compilers, MPI flags, PyCECT version and test-data release tags stay in ci-config.env.
# Workflows take an `ect-case` input (empty = `default`), resolve it once in their config job, and pass the resolved values on
# as explicit inputs/outputs.
#
# Fields
# description free text
# resolution test-archive name; must have RELEASE_TESTDATA_<RES> in ci-config.env (the archive is <resolution>.tar.gz)
# summary-file ensemble summary asset in the ECT release (ect-v{MAJOR.MINOR}); give each case its own name
# restart-file spin-up restart asset in the ECT release (stored gzipped as <restart-file>.gz)
# spinup-duration (QUOTE durations and magnitudes) unperturbed spin-up before the ensemble is perturbed (config_run_duration format)
# run-duration length of each (perturbed) member
# ensemble-size reference ensemble size (must be >= number of analysed variables; ~48 minimum)
# perturb-variable initial-condition variable perturbed (O(1e-14), needs double precision)
# perturb-magnitude
# excluded-vars file listing history variables dropped before PyCECT
default: mesoscale_reference

defaults:
resolution: 120km
spinup-duration: "1_00:00:00" # quote durations: unquoted 1_00:00:00 would be read as a base-60 number
run-duration: "0_02:36:00"
ensemble-size: 200
perturb-variable: theta
perturb-magnitude: "1e-14"
excluded-vars: .github/data/ect_excluded_vars.txt

cases:
mesoscale_reference:
description: 120km global mesh, mesoscale_reference physics suite as shipped in the archive's namelist
summary-file: mpas_ect_summary_120km.nc
restart-file: 120km-spinup-restart.nc
41 changes: 41 additions & 0 deletions .github/scripts/resolve-ect-case.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Resolve one ECT case from .github/ect-cases.yml: defaults merged with the case, printed as key=value lines.

usage: resolve-ect-case.py [--case NAME] [--table PATH] (empty/omitted case = the table's `default`)
Output keys: case, resolution, summary-file, restart-file, spinup-duration, run-duration,
ensemble-size, perturb-variable, perturb-magnitude, excluded-vars.
"""
import argparse, sys
from pathlib import Path
try:
import yaml
except ImportError:
sys.exit("::error::PyYAML is required (pip install pyyaml)")
ap = argparse.ArgumentParser()
ap.add_argument('--case', default='')
ap.add_argument('--table', default=str(Path(__file__).resolve().parent.parent / 'ect-cases.yml'))
a = ap.parse_args()
t = yaml.safe_load(open(a.table))
cases = t.get('cases') or {}
name = a.case.strip() or t['default']
if name not in cases:
sys.exit(f"::error::Unknown ECT case '{name}' in {a.table}. Available: {', '.join(cases)}")
r = dict(t.get('defaults') or {}); r.update(cases[name] or {})
required = ['resolution', 'summary-file', 'restart-file', 'spinup-duration', 'run-duration', 'perturb-variable',
'perturb-magnitude', 'excluded-vars']
missing = [k for k in required if r.get(k) in (None, '')]
if missing:
sys.exit(f"::error::ECT case '{name}' is missing required field(s): {', '.join(missing)}")
for k in ['resolution', 'summary-file', 'restart-file', 'spinup-duration', 'run-duration', 'perturb-variable',
'perturb-magnitude', 'excluded-vars']:
if k in r and not isinstance(r[k], str):
sys.exit(f"::error::ECT case '{name}': field {k} must be a quoted string in {a.table} (got {type(r[k]).__name__} {r[k]!r}; "
"YAML turns unquoted durations like 1_00:00:00 into numbers)")
out = {'case': name}
for k in ['resolution', 'summary-file', 'restart-file', 'spinup-duration', 'run-duration', 'ensemble-size', 'perturb-variable',
'perturb-magnitude', 'excluded-vars']:
out[k] = str(r.get(k, '')).strip()
for k, v in out.items():
if '\n' in v:
sys.exit(f"::error::ECT case field {k} must be a single line")
print(f"{k}={v}")
28 changes: 24 additions & 4 deletions .github/workflows/_test-compiler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ permissions:
on:
workflow_call:
inputs:
ect-case:
description: 'ECT case (a key under `cases` in .github/ect-cases.yml). Empty = the table default (mesoscale_reference).'
required: false
type: string
default: ''
compiler:
description: 'Compiler family (gcc, nvhpc, oneapi)'
required: true
Expand All @@ -39,6 +44,13 @@ jobs:
outputs:
image: ${{ steps.container.outputs.image }}
ect-tag: ${{ steps.version.outputs.ect-tag }}
ect-resolution: ${{ steps.ectcase.outputs.resolution }}
ect-summary-file: ${{ steps.ectcase.outputs.summary-file }}
ect-restart-file: ${{ steps.ectcase.outputs.restart-file }}
ect-run-duration: ${{ steps.ectcase.outputs.run-duration }}
ect-excluded-vars: ${{ steps.ectcase.outputs.excluded-vars }}
ect-perturb-magnitude: ${{ steps.ectcase.outputs.perturb-magnitude }}
ect-case: ${{ steps.ectcase.outputs.case }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
Expand All @@ -54,6 +66,10 @@ jobs:
mpi: ${{ inputs.mpi }}
- uses: ./.github/actions/mpas-version
id: version
- uses: ./.github/actions/resolve-ect-case
id: ectcase
with:
ect-case: ${{ inputs.ect-case }}

build:
needs: config
Expand Down Expand Up @@ -127,7 +143,7 @@ jobs:
if: steps.download.outcome == 'success'
uses: ./.github/actions/download-testdata
with:
resolution: 120km
resolution: ${{ needs.config.outputs.ect-resolution }}
dest-dir: base-case

- name: Restore cached restart
Expand All @@ -136,15 +152,15 @@ jobs:
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: spinup-restart.nc
key: ect-spinup-restart-${{ needs.config.outputs.ect-tag }}-${{ hashFiles('.github/ci-config.env') }}
key: ect-spinup-restart-${{ needs.config.outputs.ect-tag }}-${{ hashFiles('.github/ci-config.env') }}-${{ needs.config.outputs.ect-case }}

- name: Download spin-up restart
if: steps.download.outcome == 'success'
id: restart
shell: bash
run: |
source .github/ci-config.env
RESTART="${ECT_RESTART_FILE}"
RESTART="${{ needs.config.outputs.ect-restart-file }}"
RELEASE_TAG="${{ needs.config.outputs.ect-tag }}"

if [ -f "spinup-restart.nc" ]; then
Expand Down Expand Up @@ -174,13 +190,15 @@ jobs:
if: steps.download.outcome == 'success'
uses: ./.github/actions/run-perturb-mpas
with:
excluded-vars: ${{ needs.config.outputs.ect-excluded-vars }}
perturb-magnitude: ${{ needs.config.outputs.ect-perturb-magnitude }}
base-dir: base-case
executable: ./atmosphere_model
member-start: '${{ matrix.member }}'
member-end: '${{ matrix.member }}'
num-ranks: '4'
mpi-impl: ${{ inputs.mpi }}
run-duration: '0_02:36:00'
run-duration: ${{ needs.config.outputs.ect-run-duration }}
run-timeout: '45'
restart-file: ${{ steps.restart.outputs.available == 'true' && steps.restart.outputs.file || '' }}

Expand Down Expand Up @@ -224,6 +242,8 @@ jobs:
history-dir: ect-test-files
label: ${{ inputs.compiler }}/${{ inputs.mpi }}/smiol
ect-tag: ${{ needs.config.outputs.ect-tag }}
summary-file: ${{ needs.config.outputs.ect-summary-file }}
ect-case: ${{ needs.config.outputs.ect-case }}
dimensions: |
compiler=${{ inputs.compiler }}
mpi=${{ inputs.mpi }}
Expand Down
Loading
Loading