diff --git a/.flake8 b/.flake8
new file mode 100644
index 00000000000..e3a4e3e47ea
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,3 @@
+[flake8]
+exclude = .git,.github,venv,__pycache__,old,build,dist
+max-line-length = 160
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 00000000000..6a9cf9d47c6
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,214 @@
+# global-workflow is a collaborative space where contributions come from a variety of sources
+# This file is to ensure that new development gets reviewed by the appropriate SME
+
+# global-workflow default owners (not a complete list)
+@KateFriedman-NOAA
+@WalterKolczynski-NOAA
+@DavidHuber-NOAA
+
+# Specific directory owners
+/ci/ @TerrenceMcGuinness-NOAA @WalterKolczynski-NOAA
+
+/ecf/ @lgannoaa
+
+/workflow/ @WalterKolczynski-NOAA @aerorahul @DavidHuber-NOAA
+
+# Specific file owners
+# build scripts
+sorc/build_*.sh @WalterKolczynski-NOAA @DavidHuber-NOAA @aerorahul @KateFriedman-NOAA
+sorc/link_workflow.sh @WalterKolczynski-NOAA @DavidHuber-NOAA @aerorahul @KateFriedman-NOAA
+
+# jobs
+jobs/JGDAS_AERO_ANALYSIS_GENERATE_BMATRIX @CoryMartin-NOAA
+jobs/JGDAS_ATMOS_ANALYSIS_DIAG @RussTreadon-NOAA @CoryMartin-NOAA
+jobs/JGDAS_ATMOS_CHGRES_FORENKF @RussTreadon-NOAA @CoryMartin-NOAA
+jobs/JGDAS_ATMOS_GEMPAK @GwenChen-NOAA
+jobs/JGDAS_ATMOS_GEMPAK_META_NCDC @GwenChen-NOAA
+jobs/JGDAS_ATMOS_VERFOZN @EdwardSafford-NOAA
+jobs/JGDAS_ATMOS_VERFRAD @EdwardSafford-NOAA
+jobs/JGDAS_ENKF_* @RussTreadon-NOAA @CoryMartin-NOAA @CatherineThomas-NOAA
+jobs/JGDAS_FIT2OBS @jack-woollen
+jobs/JGDAS_GLOBAL_OCEAN_ANALYSIS_ECEN @guillaumevernieres
+jobs/JGFS_ATMOS_AWIPS_20KM_1P0DEG @GwenChen-NOAA
+jobs/JGFS_ATMOS_CYCLONE_GENESIS @JiayiPeng-NOAA
+jobs/JGFS_ATMOS_CYCLONE_TRACKER @JiayiPeng-NOAA
+jobs/JGFS_ATMOS_FBWIND @GwenChen-NOAA
+jobs/JGFS_ATMOS_FSU_GENESIS
+jobs/JGFS_ATMOS_GEMPAK @GwenChen-NOAA
+jobs/JGFS_ATMOS_GEMPAK_META @GwenChen-NOAA
+jobs/JGFS_ATMOS_GEMPAK_NCDC_UPAPGIF @GwenChen-NOAA
+jobs/JGFS_ATMOS_GEMPAK_PGRB2_SPEC @GwenChen-NOAA
+jobs/JGFS_ATMOS_PGRB2_SPEC_NPOESS @WenMeng-NOAA
+jobs/JGFS_ATMOS_POSTSND @BoCui-NOAA
+jobs/JGFS_ATMOS_VERIFICATION
+jobs/JGLOBAL_AERO_ANALYSIS_* @CoryMartin-NOAA
+jobs/JGLOBAL_ARCHIVE @DavidHuber-NOAA
+jobs/JGLOBAL_ATMENS_ANALYSIS_* @RussTreadon-NOAA @CoryMartin-NOAA @DavidNew-NOAA
+jobs/JGLOBAL_ATMOS_ANALYSIS @RussTreadon-NOAA @CatherineThomas-NOAA
+jobs/JGLOBAL_ATMOS_ANALYSIS_CALC @RussTreadon-NOAA @CatherineThomas-NOAA @CoryMartin-NOAA
+jobs/JGLOBAL_ATMOS_EMCSFC_SFC_PREP @GeorgeGayno-NOAA
+jobs/JGLOBAL_ATMOS_ENSSTAT
+jobs/JGLOBAL_ATMOS_POST_MANAGER
+jobs/JGLOBAL_ATMOS_PRODUCTS @WenMeng-NOAA
+jobs/JGLOBAL_ATMOS_SFCANL @GeorgeGayno-NOAA
+jobs/JGLOBAL_ATMOS_TROPCY_QC_RELOC
+jobs/JGLOBAL_ATMOS_UPP @WenMeng-NOAA
+jobs/JGLOBAL_ATMOS_VMINMON @EdwardSafford-NOAA
+jobs/JGLOBAL_ATM_* @RussTreadon-NOAA @DavidNew-NOAA @CoryMartin-NOAA
+jobs/JGLOBAL_CLEANUP @WalterKolczynski-NOAA @DavidHuber-NOAA @KateFriedman-NOAA
+jobs/JGLOBAL_EXTRACTVARS @EricSinsky-NOAA
+jobs/JGLOBAL_FORECAST @aerorahul
+jobs/JGLOBAL_MARINE_* @guillaumevernieres @AndrewEichmann-NOAA
+jobs/JGLOBAL_OCEANICE_PRODUCTS @GwenChen-NOAA
+jobs/JGLOBAL_PREP_EMISSIONS @bbakernoaa
+jobs/JGLOBAL_PREP_OBS_AERO @CoryMartin-NOAA
+jobs/JGLOBAL_PREP_OCEAN_OBS @guillaumevernieres @AndrewEichmann-NOAA
+jobs/JGLOBAL_*SNOW* @jiaruidong2017
+jobs/JGLOBAL_STAGE_IC @KateFriedman-NOAA
+jobs/JGLOBAL_WAVE_* @JessicaMeixner-NOAA @sbanihash
+jobs/rocoto/* @WalterKolczynski-NOAA @KateFriedman-NOAA @DavidHuber-NOAA
+
+# scripts
+scripts/exgdas_aero_analysis_generate_bmatrix.py @CoryMartin-NOAA
+scripts/exgdas_atmos_chgres_forenkf.sh @RussTreadon-NOAA @CoryMartin-NOAA
+scripts/exgdas_atmos_gempak_gif_ncdc.sh @GwenChen-NOAA
+scripts/exgdas_atmos_nawips.sh @GwenChen-NOAA
+scripts/exgdas_atmos_verfozn.sh @EdwardSafford-NOAA
+scripts/exgdas_atmos_verfrad.sh @EdwardSafford-NOAA
+scripts/exgdas_enkf_earc_vrfy.py @DavidHuber-NOAA
+scripts/exgdas_enkf_earc_tars.py @DavidHuber-NOAA
+scripts/exgdas_enkf_ecen.sh @CoryMartin-NOAA @RussTreadon-NOAA @CatherineThomas-NOAA
+scripts/exgdas_enkf_post.sh @CoryMartin-NOAA @RussTreadon-NOAA @CatherineThomas-NOAA
+scripts/exgdas_enkf_select_obs.sh @CoryMartin-NOAA @RussTreadon-NOAA @CatherineThomas-NOAA
+scripts/exgdas_enkf_sfc.sh @CoryMartin-NOAA @RussTreadon-NOAA @CatherineThomas-NOAA
+scripts/exgdas_enkf_snow_recenter.py @jiaruidong2017
+scripts/exgdas_enkf_update.sh @CoryMartin-NOAA @RussTreadon-NOAA @CatherineThomas-NOAA
+scripts/exgdas_global_marine_analysis_letkf.py @guillaumevernieres @AndrewEichmann-NOAA
+scripts/exgfs_aero_init_aerosol.py @WalterKolczynski-NOAA
+scripts/exgfs_atmos_awips_20km_1p0deg.sh @GwenChen-NOAA
+scripts/exgfs_atmos_fbwind.sh @GwenChen-NOAA
+scripts/exgfs_atmos_gempak_gif_ncdc_skew_t.sh @GwenChen-NOAA
+scripts/exgfs_atmos_gempak_meta.sh @GwenChen-NOAA
+scripts/exgfs_atmos_goes_nawips.sh @GwenChen-NOAA
+scripts/exgfs_atmos_grib2_special_npoess.sh @WenMeng-NOAA
+scripts/exgfs_atmos_nawips.sh @GwenChen-NOAA
+scripts/exgfs_atmos_postsnd.sh @BoCui-NOAA
+scripts/exgfs_pmgr.sh
+scripts/exgfs_prdgen_manager.sh
+scripts/exgfs_wave_* @JessicaMeixner-NOAA @sbanihash
+scripts/exglobal_aero_analysis_* @CoryMartin-NOAA
+scripts/exglobal_archive.py @DavidHuber-NOAA
+scripts/exglobal_atm_analysis_* @RussTreadon-NOAA @DavidNew-NOAA
+scripts/exglobal_atmens_analysis_* @RussTreadon-NOAA @DavidNew-NOAA
+scripts/exglobal_atmos_analysis*.sh @RussTreadon-NOAA @CoryMartin-NOAA
+scripts/exglobal_atmos_ensstat.sh @RussTreadon-NOAA
+scripts/exglobal_atmos_pmgr.sh
+scripts/exglobal_atmos_products.sh @WenMeng-NOAA
+scripts/exglobal_atmos_sfcanl.sh @GeorgeGayno-NOAA
+scripts/exglobal_atmos_tropcy_qc_reloc.sh
+scripts/exglobal_atmos_upp.py @WenMeng-NOAA
+scripts/exglobal_atmos_vminmon.sh @EdwardSafford-NOAA
+scripts/exglobal_cleanup.sh @DavidHuber-NOAA
+scripts/exglobal_diag.sh @RussTreadon-NOAA @CoryMartin-NOAA
+scripts/exglobal_extractvars.sh @EricSinsky-NOAA
+scripts/exglobal_forecast.py @aerorahul
+scripts/exglobal_forecast.sh @aerorahul @WalterKolczynski-NOAA
+scripts/exglobal_marine_analysis_* @guillaumevernieres @AndrewEichmann-NOAA
+scripts/exglobal_marinebmat.py @guillaumevernieres @AndrewEichmann-NOAA
+scripts/exglobal_oceanice_products.py @GwenChen-NOAA
+scripts/exglobal_prep_emissions.py @bbakernoaa
+scripts/exglobal_prep_obs_aero.py @CoryMartin-NOAA
+scripts/exglobal_prep_snow_obs.py @jiaruidong2017
+scripts/exglobal_snow_analysis.py @jiaruidong2017
+scripts/exglobal_stage_ic.py @KateFriedman-NOAA
+
+# ush
+WAM_XML_to_ASCII.pl
+atmos_ensstat.sh
+atmos_extractvars.sh @EricSinsky-NOAA
+bash_utils.sh @WalterKolczynski-NOAA
+calcanl_gfs.py @CoryMartin-NOAA
+calcinc_gfs.py @CoryMartin-NOAA
+compare_f90nml.py @WalterKolczynski-NOAA @aerorahul
+detect_machine.sh @WalterKolczynski-NOAA
+extractvars_tools.sh @EricSinsky-NOAA
+file_utils.sh @WalterKolczynski-NOAA
+forecast_det.sh @aerorahul @WalterKolczynski-NOAA
+forecast_postdet.sh @aerorahul @WalterKolczynski-NOAA
+forecast_predet.sh @aerorahul @WalterKolczynski-NOAA
+fv3gfs_remap_weights.sh
+gaussian_sfcanl.sh @GeorgeGayno-NOAA
+getdump.sh @WalterKolczynski-NOAA @KateFriedman-NOAA
+getges.sh @WalterKolczynski-NOAA @KateFriedman-NOAA
+getgfsnctime @CoryMartin-NOAA
+getncdimlen @CoryMartin-NOAA
+gfs_bfr2gpk.sh @GwenChen-NOAA
+gfs_bufr.sh @GwenChen-NOAA
+gfs_bufr_netcdf.sh @GwenChen-NOAA
+gfs_sndp.sh @BoCui-NOAA
+gfs_truncate_enkf.sh @CoryMartin-NOAA
+global_savefits.sh
+gsi_utils.py @CoryMartin-NOAA
+interp_atmos_master.sh @aerorahul @WenMeng-NOAA @WalterKolczynski-NOAA
+interp_atmos_sflux.sh @aerorahul @WenMeng-NOAA @WalterKolczynski-NOAA
+jjob_header.sh @WalterKolczynski-NOAA
+link_crtm_fix.sh @WalterKolczynski-NOAA
+load_fv3gfs_modules.sh @WalterKolczynski-NOAA @aerorahul
+load_ufsda_modules.sh @WalterKolczynski-NOAA @aerorahul @CoryMartin-NOAA
+load_ufswm_modules.sh @WalterKolczynski-NOAA @aerorahul @JessicaMeixner-NOAA
+merge_fv3_aerosol_tile.py @WalterKolczynski-NOAA
+minmon_xtrct_*.pl @EdwardSafford-NOAA
+module-setup.sh @WalterKolczynski-NOAA @aerorahul
+oceanice_nc2grib2.sh @GwenChen-NOAA
+ocnice_extractvars.sh @EricSinsky-NOAA
+ozn_xtrct.sh @EdwardSafford-NOAA
+parse-storm-type.pl
+parsing_model_configure_FV3.sh @WalterKolczynski-NOAA @aerorahul @junwang-noaa
+parsing_namelists_CICE.sh @WalterKolczynski-NOAA @aerorahul @junwang-noaa @DeniseWorthen
+parsing_namelists_FV3.sh @WalterKolczynski-NOAA @aerorahul @junwang-noaa
+parsing_namelists_FV3_nest.sh @guoqing-noaa
+parsing_namelists_MOM6.sh @WalterKolczynski-NOAA @aerorahul @junwang-noaa @jiandewang
+parsing_namelists_WW3.sh @WalterKolczynski-NOAA @aerorahul @JessicaMeixner-NOAA @sbanihash
+parsing_ufs_configure.sh @WalterKolczynski-NOAA @aerorahul @junwang-noaa
+preamble.sh @WalterKolczynski-NOAA
+product_functions.sh @WalterKolczynski-NOAA @aerorahul
+radmon_*.sh @EdwardSafford-NOAA
+rstprod.sh @WalterKolczynski-NOAA @DavidHuber-NOAA
+run_mpmd.sh @WalterKolczynski-NOAA @aerorahul @DavidHuber-NOAA
+syndat_getjtbul.sh @JiayiPeng-NOAA
+syndat_qctropcy.sh @JiayiPeng-NOAA
+tropcy_relocate.sh @JiayiPeng-NOAA
+tropcy_relocate_extrkr.sh @JiayiPeng-NOAA
+wave_*.sh @JessicaMeixner-NOAA @sbanihash
+
+# ush/python
+ush/python/pygfs/jedi/__init__.py @aerorahul @DavidNew-NOAA
+ush/python/pygfs/jedi/jedi.py @DavidNew-NOAA
+ush/python/pygfs/task/__init__.py @aerorahul
+ush/python/pygfs/task/aero_analysis.py @DavidNew-NOAA @CoryMartin-NOAA
+ush/python/pygfs/task/aero_bmatrix.py @DavidNew-NOAA @CoryMartin-NOAA
+ush/python/pygfs/task/aero_emissions.py @bbakernoaa
+ush/python/pygfs/task/aero_prepobs.py @CoryMartin-NOAA
+ush/python/pygfs/task/analysis.py @DavidNew-NOAA @RussTreadon-NOAA
+ush/python/pygfs/task/archive.py @DavidHuber-NOAA
+ush/python/pygfs/task/atm_analysis.py @DavidNew-NOAA @RussTreadon-NOAA
+ush/python/pygfs/task/atmens_analysis.py @DavidNew-NOAA @RussTreadon-NOAA
+ush/python/pygfs/task/bmatrix.py @DavidNew-NOAA
+ush/python/pygfs/task/gfs_forecast.py @aerorahul
+ush/python/pygfs/task/marine_analysis.py @guillaumevernieres @AndrewEichmann-NOAA
+ush/python/pygfs/task/marine_bmat.py @guillaumevernieres @AndrewEichmann-NOAA
+ush/python/pygfs/task/marine_letkf.py @guillaumevernieres @AndrewEichmann-NOAA
+ush/python/pygfs/task/oceanice_products.py @aerorahul @GwenChen-NOAA
+ush/python/pygfs/task/snow_analysis.py @jiaruidong2017
+ush/python/pygfs/task/snowens_analysis.py @jiaruidong2017
+ush/python/pygfs/task/stage_ic.py @KateFriedman-NOAA
+ush/python/pygfs/task/upp.py @aerorahul @WenMeng-NOAA
+ush/python/pygfs/ufswm/__init__.py @aerorahul
+ush/python/pygfs/ufswm/gfs.py @aerorahul
+ush/python/pygfs/ufswm/ufs.py @aerorahul
+ush/python/pygfs/utils/__init__.py @aerorahul
+ush/python/pygfs/utils/marine_da_utils.py @guillaumevernieres @AndrewEichmann-NOAA
+
+# Specific workflow scripts
+workflow/generate_workflows.sh @DavidHuber-NOAA
+workflow/build_compute.py @DavidHuber-NOAA @aerorahul
diff --git a/.github/ISSUE_TEMPLATE/NCO_bug_report.yml b/.github/ISSUE_TEMPLATE/NCO_bug_report.yml
index cc532058077..79632779aa4 100644
--- a/.github/ISSUE_TEMPLATE/NCO_bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/NCO_bug_report.yml
@@ -1,4 +1,4 @@
-name: NCO Bug report
+name: NCO Bug Report
description: Report something that is incorrect or broken
labels: ["nco-bug", "triage"]
assignees:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 216293781c5..d43d2eb3d72 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,4 +1,4 @@
-name: Bug report
+name: Bug Report
description: Report something that is incorrect or broken
labels: ["bug", "triage"]
@@ -9,6 +9,8 @@ body:
Your bug may already be reported!
Please search on the [Issue tracker](https://github.com/NOAA-EMC/global-workflow/issues) before creating one.
+ Is this actually a workflow bug? If not, please open an issue in the appropriate repository first.
+
- type: textarea
id: current_behavior
attributes:
@@ -46,6 +48,13 @@ body:
validations:
required: true
+ - type: input
+ id: hash
+ attributes:
+ label: What global-workflow hash are you using?
+ validations:
+ required: true
+
- type: textarea
id: reproduction_steps
attributes:
@@ -63,8 +72,9 @@ body:
attributes:
label: Additional information
description: Provide context or any additional information about the bug.
+ placeholder: Optional
validations:
- required: true
+ required: false
- type: textarea
id: propsed_implementation
diff --git a/.github/ISSUE_TEMPLATE/dump_request.yml b/.github/ISSUE_TEMPLATE/dump_request.yml
new file mode 100644
index 00000000000..4481e7f6e05
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/dump_request.yml
@@ -0,0 +1,37 @@
+name: Global Observation Dump Request
+description: Request additional dates be added to a machine's global dump archive (GDA) or introduce experimental dump data to the GDA
+labels: ["Static Data Mgmt"]
+assignees:
+ - KateFriedman-NOAA
+ - WalterKolczynski-NOAA
+
+body:
+ - type: dropdown
+ attributes:
+ label: Machine
+ options:
+ - WCOSS2
+ - Hera/Ursa
+ - Orion/Hercules
+ - Jet
+ multiple: true
+ validations:
+ required: true
+
+ - type: input
+ attributes:
+ label: Start date
+ validations:
+ required: true
+
+ - type: input
+ attributes:
+ label: End date
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Additional information
+ placeholder: |
+ Any additional information needed (experimental obs, etc.)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 3aed58c520c..7e0ddb24590 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -1,4 +1,4 @@
-name: Feature request
+name: Feature Request
description: Request new capability
labels: ["feature", "triage"]
@@ -10,8 +10,7 @@ body:
- Something is wrong or broken
- It is a request from NCO
- It is initiating a production update
- - Files need to be updated or added to fix
-
+ - Files need to be updated in a directory managed by workflow CMs under "glopara"
Please search on the [Issue tracker](https://github.com/NOAA-EMC/global-workflow/issues) to make sure the feature has not already been requested to avoid duplicates.
@@ -34,7 +33,17 @@ body:
id: criteria
attributes:
label: Acceptance Criteria
- placeholder: What does it mean for this issue to be complete?
+ description: |
+ A checklist of criteria to be satisfied before this feature is considered complete.
+
+ Examples:
+ - Forecast with option X completes successfully
+ - File X produced
+ - Output unchanged
+ placeholder: |
+ - [ ] Criterion #1
+ - [ ] Criterion #2
+ - [ ] Criterion #3
validations:
required: true
diff --git a/.github/ISSUE_TEMPLATE/production_update.yml b/.github/ISSUE_TEMPLATE/production_update.yml
index ac9ada505a5..cb1fb588d85 100644
--- a/.github/ISSUE_TEMPLATE/production_update.yml
+++ b/.github/ISSUE_TEMPLATE/production_update.yml
@@ -33,18 +33,19 @@ body:
validations:
required: true
- - type: markdown
+ - type: checkboxes
attributes:
- value: |
- ** Tasks **
- - [ ] Create release branch
- - [ ] Make workflow changes for upgrade in release branch (add additional checklist items as needed)
- - [ ] Create release notes
- - [ ] Cut hand-off tag for CDF
- - [ ] Submit CDF to NCO
- - [ ] Implementation into operations complete
- - [ ] Merge release branch into operational branch
- - [ ] Cut version tag from operational branch
- - [ ] Release new version tag
- - [ ] Announce to users
- - [ ] Update Read-The-Docs operations status version in develop
+ label: Tasks
+ description: List of tasks to complete update. For use after issue is created.
+ options:
+ - label: Create release branch
+ - label: Make workflow changes for upgrade in release branch (add additional checklist items as needed)
+ - label: Create release notes
+ - label: Cut hand-off tag for CDF
+ - label: Submit CDF to NCO
+ - label: Implementation into operations complete
+ - label: Merge release branch into operational branch
+ - label: Cut version tag from operational branch
+ - label: Release new version tag
+ - label: Announce to users
+ - label: Update Read-The-Docs operations status version in develop
diff --git a/.github/ISSUE_TEMPLATE/static_data.yml b/.github/ISSUE_TEMPLATE/static_data.yml
new file mode 100644
index 00000000000..f29f155cf8f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/static_data.yml
@@ -0,0 +1,134 @@
+name: Static Data Update
+description: Request static data be added or updated
+labels: ["Static Data Mgmt"]
+assignees:
+ - KateFriedman-NOAA
+ - WalterKolczynski-NOAA
+
+body:
+ - type: dropdown
+ attributes:
+ label: Type of static data
+ description: |
+ - Fix: large static files needed to run global-workflow
+ - Initial conditions: ICs needed to run one of global-workflow's included tests
+ - Experimental data: Temporary datasets needed for development that will be replaced by other sources for operations (e.g. data that will be in DCOM)
+ options:
+ - Fix
+ - Initial conditions
+ - Experimental data
+ validations:
+ required: true
+
+ - type: dropdown
+ attributes:
+ label: Target directory
+ options:
+ - "Fix: aer"
+ - "Fix: am"
+ - "Fix: chem"
+ - "Fix: cice"
+ - "Fix: cpl"
+ - "Fix: datm"
+ - "Fix: gdas/crtm"
+ - "Fix: gdas/fv3jedi"
+ - "Fix: gdas/gsibec"
+ - "Fix: gldas"
+ - "Fix: glwu"
+ - "Fix: gsi"
+ - "Fix: lut"
+ - "Fix: mom6"
+ - "Fix: orog"
+ - "Fix: raw"
+ - "Fix: reg2grb2"
+ - "Fix: sfc_climo"
+ - "Fix: ugwd"
+ - "Fix: verif"
+ - "Fix: wave"
+ - "Fix: *NEW*"
+ - "ICs: C48/C48 mx500"
+ - "ICs: C96/C48"
+ - "ICs: C96 mx100"
+ - "ICs: C384/C192"
+ - "ICs: C384 mx025"
+ - "ICs: C768 mx025"
+ - "ICs: C1152 mx025"
+ - "ICs: *NEW RESOLUTION*"
+ - "Exp Data: GOCART emissions"
+ - "Exp Data: JEDI obs"
+ - "Exp Data: *NEW*"
+ multiple: true
+ validations:
+ required: true
+
+ - type: checkboxes
+ attributes:
+ label: Type of change
+ options:
+ - label: Update existing files (Version update needed)
+ - label: Remove files (Version update needed)
+ - label: Add new files to existing directory
+ - label: Add new directory (Version update needed if fix)
+ validations:
+ required: true
+
+ - type: dropdown
+ attributes:
+ label: Any workflow changes needed?
+ description: |
+ ANY fix or IC change other than adding files will require at least a new version.
+ options:
+ - No change needed
+ - Fix/IC version update only
+ - Fix/IC version update + additional changes
+ - Non-version changes only
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: |
+ Please list all related issues. If this request requires a workflow update (including a fix version update), please open a *separate* global-workflow issue to track the change.
+ description: Please link any related issues
+ placeholder: NOAA-EMC/repo#1234
+ validations:
+ required: false
+
+ - type: input
+ attributes:
+ label: Pickup location
+ description: |
+ Please provide a path to the data on either Hera or WCOSS.
+
+ If this requires a version update, please provide a CLEAN copy of the *entire* directory, including the structure and unchanged files from current directory.
+
+ If this is just adding additional files, you may provide just the new files. The directory structure should still match the target.
+ placeholder: '/path/to/new/data'
+ validations:
+ required: true
+
+ - type: input
+ attributes:
+ label: Size
+ description: How much data needs to be copied (size of pickup location in MB)?
+ placeholder: '0'
+ validations:
+ required: true
+
+ - type: textarea
+ attributes:
+ label: Additional information
+ description: |
+ Please provide any additional information needed for this request. If this is a new directory, please provide a short description and a point of contact.
+ validations:
+ required: false
+
+ - type: markdown
+ attributes:
+ value: |
+ # Tasks
+ - [ ] Discuss needs with global-workflow developer assigned to request
+ - [ ] Stage a CLEAN copy of data on Hera or WCOSS in the location provided above
+ - [ ] [global-workflow CM] Update data in "glopara"-managed space on supported platforms
+ - [ ] [Fix only] Update "Fix File Management" [spreadsheet](https://docs.google.com/spreadsheets/d/1BeIvcz6TO3If4YCqkUK-oz_kGS9q2wTjwLS-BBemSEY/edit?usp=sharing)
+ - [ ] Make related workflow/component updates (if any)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 3f8fe650652..9e9c9eccfec 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,19 +1,28 @@
-
-
# Description
-
-
+
+
# Type of change
-
-- Bug fix (fixes something broken)
-- New feature (adds functionality)
-- Maintenance (code refactor, clean-up, new CI test, etc.)
+- [ ] Bug fix (fixes something broken)
+- [ ] New feature (adds functionality)
+- [ ] Maintenance (code refactor, clean-up, new CI test, etc.)
# Change characteristics
+
- Is this a breaking change (a change in existing functionality)? YES/NO
- Does this change require a documentation update? YES/NO
- Does this change require an update to any of the following submodules? YES/NO (If YES, please add a link to any PRs that are pending.)
- - [ ] EMC verif-global
- - [ ] GDAS
- - [ ] GFS-utils
- - [ ] GSI
- - [ ] GSI-monitor
- - [ ] GSI-utils
- - [ ] UFS-utils
- - [ ] UFS-weather-model
- - [ ] wxflow
-
+ - [ ] EMC verif-global
+ - [ ] GDAS
+ - [ ] GFS-utils
+ - [ ] GSI
+ - [ ] GSI-monitor
+ - [ ] GSI-utils
+ - [ ] UFS-utils
+ - [ ] UFS-weather-model
+ - [ ] wxflow
# How has this been tested?
',
+ '']
+
+ return '\n'.join(strings)
+
+ def _get_definitions(self) -> str:
+ """
+ Create entities related to the experiment
+ """
+
+ entity = OrderedDict()
+
+ entity['PSLOT'] = self._base['PSLOT']
+
+ entity['ROTDIR'] = self._base['ROTDIR']
+ entity['JOBS_DIR'] = self._base['BASE_JOB']
+
+ entity['MAXTRIES'] = self.rocoto_config['maxtries']
+
+ # Put them all in an XML key-value syntax
+ strings = []
+ for key, value in entity.items():
+ strings.append('\t' + rocoto.create_entity(key, value))
+
+ return '\n'.join(strings)
+
+ def _get_workflow_header(self):
+ """
+ Create the workflow header block
+ """
+
+ scheduler = self._app_config.scheduler
+ cyclethrottle = self.rocoto_config['cyclethrottle']
+ taskthrottle = self.rocoto_config['taskthrottle']
+ verbosity = self.rocoto_config['verbosity']
+
+ expdir = self._base['EXPDIR']
+
+ strings = ['',
+ ']>',
+ '',
+ f'',
+ '',
+ f'\t{expdir}/logs/@Y@m@d@H.log',
+ '',
+ '\t',
+ '']
+
+ return '\n'.join(strings)
+
+ @abstractmethod
+ def get_cycledefs(self):
+ pass
+
+ @staticmethod
+ def _get_workflow_footer():
+ """
+ Generate workflow footer
+ """
+
+ return '\n\n'
+
+ def _assemble_xml(self) -> str:
+
+ strings = [self.preamble,
+ self.definitions,
+ self.header,
+ self.cycledefs,
+ self.tasks,
+ self.footer]
+
+ return ''.join(strings)
+
+ def write(self, xml_file: str = None, crontab_file: str = None):
+ self._write_xml(xml_file=xml_file)
+ self._write_crontab(crontab_file=crontab_file)
+
+ def _write_xml(self, xml_file: str = None) -> None:
+
+ expdir = self._base['EXPDIR']
+ pslot = self._base['PSLOT']
+
+ if xml_file is None:
+ xml_file = f"{expdir}/{pslot}.xml"
+
+ with open(xml_file, 'w') as fh:
+ fh.write(self.xml)
+
+ def _write_crontab(self, crontab_file: str = None, cronint: int = 5) -> None:
+ """
+ Create crontab to execute rocotorun every cronint (5) minutes
+ """
+
+ # No point creating a crontab if rocotorun is not available.
+ rocotoruncmd = find_executable('rocotorun')
+ if rocotoruncmd is None:
+ print('Failed to find rocotorun, crontab will not be created')
+ return
+
+ expdir = self._base['EXPDIR']
+ pslot = self._base['PSLOT']
+
+ rocotorunstr = f'{rocotoruncmd} -d {expdir}/{pslot}.db -w {expdir}/{pslot}.xml'
+ cronintstr = f'*/{cronint} * * * *'
+
+ try:
+ replyto = os.environ['REPLYTO']
+ except KeyError:
+ replyto = ''
+
+ strings = ['',
+ f'#################### {pslot} ####################',
+ f'MAILTO="{replyto}"'
+ ]
+ # AWS need 'SHELL', and 'BASH_ENV' defined, or, the crontab job won't start.
+ if os.environ.get('PW_CSP', None) in ['aws', 'azure', 'google']:
+ strings.extend([f'SHELL="/bin/bash"',
+ f'BASH_ENV="/etc/bashrc"'])
+ strings.extend([f'{cronintstr} {rocotorunstr}',
+ '#################################################################',
+ ''])
+
+ if crontab_file is None:
+ crontab_file = f"{expdir}/{pslot}.crontab"
+
+ with open(crontab_file, 'w') as fh:
+ fh.write('\n'.join(strings))
+
+ return
diff --git a/workflow/rocoto/workflow_xml.py~develop_Feb212025_ba09498 b/workflow/rocoto/workflow_xml.py~develop_Feb212025_ba09498
new file mode 100644
index 00000000000..bed19ad5eea
--- /dev/null
+++ b/workflow/rocoto/workflow_xml.py~develop_Feb212025_ba09498
@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+
+import os
+from distutils.spawn import find_executable
+from datetime import datetime
+from collections import OrderedDict
+from typing import Dict
+from applications.applications import AppConfig
+from rocoto.workflow_tasks import get_wf_tasks
+from wxflow import to_timedelta
+import rocoto.rocoto as rocoto
+from abc import ABC, abstractmethod
+
+
+class RocotoXML(ABC):
+
+ def __init__(self, app_config: AppConfig, rocoto_config: Dict) -> None:
+
+ self._app_config = app_config
+ self.rocoto_config = rocoto_config
+
+ # Use the first config.base (sourced with an arbitrary RUN)
+ self._base = self._app_config.configs[next(iter(self._app_config.configs))]['base']
+ self._base['interval_gdas'] = to_timedelta(f'{self._base["assim_freq"]}H')
+ self._base['interval_gfs'] = to_timedelta(f'{self._base["INTERVAL_GFS"]}H')
+
+ self.preamble = self._get_preamble()
+ self.definitions = self._get_definitions()
+ self.header = self._get_workflow_header()
+ self.cycledefs = self.get_cycledefs()
+ task_list = get_wf_tasks(app_config)
+ self.tasks = '\n'.join(task_list)
+ self.footer = self._get_workflow_footer()
+
+ self.xml = self._assemble_xml()
+
+ @staticmethod
+ def _get_preamble():
+ """
+ Generate preamble for XML
+ """
+
+ strings = ['',
+ '',
+ '']
+
+ return '\n'.join(strings)
+
+ def _get_definitions(self) -> str:
+ """
+ Create entities related to the experiment
+ """
+
+ entity = OrderedDict()
+
+ entity['PSLOT'] = self._base['PSLOT']
+
+ entity['ROTDIR'] = self._base['ROTDIR']
+ entity['JOBS_DIR'] = self._base['BASE_JOB']
+
+ entity['MAXTRIES'] = self.rocoto_config['maxtries']
+
+ # Put them all in an XML key-value syntax
+ strings = []
+ for key, value in entity.items():
+ strings.append('\t' + rocoto.create_entity(key, value))
+
+ return '\n'.join(strings)
+
+ def _get_workflow_header(self):
+ """
+ Create the workflow header block
+ """
+
+ scheduler = self._app_config.scheduler
+ cyclethrottle = self.rocoto_config['cyclethrottle']
+ taskthrottle = self.rocoto_config['taskthrottle']
+ verbosity = self.rocoto_config['verbosity']
+
+ expdir = self._base['EXPDIR']
+
+ strings = ['',
+ ']>',
+ '',
+ f'',
+ '',
+ f'\t{expdir}/logs/@Y@m@d@H.log',
+ '',
+ '\t',
+ '']
+
+ return '\n'.join(strings)
+
+ @abstractmethod
+ def get_cycledefs(self):
+ pass
+
+ @staticmethod
+ def _get_workflow_footer():
+ """
+ Generate workflow footer
+ """
+
+ return '\n\n'
+
+ def _assemble_xml(self) -> str:
+
+ strings = [self.preamble,
+ self.definitions,
+ self.header,
+ self.cycledefs,
+ self.tasks,
+ self.footer]
+
+ return ''.join(strings)
+
+ def write(self, xml_file: str = None, crontab_file: str = None):
+ self._write_xml(xml_file=xml_file)
+ self._write_crontab(crontab_file=crontab_file)
+
+ def _write_xml(self, xml_file: str = None) -> None:
+
+ expdir = self._base['EXPDIR']
+ pslot = self._base['PSLOT']
+
+ if xml_file is None:
+ xml_file = f"{expdir}/{pslot}.xml"
+
+ with open(xml_file, 'w') as fh:
+ fh.write(self.xml)
+
+ def _write_crontab(self, crontab_file: str = None, cronint: int = 5) -> None:
+ """
+ Create crontab to execute rocotorun every cronint (5) minutes
+ """
+
+ # No point creating a crontab if rocotorun is not available.
+ rocotoruncmd = find_executable('rocotorun')
+ if rocotoruncmd is None:
+ print('Failed to find rocotorun, crontab will not be created')
+ return
+
+ expdir = self._base['EXPDIR']
+ pslot = self._base['PSLOT']
+
+ rocotorunstr = f'{rocotoruncmd} -d {expdir}/{pslot}.db -w {expdir}/{pslot}.xml'
+ cronintstr = f'*/{cronint} * * * *'
+
+ try:
+ replyto = os.environ['REPLYTO']
+ except KeyError:
+ replyto = ''
+
+ strings = ['',
+ f'#################### {pslot} ####################',
+ f'MAILTO="{replyto}"'
+ ]
+ # AWS need 'SHELL', and 'BASH_ENV' defined, or, the crontab job won't start.
+ if os.environ.get('PW_CSP', None) in ['aws', 'azure', 'google']:
+ strings.extend([f'SHELL="/bin/bash"',
+ f'BASH_ENV="/etc/bashrc"'])
+ strings.extend([f'{cronintstr} {rocotorunstr}',
+ '#################################################################',
+ ''])
+
+ if crontab_file is None:
+ crontab_file = f"{expdir}/{pslot}.crontab"
+
+ with open(crontab_file, 'w') as fh:
+ fh.write('\n'.join(strings))
+
+ return
diff --git a/workflow/rocoto/workflow_xml.py~gsl_ufs_dev_Mar05_af805b b/workflow/rocoto/workflow_xml.py~gsl_ufs_dev_Mar05_af805b
new file mode 100644
index 00000000000..bed19ad5eea
--- /dev/null
+++ b/workflow/rocoto/workflow_xml.py~gsl_ufs_dev_Mar05_af805b
@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+
+import os
+from distutils.spawn import find_executable
+from datetime import datetime
+from collections import OrderedDict
+from typing import Dict
+from applications.applications import AppConfig
+from rocoto.workflow_tasks import get_wf_tasks
+from wxflow import to_timedelta
+import rocoto.rocoto as rocoto
+from abc import ABC, abstractmethod
+
+
+class RocotoXML(ABC):
+
+ def __init__(self, app_config: AppConfig, rocoto_config: Dict) -> None:
+
+ self._app_config = app_config
+ self.rocoto_config = rocoto_config
+
+ # Use the first config.base (sourced with an arbitrary RUN)
+ self._base = self._app_config.configs[next(iter(self._app_config.configs))]['base']
+ self._base['interval_gdas'] = to_timedelta(f'{self._base["assim_freq"]}H')
+ self._base['interval_gfs'] = to_timedelta(f'{self._base["INTERVAL_GFS"]}H')
+
+ self.preamble = self._get_preamble()
+ self.definitions = self._get_definitions()
+ self.header = self._get_workflow_header()
+ self.cycledefs = self.get_cycledefs()
+ task_list = get_wf_tasks(app_config)
+ self.tasks = '\n'.join(task_list)
+ self.footer = self._get_workflow_footer()
+
+ self.xml = self._assemble_xml()
+
+ @staticmethod
+ def _get_preamble():
+ """
+ Generate preamble for XML
+ """
+
+ strings = ['',
+ '',
+ '']
+
+ return '\n'.join(strings)
+
+ def _get_definitions(self) -> str:
+ """
+ Create entities related to the experiment
+ """
+
+ entity = OrderedDict()
+
+ entity['PSLOT'] = self._base['PSLOT']
+
+ entity['ROTDIR'] = self._base['ROTDIR']
+ entity['JOBS_DIR'] = self._base['BASE_JOB']
+
+ entity['MAXTRIES'] = self.rocoto_config['maxtries']
+
+ # Put them all in an XML key-value syntax
+ strings = []
+ for key, value in entity.items():
+ strings.append('\t' + rocoto.create_entity(key, value))
+
+ return '\n'.join(strings)
+
+ def _get_workflow_header(self):
+ """
+ Create the workflow header block
+ """
+
+ scheduler = self._app_config.scheduler
+ cyclethrottle = self.rocoto_config['cyclethrottle']
+ taskthrottle = self.rocoto_config['taskthrottle']
+ verbosity = self.rocoto_config['verbosity']
+
+ expdir = self._base['EXPDIR']
+
+ strings = ['',
+ ']>',
+ '',
+ f'',
+ '',
+ f'\t{expdir}/logs/@Y@m@d@H.log',
+ '',
+ '\t',
+ '']
+
+ return '\n'.join(strings)
+
+ @abstractmethod
+ def get_cycledefs(self):
+ pass
+
+ @staticmethod
+ def _get_workflow_footer():
+ """
+ Generate workflow footer
+ """
+
+ return '\n\n'
+
+ def _assemble_xml(self) -> str:
+
+ strings = [self.preamble,
+ self.definitions,
+ self.header,
+ self.cycledefs,
+ self.tasks,
+ self.footer]
+
+ return ''.join(strings)
+
+ def write(self, xml_file: str = None, crontab_file: str = None):
+ self._write_xml(xml_file=xml_file)
+ self._write_crontab(crontab_file=crontab_file)
+
+ def _write_xml(self, xml_file: str = None) -> None:
+
+ expdir = self._base['EXPDIR']
+ pslot = self._base['PSLOT']
+
+ if xml_file is None:
+ xml_file = f"{expdir}/{pslot}.xml"
+
+ with open(xml_file, 'w') as fh:
+ fh.write(self.xml)
+
+ def _write_crontab(self, crontab_file: str = None, cronint: int = 5) -> None:
+ """
+ Create crontab to execute rocotorun every cronint (5) minutes
+ """
+
+ # No point creating a crontab if rocotorun is not available.
+ rocotoruncmd = find_executable('rocotorun')
+ if rocotoruncmd is None:
+ print('Failed to find rocotorun, crontab will not be created')
+ return
+
+ expdir = self._base['EXPDIR']
+ pslot = self._base['PSLOT']
+
+ rocotorunstr = f'{rocotoruncmd} -d {expdir}/{pslot}.db -w {expdir}/{pslot}.xml'
+ cronintstr = f'*/{cronint} * * * *'
+
+ try:
+ replyto = os.environ['REPLYTO']
+ except KeyError:
+ replyto = ''
+
+ strings = ['',
+ f'#################### {pslot} ####################',
+ f'MAILTO="{replyto}"'
+ ]
+ # AWS need 'SHELL', and 'BASH_ENV' defined, or, the crontab job won't start.
+ if os.environ.get('PW_CSP', None) in ['aws', 'azure', 'google']:
+ strings.extend([f'SHELL="/bin/bash"',
+ f'BASH_ENV="/etc/bashrc"'])
+ strings.extend([f'{cronintstr} {rocotorunstr}',
+ '#################################################################',
+ ''])
+
+ if crontab_file is None:
+ crontab_file = f"{expdir}/{pslot}.crontab"
+
+ with open(crontab_file, 'w') as fh:
+ fh.write('\n'.join(strings))
+
+ return
diff --git a/workflow/setup_expt.py b/workflow/setup_expt.py
index 3e70df0f021..88958b13b67 100755
--- a/workflow/setup_expt.py
+++ b/workflow/setup_expt.py
@@ -7,8 +7,7 @@
import os
import glob
import shutil
-import warnings
-from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter, SUPPRESS
+from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter, SUPPRESS, ArgumentTypeError
from hosts import Host
@@ -29,236 +28,7 @@ def makedirs_if_missing(dirname):
os.makedirs(dirname)
-def fill_ROTDIR(host, inputs):
- """
- Method to populate the ROTDIR for supported modes.
- INPUTS:
- host: host object from class Host
- inputs: user inputs to setup_expt.py
- """
-
- fill_modes = {
- 'cycled': fill_ROTDIR_cycled,
- 'forecast-only': fill_ROTDIR_forecasts
- }
-
- try:
- fill_modes[inputs.mode](host, inputs)
- except KeyError:
- raise NotImplementedError(f'{inputs.mode} is not a supported mode.\n' +
- 'Currently supported modes are:\n' +
- f'{" | ".join(fill_modes.keys())}')
-
- return
-
-
-def fill_ROTDIR_cycled(host, inputs):
- """
- Implementation of 'fill_ROTDIR' for cycled mode
- """
-
- rotdir = os.path.join(inputs.comroot, inputs.pslot)
-
- do_ocean = do_ice = do_med = False
-
- if 'S2S' in inputs.app:
- do_ocean = do_ice = do_med = True
-
- if inputs.icsdir is None:
- warnings.warn("User did not provide '--icsdir' to stage initial conditions")
- return
-
- rdatestr = datetime_to_YMDH(inputs.idate - to_timedelta('T06H'))
- idatestr = datetime_to_YMDH(inputs.idate)
-
- # Test if we are using the new COM structure or the old flat one for ICs
- if inputs.start in ['warm']:
- pathstr = os.path.join(inputs.icsdir, f'{inputs.run}.{rdatestr[:8]}',
- rdatestr[8:], 'model_data', 'atmos')
- else:
- pathstr = os.path.join(inputs.icsdir, f'{inputs.run}.{idatestr[:8]}',
- idatestr[8:], 'model_data', 'atmos')
-
- if os.path.isdir(pathstr):
- flat_structure = False
- else:
- flat_structure = True
-
- # Destination always uses the new COM structure
- # These should match the templates defined in config.com
- if inputs.start in ['warm']:
- dst_atm_dir = os.path.join('model_data', 'atmos', 'restart')
- dst_med_dir = os.path.join('model_data', 'med', 'restart')
- else:
- dst_atm_dir = os.path.join('model_data', 'atmos', 'input')
- dst_med_dir = '' # no mediator files for a "cold start"
- do_med = False
- dst_ocn_rst_dir = os.path.join('model_data', 'ocean', 'restart')
- dst_ocn_anl_dir = os.path.join('analysis', 'ocean')
- dst_ice_rst_dir = os.path.join('model_data', 'ice', 'restart')
- dst_ice_anl_dir = os.path.join('analysis', 'ice')
- dst_atm_anl_dir = os.path.join('analysis', 'atmos')
-
- if flat_structure:
- # ICs are in the old flat COM structure
- if inputs.start in ['warm']: # This is warm start experiment
- src_atm_dir = os.path.join('atmos', 'RESTART')
- src_med_dir = os.path.join('med', 'RESTART')
- elif inputs.start in ['cold']: # This is a cold start experiment
- src_atm_dir = os.path.join('atmos', 'INPUT')
- src_med_dir = '' # no mediator files for a "cold start"
- do_med = False
- # ocean and ice have the same filenames for warm and cold
- src_ocn_rst_dir = os.path.join('ocean', 'RESTART')
- src_ocn_anl_dir = 'ocean'
- src_ice_rst_dir = os.path.join('ice', 'RESTART')
- src_ice_anl_dir = dst_ice_anl_dir
- src_atm_anl_dir = 'atmos'
- else:
- src_atm_dir = dst_atm_dir
- src_med_dir = dst_med_dir
- src_ocn_rst_dir = dst_ocn_rst_dir
- src_ocn_anl_dir = dst_ocn_anl_dir
- src_ice_rst_dir = dst_ice_rst_dir
- src_ice_anl_dir = dst_ice_anl_dir
- src_atm_anl_dir = dst_atm_anl_dir
-
- def link_files_from_src_to_dst(src_dir, dst_dir):
- files = os.listdir(src_dir)
- for fname in files:
- os.symlink(os.path.join(src_dir, fname),
- os.path.join(dst_dir, fname))
- return
-
- # Link ensemble member initial conditions
- if inputs.nens > 0:
- previous_cycle_dir = f'enkf{inputs.run}.{rdatestr[:8]}/{rdatestr[8:]}'
- current_cycle_dir = f'enkf{inputs.run}.{idatestr[:8]}/{idatestr[8:]}'
-
- for ii in range(1, inputs.nens + 1):
- memdir = f'mem{ii:03d}'
- # Link atmospheric files
- if inputs.start in ['warm']:
- dst_dir = os.path.join(rotdir, previous_cycle_dir, memdir, dst_atm_dir)
- src_dir = os.path.join(inputs.icsdir, previous_cycle_dir, memdir, src_atm_dir)
- elif inputs.start in ['cold']:
- dst_dir = os.path.join(rotdir, current_cycle_dir, memdir, dst_atm_dir)
- src_dir = os.path.join(inputs.icsdir, current_cycle_dir, memdir, src_atm_dir)
- makedirs_if_missing(dst_dir)
- link_files_from_src_to_dst(src_dir, dst_dir)
-
- # Link ocean files
- if do_ocean:
- dst_dir = os.path.join(rotdir, previous_cycle_dir, memdir, dst_ocn_rst_dir)
- src_dir = os.path.join(inputs.icsdir, previous_cycle_dir, memdir, src_ocn_rst_dir)
- makedirs_if_missing(dst_dir)
- link_files_from_src_to_dst(src_dir, dst_dir)
-
- # First 1/2 cycle needs a MOM6 increment
- incfile = f'enkf{inputs.run}.t{idatestr[8:]}z.ocninc.nc'
- src_file = os.path.join(inputs.icsdir, current_cycle_dir, memdir, src_ocn_anl_dir, incfile)
- dst_file = os.path.join(rotdir, current_cycle_dir, memdir, dst_ocn_anl_dir, incfile)
- makedirs_if_missing(os.path.join(rotdir, current_cycle_dir, memdir, dst_ocn_anl_dir))
- os.symlink(src_file, dst_file)
-
- # Link ice files
- if do_ice:
- dst_dir = os.path.join(rotdir, previous_cycle_dir, memdir, dst_ice_rst_dir)
- src_dir = os.path.join(inputs.icsdir, previous_cycle_dir, memdir, src_ice_rst_dir)
- makedirs_if_missing(dst_dir)
- link_files_from_src_to_dst(src_dir, dst_dir)
-
- # Link mediator files
- if do_med:
- dst_dir = os.path.join(rotdir, previous_cycle_dir, memdir, dst_med_dir)
- src_dir = os.path.join(inputs.icsdir, previous_cycle_dir, memdir, src_med_dir)
- makedirs_if_missing(dst_dir)
- link_files_from_src_to_dst(src_dir, dst_dir)
-
- # Link deterministic initial conditions
- previous_cycle_dir = f'{inputs.run}.{rdatestr[:8]}/{rdatestr[8:]}'
- current_cycle_dir = f'{inputs.run}.{idatestr[:8]}/{idatestr[8:]}'
-
- # Link atmospheric files
- if inputs.start in ['warm']:
- dst_dir = os.path.join(rotdir, previous_cycle_dir, dst_atm_dir)
- src_dir = os.path.join(inputs.icsdir, previous_cycle_dir, src_atm_dir)
- elif inputs.start in ['cold']:
- dst_dir = os.path.join(rotdir, current_cycle_dir, dst_atm_dir)
- src_dir = os.path.join(inputs.icsdir, current_cycle_dir, src_atm_dir)
-
- makedirs_if_missing(dst_dir)
- link_files_from_src_to_dst(src_dir, dst_dir)
-
- # Link ocean files
- if do_ocean:
- dst_dir = os.path.join(rotdir, previous_cycle_dir, dst_ocn_rst_dir)
- src_dir = os.path.join(inputs.icsdir, previous_cycle_dir, src_ocn_rst_dir)
- makedirs_if_missing(dst_dir)
- link_files_from_src_to_dst(src_dir, dst_dir)
-
- # First 1/2 cycle needs a MOM6 increment
- incfile = f'{inputs.run}.t{idatestr[8:]}z.ocninc.nc'
- src_file = os.path.join(inputs.icsdir, current_cycle_dir, src_ocn_anl_dir, incfile)
- dst_file = os.path.join(rotdir, current_cycle_dir, dst_ocn_anl_dir, incfile)
- makedirs_if_missing(os.path.join(rotdir, current_cycle_dir, dst_ocn_anl_dir))
- os.symlink(src_file, dst_file)
-
- # Link ice files
- if do_ice:
- # First 1/2 cycle needs a CICE6 analysis restart
- src_dir = os.path.join(inputs.icsdir, current_cycle_dir, src_ice_anl_dir)
- dst_dir = os.path.join(rotdir, current_cycle_dir, src_ice_anl_dir)
- makedirs_if_missing(dst_dir)
- link_files_from_src_to_dst(src_dir, dst_dir)
-
- # Link mediator files
- if do_med:
- dst_dir = os.path.join(rotdir, previous_cycle_dir, dst_med_dir)
- src_dir = os.path.join(inputs.icsdir, previous_cycle_dir, src_med_dir)
- makedirs_if_missing(dst_dir)
- link_files_from_src_to_dst(src_dir, dst_dir)
-
- # Link bias correction and radiance diagnostics files
- src_dir = os.path.join(inputs.icsdir, current_cycle_dir, src_atm_anl_dir)
- dst_dir = os.path.join(rotdir, current_cycle_dir, dst_atm_anl_dir)
- makedirs_if_missing(dst_dir)
- for ftype in ['abias', 'abias_pc', 'abias_air', 'radstat']:
- fname = f'{inputs.run}.t{idatestr[8:]}z.{ftype}'
- src_file = os.path.join(src_dir, fname)
- if os.path.exists(src_file):
- os.symlink(src_file, os.path.join(dst_dir, fname))
- # First 1/2 cycle also needs a atmos increment if doing warm start
- if inputs.start in ['warm']:
- for ftype in ['atmi003.nc', 'atminc.nc', 'atmi009.nc']:
- fname = f'{inputs.run}.t{idatestr[8:]}z.{ftype}'
- src_file = os.path.join(src_dir, fname)
- if os.path.exists(src_file):
- os.symlink(src_file, os.path.join(dst_dir, fname))
- if inputs.nens > 0:
- current_cycle_dir = f'enkf{inputs.run}.{idatestr[:8]}/{idatestr[8:]}'
- for ii in range(1, inputs.nens + 1):
- memdir = f'mem{ii:03d}'
- src_dir = os.path.join(inputs.icsdir, current_cycle_dir, memdir, src_atm_anl_dir)
- dst_dir = os.path.join(rotdir, current_cycle_dir, memdir, dst_atm_anl_dir)
- makedirs_if_missing(dst_dir)
- for ftype in ['ratmi003.nc', 'ratminc.nc', 'ratmi009.nc']:
- fname = f'enkf{inputs.run}.t{idatestr[8:]}z.{ftype}'
- src_file = os.path.join(src_dir, fname)
- if os.path.exists(src_file):
- os.symlink(src_file, os.path.join(dst_dir, fname))
-
- return
-
-
-def fill_ROTDIR_forecasts(host, inputs):
- """
- Implementation of 'fill_ROTDIR' for forecast-only mode
- """
- print('forecast-only mode treats ICs differently and cannot be staged here')
-
-
-def fill_EXPDIR(inputs):
+def fill_expdir(inputs):
"""
Method to copy config files from workflow to experiment directory
INPUTS:
@@ -279,52 +49,74 @@ def fill_EXPDIR(inputs):
def update_configs(host, inputs):
def _update_defaults(dict_in: dict) -> dict:
+ # Given an input dict_in of the form
+ # {defaults: {config_name: {var1: value1, ...}, }, config_name: {var1: value1, ...}}
+ # Replace values in ['defaults']['config_name']['var1'] with ['config_name']['var1']
+ # and return the ['defaults'] subdictionary as its own new dictionary.
defaults = dict_in.pop('defaults', AttrDict())
+ if 'defaults' in defaults:
+ _update_defaults(defaults)
defaults.update(dict_in)
return defaults
- # Read in the YAML file to fill out templates and override host defaults
+ # Convert the inputs to an AttrDict
data = AttrDict(host.info, **inputs.__dict__)
+
+ # Read in the YAML file to fill out templates
data.HOMEgfs = _top
yaml_path = inputs.yaml
if not os.path.exists(yaml_path):
- raise IOError(f'YAML file does not exist, check path:' + yaml_path)
- yaml_dict = _update_defaults(AttrDict(parse_j2yaml(yaml_path, data)))
+ raise FileNotFoundError(f'YAML file does not exist, check path: {yaml_path}')
+ yaml_dict = parse_j2yaml(yaml_path, data)
+
+ # yaml_dict is in the form {defaults: {key1: val1, ...}, base: {key1: val1, ...}, ...}
+ # _update_defaults replaces any keys/values in defaults with matching keys in base
+ yaml_dict = _update_defaults(yaml_dict)
+ # Override the YAML defaults with the host-specific capabilities
# First update config.base
edit_baseconfig(host, inputs, yaml_dict)
- # loop over other configs and update them
+ # Update stage config
+ stage_dict = {
+ "@ICSDIR@": inputs.icsdir
+ }
+ host_dict = get_template_dict(host.info)
+ stage_dict = dict(stage_dict, **host_dict)
+ stage_input = f'{inputs.configdir}/config.stage_ic'
+ stage_output = f'{inputs.expdir}/{inputs.pslot}/config.stage_ic'
+ edit_config(stage_input, stage_output, host_dict, stage_dict)
+
+ # Loop over other configs and update them with defaults
for cfg in yaml_dict.keys():
if cfg == 'base':
continue
cfg_file = f'{inputs.expdir}/{inputs.pslot}/config.{cfg}'
cfg_dict = get_template_dict(yaml_dict[cfg])
- edit_config(cfg_file, cfg_file, cfg_dict)
+ edit_config(cfg_file, cfg_file, host_dict, cfg_dict)
return
def edit_baseconfig(host, inputs, yaml_dict):
"""
- Parses and populates the templated `HOMEgfs/parm/config//config.base`
+ Parses and populates the templated `HOMEgfs/parm/config//config.base`
to `EXPDIR/pslot/config.base`
"""
- tmpl_dict = {
+ # Create base_dict which holds templated variables to be written to config.base
+ base_dict = {
"@HOMEgfs@": _top,
"@MACHINE@": host.machine.upper()}
- # Replace host related items
- extend_dict = get_template_dict(host.info)
- tmpl_dict = dict(tmpl_dict, **extend_dict)
-
if inputs.start in ["warm"]:
is_warm_start = ".true."
elif inputs.start in ["cold"]:
is_warm_start = ".false."
+ else:
+ raise ValueError(f"Invalid start type: {inputs.start}")
- extend_dict = dict()
+ # Construct a dictionary from user inputs
extend_dict = {
"@PSLOT@": inputs.pslot,
"@SDATE@": datetime_to_YMDH(inputs.idate),
@@ -335,39 +127,42 @@ def edit_baseconfig(host, inputs, yaml_dict):
"@COMROOT@": inputs.comroot,
"@EXP_WARM_START@": is_warm_start,
"@MODE@": inputs.mode,
- "@gfs_cyc@": inputs.gfs_cyc,
+ "@INTERVAL_GFS@": inputs.interval,
+ "@SDATE_GFS@": datetime_to_YMDH(inputs.sdate_gfs),
"@APP@": inputs.app,
"@NMEM_ENS@": getattr(inputs, 'nens', 0)
}
- tmpl_dict = dict(tmpl_dict, **extend_dict)
- extend_dict = dict()
if getattr(inputs, 'nens', 0) > 0:
- extend_dict = {
- "@CASEENS@": f'C{inputs.resensatmos}',
- }
- tmpl_dict = dict(tmpl_dict, **extend_dict)
+ extend_dict['@CASEENS@'] = f'C{inputs.resensatmos}'
- extend_dict = dict()
if inputs.mode in ['cycled']:
- extend_dict = {
- "@DOHYBVAR@": "YES" if inputs.nens > 0 else "NO",
- }
- tmpl_dict = dict(tmpl_dict, **extend_dict)
+ extend_dict["@DOHYBVAR@"] = "YES" if inputs.nens > 0 else "NO"
- try:
- tmpl_dict = dict(tmpl_dict, **get_template_dict(yaml_dict['base']))
- except KeyError:
- pass
+ # Further extend/redefine base_dict with extend_dict
+ base_dict = dict(base_dict, **extend_dict)
+
+ # Add/override 'base'-specific declarations in base_dict
+ if 'base' in yaml_dict:
+ base_dict = dict(base_dict, **get_template_dict(yaml_dict['base']))
base_input = f'{inputs.configdir}/config.base'
base_output = f'{inputs.expdir}/{inputs.pslot}/config.base'
- edit_config(base_input, base_output, tmpl_dict)
+ edit_config(base_input, base_output, host.info, base_dict)
return
-def edit_config(input_config, output_config, config_dict):
+def edit_config(input_config, output_config, host_info, config_dict):
+ """
+ Given a templated input_config filename, parse it based on config_dict and
+ host_info and write it out to the output_config filename.
+ """
+
+ # Override defaults with machine-specific capabilties
+ # e.g. some machines are not able to run metp jobs
+ host_dict = get_template_dict(host_info)
+ config_dict = dict(config_dict, **host_dict)
# Read input config
with open(input_config, 'rt') as fi:
@@ -391,9 +186,17 @@ def edit_config(input_config, output_config, config_dict):
def get_template_dict(input_dict):
+ # Reads a templated input dictionary and updates the output
+
output_dict = dict()
+
for key, value in input_dict.items():
- output_dict[f'@{key}@'] = value
+ # In some cases, the same config may be templated twice
+ # Prevent adding additional "@"s
+ if "@" in key:
+ output_dict[f'{key}'] = value
+ else:
+ output_dict[f'@{key}@'] = value
return output_dict
@@ -405,6 +208,19 @@ def input_args(*argv):
ufs_apps = ['ATM', 'ATMA', 'ATMW', 'S2S', 'S2SA', 'S2SW', 'S2SWA']
+ def _validate_interval(interval_str):
+ err_msg = f'must be a non-negative integer multiple of 6 ({interval_str} given)'
+ try:
+ interval = int(interval_str)
+ except ValueError:
+ raise ArgumentTypeError(err_msg)
+
+ # This assumes the gdas frequency (assim_freq) is 6h
+ # If this changes, the modulus needs to as well
+ if interval < 0 or interval % 6 != 0:
+ raise ArgumentTypeError(err_msg)
+ return interval
+
def _common_args(parser):
parser.add_argument('--pslot', help='parallel experiment name',
type=str, required=False, default='test')
@@ -418,7 +234,10 @@ def _common_args(parser):
type=str, required=False, default=os.getenv('HOME'))
parser.add_argument('--idate', help='starting date of experiment, initial conditions must exist!',
required=True, type=lambda dd: to_datetime(dd))
- parser.add_argument('--edate', help='end date experiment', required=True, type=lambda dd: to_datetime(dd))
+ parser.add_argument('--edate', help='end date experiment', required=False, type=lambda dd: to_datetime(dd))
+ parser.add_argument('--account', help='HPC account to use; default is host-dependent', required=False, default=os.getenv('HPC_ACCOUNT'))
+ parser.add_argument('--interval', help='frequency of forecast (in hours); must be a multiple of 6', type=_validate_interval, required=False, default=6)
+ parser.add_argument('--icsdir', help='full path to user initial condition directory', type=str, required=False, default='')
parser.add_argument('--overwrite', help='overwrite previously created experiment (if it exists)',
action='store_true', required=False)
return parser
@@ -435,25 +254,21 @@ def _gfs_args(parser):
return parser
def _gfs_cycled_args(parser):
- parser.add_argument('--icsdir', help='full path to initial condition directory', type=str, required=False, default=None)
parser.add_argument('--app', help='UFS application', type=str,
choices=ufs_apps, required=False, default='ATM')
- parser.add_argument('--gfs_cyc', help='cycles to run forecast', type=int,
- choices=[0, 1, 2, 4], default=1, required=False)
+ parser.add_argument('--sdate_gfs', help='date to start GFS', type=lambda dd: to_datetime(dd), required=False, default=None)
return parser
- def _gfs_or_gefs_ensemble_args(parser):
+ def _any_ensemble_args(parser):
parser.add_argument('--resensatmos', help='atmosphere resolution of the ensemble model forecast',
type=int, required=False, default=192)
parser.add_argument('--nens', help='number of ensemble members',
type=int, required=False, default=20)
return parser
- def _gfs_or_gefs_forecast_args(parser):
+ def _any_forecast_args(parser):
parser.add_argument('--app', help='UFS application', type=str,
choices=ufs_apps, required=False, default='ATM')
- parser.add_argument('--gfs_cyc', help='Number of forecasts per day', type=int,
- choices=[1, 2, 4], default=1, required=False)
return parser
def _gefs_args(parser):
@@ -463,8 +278,15 @@ def _gefs_args(parser):
default=os.path.join(_top, 'parm/config/gefs'))
parser.add_argument('--yaml', help='Defaults to substitute from', type=str, required=False,
default=os.path.join(_top, 'parm/config/gefs/yaml/defaults.yaml'))
- parser.add_argument('--icsdir', help='full path to initial condition directory [temporary hack in place for testing]',
- type=str, required=False, default=None)
+ return parser
+
+ def _sfs_args(parser):
+ parser.add_argument('--start', help='restart mode: warm or cold', type=str,
+ choices=['warm', 'cold'], required=False, default='cold')
+ parser.add_argument('--configdir', help=SUPPRESS, type=str, required=False,
+ default=os.path.join(_top, 'parm/config/sfs'))
+ parser.add_argument('--yaml', help='Defaults to substitute from', type=str, required=False,
+ default=os.path.join(_top, 'parm/config/sfs/yaml/defaults.yaml'))
return parser
description = """
@@ -480,6 +302,7 @@ def _gefs_args(parser):
sysparser = parser.add_subparsers(dest='system')
gfs = sysparser.add_parser('gfs', help='arguments for GFS')
gefs = sysparser.add_parser('gefs', help='arguments for GEFS')
+ sfs = sysparser.add_parser('sfs', help='arguments for SFS')
gfsmodeparser = gfs.add_subparsers(dest='mode')
gfscycled = gfsmodeparser.add_parser('cycled', help='arguments for cycled mode')
@@ -488,8 +311,11 @@ def _gefs_args(parser):
gefsmodeparser = gefs.add_subparsers(dest='mode')
gefsforecasts = gefsmodeparser.add_parser('forecast-only', help='arguments for forecast-only mode')
+ sfsmodeparser = sfs.add_subparsers(dest='mode')
+ sfsforecasts = sfsmodeparser.add_parser('forecast-only', help='arguments for forecast-only mode')
+
# Common arguments across all modes
- for subp in [gfscycled, gfsforecasts, gefsforecasts]:
+ for subp in [gfscycled, gfsforecasts, gefsforecasts, sfsforecasts]:
subp = _common_args(subp)
# GFS-only arguments
@@ -497,12 +323,12 @@ def _gefs_args(parser):
subp = _gfs_args(subp)
# ensemble-only arguments
- for subp in [gfscycled, gefsforecasts]:
- subp = _gfs_or_gefs_ensemble_args(subp)
+ for subp in [gfscycled, gefsforecasts, sfsforecasts]:
+ subp = _any_ensemble_args(subp)
# GFS/GEFS forecast-only additional arguments
- for subp in [gfsforecasts, gefsforecasts]:
- subp = _gfs_or_gefs_forecast_args(subp)
+ for subp in [gfsforecasts, gefsforecasts, sfsforecasts]:
+ subp = _any_forecast_args(subp)
# cycled mode additional arguments
for subp in [gfscycled]:
@@ -512,7 +338,32 @@ def _gefs_args(parser):
for subp in [gefsforecasts]:
subp = _gefs_args(subp)
- return parser.parse_args(list(*argv) if len(argv) else None)
+ # SFS arguments
+ for subp in [sfsforecasts]:
+ subp = _sfs_args(subp)
+
+ inputs = parser.parse_args(list(*argv) if len(argv) else None)
+
+ # Validate dates
+ if inputs.edate is None:
+ inputs.edate = inputs.idate
+
+ if inputs.edate < inputs.idate:
+ raise ArgumentTypeError(f'edate ({inputs.edate}) cannot be before idate ({inputs.idate})')
+
+ # For forecast-only, GFS starts in the first cycle
+ if not hasattr(inputs, 'sdate_gfs'):
+ inputs.sdate_gfs = inputs.idate
+
+ # For cycled, GFS starts after the half-cycle
+ if inputs.sdate_gfs is None:
+ inputs.sdate_gfs = inputs.idate + to_timedelta("6H")
+
+ if inputs.interval > 0:
+ if inputs.sdate_gfs < inputs.idate or inputs.sdate_gfs > inputs.edate:
+ raise ArgumentTypeError(f'sdate_gfs ({inputs.sdate_gfs}) must be between idate ({inputs.idate}) and edate ({inputs.edate})')
+
+ return inputs
def query_and_clean(dirname, force_clean=False):
@@ -524,7 +375,7 @@ def query_and_clean(dirname, force_clean=False):
if os.path.exists(dirname):
print(f'\ndirectory already exists in {dirname}')
if force_clean:
- overwrite = True
+ overwrite = "YES"
print(f'removing directory ........ {dirname}\n')
else:
overwrite = input('Do you wish to over-write [y/N]: ')
@@ -570,6 +421,10 @@ def main(*argv):
validate_user_request(host, user_inputs)
+ # Update the default host account if the user supplied one
+ if user_inputs.account is not None:
+ host.info.ACCOUNT = user_inputs.account
+
# Determine ocean resolution if not provided
if user_inputs.resdetocean <= 0:
user_inputs.resdetocean = get_ocean_resolution(user_inputs.resdetatmos)
@@ -582,11 +437,10 @@ def main(*argv):
if create_rotdir:
makedirs_if_missing(rotdir)
- fill_ROTDIR(host, user_inputs)
if create_expdir:
makedirs_if_missing(expdir)
- fill_EXPDIR(user_inputs)
+ fill_expdir(user_inputs)
update_configs(host, user_inputs)
print(f"*" * 100)
diff --git a/workflow/tests/__init__.py b/workflow/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/workflow/tests/test_configuration.py b/workflow/tests/test_configuration.py
new file mode 100644
index 00000000000..32e40c67af0
--- /dev/null
+++ b/workflow/tests/test_configuration.py
@@ -0,0 +1,32 @@
+import sys
+from wxflow import Configuration
+
+
+expdir = sys.argv[1]
+
+cfg = Configuration(expdir)
+
+print(f'experiment dir: {cfg.config_dir}')
+
+print('configuration files ...')
+line_separator = '\n' # \escapes are not allowed inside f-strings
+print(f'{line_separator.join(cfg.config_files)}')
+
+print(f'config.base: {cfg.find_config("config.base")}')
+
+print('*' * 80)
+print('config.base ...')
+base = cfg.parse_config('config.base')
+cfg.print_config('config.base')
+print(type(base))
+print(base.HOMEgfs)
+
+print('*' * 80)
+print('config.anal...')
+cfg.print_config(['config.base', 'config.anal'])
+
+
+print('*' * 80)
+print('config.efcs ...')
+configs = ['config.base', 'config.fcst', 'config.efcs']
+cfg.print_config(configs)
diff --git a/workflow/tests/test_hosts.py b/workflow/tests/test_hosts.py
new file mode 100644
index 00000000000..b9fa969e143
--- /dev/null
+++ b/workflow/tests/test_hosts.py
@@ -0,0 +1,17 @@
+from hosts import Host
+
+print(f'supported hosts are: {", ".join(Host.SUPPORTED_HOSTS)}')
+
+print(f'host detected as: {Host.detect}')
+print(f'scheduler on host: {Host().scheduler}')
+
+print('initializing host ...')
+host = Host()
+
+print(f'hostname: {host.machine}')
+
+print(f'scheduler on host: {host.scheduler}')
+
+print('host information ...')
+line_separator = '\n' # \escapes are not allowed inside f-strings
+print(f'{line_separator.join(f"{key}: {host.info[key]}" for key in host.info.keys())}')
diff --git a/workflow/tests/test_tasks.py b/workflow/tests/test_tasks.py
new file mode 100644
index 00000000000..7e7e7eb5e7b
--- /dev/null
+++ b/workflow/tests/test_tasks.py
@@ -0,0 +1,84 @@
+from rocoto.tasks import Tasks
+
+
+class TestTasks:
+
+ '''
+ Tasks class tests
+
+ Note: this is currently only testing a small fraction of the class.
+ '''
+
+ def test_job_groups(self):
+ test_array = list(range(0, 24))
+
+ # Test simple splitting with no breakpoints
+ test_groups = [{'fhrs': [0, 1, 2, 3, 4, 5], 'seg': 0},
+ {'fhrs': [6, 7, 8, 9, 10, 11], 'seg': 0},
+ {'fhrs': [12, 13, 14, 15, 16, 17], 'seg': 0},
+ {'fhrs': [18, 19, 20, 21, 22, 23], 'seg': 0}]
+ assert Tasks.get_job_groups(fhrs=test_array, ngroups=4) == test_groups
+
+ # Test with a break point that aligns with normal split point
+ test_groups = [{'fhrs': [0, 1, 2, 3, 4, 5], 'seg': 0},
+ {'fhrs': [6, 7, 8, 9, 10, 11], 'seg': 0},
+ {'fhrs': [12, 13, 14, 15, 16, 17], 'seg': 1},
+ {'fhrs': [18, 19, 20, 21, 22, 23], 'seg': 1}]
+ assert Tasks.get_job_groups(fhrs=test_array, ngroups=4, breakpoints=[11]) == test_groups
+
+ # Test with a break point not at a normal split point
+ test_groups = [{'fhrs': [0, 1, 2, 3, 4, 5, 6, 7], 'seg': 0},
+ {'fhrs': [8, 9, 10, 11, 12, 13, 14], 'seg': 0},
+ {'fhrs': [15, 16, 17, 18, 19], 'seg': 1},
+ {'fhrs': [20, 21, 22, 23], 'seg': 1}]
+ assert Tasks.get_job_groups(fhrs=test_array, ngroups=4, breakpoints=[14]) == test_groups
+
+ # Test highly skewed break point
+ test_groups = [{'fhrs': [0, 1, 2, 3, 4, 5, 6, 7], 'seg': 0},
+ {'fhrs': [8, 9, 10, 11, 12, 13, 14, 15], 'seg': 0},
+ {'fhrs': [16, 17, 18, 19, 20, 21, 22], 'seg': 0},
+ {'fhrs': [23], 'seg': 1}]
+ assert Tasks.get_job_groups(fhrs=test_array, ngroups=4, breakpoints=[22]) == test_groups
+
+ # Test with two break points that align
+ test_groups = [{'fhrs': [0, 1, 2, 3, 4, 5], 'seg': 0},
+ {'fhrs': [6, 7, 8, 9, 10, 11], 'seg': 0},
+ {'fhrs': [12, 13, 14, 15, 16, 17], 'seg': 1},
+ {'fhrs': [18, 19, 20, 21, 22, 23], 'seg': 2}]
+ assert Tasks.get_job_groups(fhrs=test_array, ngroups=4, breakpoints=[11, 17]) == test_groups
+
+ # Test with two skewed break points
+ test_groups = [{'fhrs': [0, 1], 'seg': 0},
+ {'fhrs': [2, 3, 4, 5, 6, 7], 'seg': 1},
+ {'fhrs': [8, 9, 10, 11, 12], 'seg': 1},
+ {'fhrs': [13, 14, 15, 16, 17], 'seg': 1},
+ {'fhrs': [18, 19, 20, 21, 22], 'seg': 1},
+ {'fhrs': [23], 'seg': 2}]
+ assert Tasks.get_job_groups(fhrs=test_array, ngroups=6, breakpoints=[1, 22]) == test_groups
+
+ # Test slightly irregular break points
+ test_groups = [{'fhrs': [0, 1, 2, 3], 'seg': 0},
+ {'fhrs': [4, 5, 6], 'seg': 0},
+ {'fhrs': [7, 8, 9, 10], 'seg': 1},
+ {'fhrs': [11, 12, 13, 14], 'seg': 1},
+ {'fhrs': [15, 16, 17, 18], 'seg': 1},
+ {'fhrs': [19, 20, 21, 22, 23], 'seg': 2}]
+ assert Tasks.get_job_groups(fhrs=test_array, ngroups=6, breakpoints=[6, 18]) == test_groups
+
+ # Test more groups than fhrs available
+ test_array = list(range(0, 6))
+ test_groups = [{'fhrs': [0], 'seg': 0},
+ {'fhrs': [1], 'seg': 0},
+ {'fhrs': [2], 'seg': 0},
+ {'fhrs': [3], 'seg': 0},
+ {'fhrs': [4], 'seg': 0},
+ {'fhrs': [5], 'seg': 0}]
+ assert Tasks.get_job_groups(fhrs=test_array, ngroups=15) == test_groups
+
+ def test_multiply_HMS(self):
+ assert Tasks.multiply_HMS('00:10:00', 2) == '00:20:00'
+ assert Tasks.multiply_HMS('00:30:00', 10) == '05:00:00'
+ assert Tasks.multiply_HMS('01:15:00', 4) == '05:00:00'
+ assert Tasks.multiply_HMS('00:05:00', 1.5) == '00:07:30'
+ assert Tasks.multiply_HMS('00:40:00', 2.5) == '01:40:00'
+ assert Tasks.multiply_HMS('00:10:00', 1) == '00:10:00'
diff --git a/workflow/wxflow b/workflow/wxflow
new file mode 120000
index 00000000000..7ea96a12bfc
--- /dev/null
+++ b/workflow/wxflow
@@ -0,0 +1 @@
+../sorc/wxflow/src/wxflow
\ No newline at end of file