model-config-tests 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/PKG-INFO +18 -5
  2. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/README.md +17 -4
  3. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/pyproject.toml +3 -1
  4. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/_version.py +3 -3
  5. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/config_tests/conftest.py +12 -0
  6. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/config_tests/qa/test_access_esm1p6_config.py +19 -4
  7. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/config_tests/qa/test_access_om2_config.py +6 -16
  8. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/config_tests/qa/test_config.py +10 -3
  9. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/config_tests/test_bit_reproducibility.py +62 -26
  10. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/exp_test_helper.py +129 -16
  11. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests.egg-info/PKG-INFO +18 -5
  12. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/tests/test_exp_test_helper.py +162 -6
  13. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/LICENSE +0 -0
  14. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/setup.cfg +0 -0
  15. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/setup.py +0 -0
  16. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/__init__.py +0 -0
  17. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/cmds/compare_exp_tests_cmd.py +0 -0
  18. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/cmds/config_tests_cmd.py +0 -0
  19. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/compare_exp_tests/conftest.py +0 -0
  20. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/compare_exp_tests/test_repro.py +0 -0
  21. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/config_tests/qa/test_access_esm1p5_config.py +0 -0
  22. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/config_tests/qa/test_access_om3_config.py +0 -0
  23. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/models/__init__.py +0 -0
  24. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/models/accessesm1p5.py +0 -0
  25. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/models/accessesm1p6.py +0 -0
  26. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/models/accessom2.py +0 -0
  27. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/models/accessom3.py +0 -0
  28. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/models/model.py +0 -0
  29. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/models/mom5.py +0 -0
  30. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/models/um7.py +0 -0
  31. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests/util.py +0 -0
  32. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests.egg-info/SOURCES.txt +0 -0
  33. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests.egg-info/dependency_links.txt +0 -0
  34. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests.egg-info/entry_points.txt +0 -0
  35. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests.egg-info/requires.txt +0 -0
  36. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/src/model_config_tests.egg-info/top_level.txt +0 -0
  37. {model_config_tests-0.2.2 → model_config_tests-0.2.4}/tests/test_util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model_config_tests
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Test for ACCESS model (payu) configurations
5
5
  Author: ACCESS-NRI
6
6
  License: Apache-2.0
@@ -38,11 +38,19 @@ Code from these pytests is adapted from COSIMAS's ACCESS-OM2's [bit reproducibil
38
38
 
39
39
  ### How to run pytests manually on NCI
40
40
 
41
- 1. Load payu module - this provides the dependencies needed to run the model
41
+ 1. Load payu module - this provides the dependencies needed to run the model.
42
42
 
43
43
  ```sh
44
44
  module use /g/data/vk83/modules
45
- module load payu/1.1.6
45
+ module load payu
46
+ ```
47
+
48
+ Some model configurations may require a minimum payu version, specified in `config.yaml` as `payu_minimum_version`. Please ensure that your loaded payu module meets the requirement.
49
+ If you need to run the model with a development version of payu, please use `payu/dev` instead:
50
+
51
+ ```sh
52
+ module use /g/data/vk83/modules
53
+ module load payu/dev
46
54
  ```
47
55
 
48
56
  2. Create and activate a python virtual environment for installing and running tests
@@ -52,10 +60,10 @@ Code from these pytests is adapted from COSIMAS's ACCESS-OM2's [bit reproducibil
52
60
  source <path/to/test-venv>/bin/activate
53
61
  ```
54
62
 
55
- 3. Either pip install a released version of `model-config-tests`,
63
+ 3. Either pip install the latest released version of `model-config-tests`,
56
64
 
57
65
  ```sh
58
- pip install model-config-tests==0.1.1
66
+ pip install model-config-tests
59
67
  ```
60
68
 
61
69
  Or to install `model-config-tests` in "editable" mode, first clone the repository, and then run pip install from the repository. This means any changes to the code are reflected in the installed package.
@@ -118,10 +126,15 @@ Running all tests in the pytest suite on a configuration will likely fail as the
118
126
  - `repro_determinism`: Determinism test that confirms repeated model runs give the same result.
119
127
  - `repro_determinism_restart`: Determinism test that confirms repeated experiments with two consecutive runs give the same result.
120
128
  - `repro_restart`: Restart reproducibility test that confirms two short consecutive model runs give the same result as a longer single model run.
129
+ - `repro_payu_setup`: Test payu setup reproducibility; fail if MD5 of any file in manifest is changed.
130
+ - `manifests_unchanged`: Uses `git diff` to check manifests are up-to-date. If only fast hashes (e.g. `binhash`) are different, the manifests are reproducible, but `payu setup` may take longer to run as `md5` hashes need to be recalculated. This test is not intended for tagged configurations.
131
+ - `manifests`: A shortcut to run both `manifests_unchanged` and `repro_payu_setup`.
121
132
  - `slow`: Tests that are slow to run
122
133
  - `dev_config`: General configuration QA tests.
123
134
  - `config`: Configuration QA tests for released branches. This includes the `dev_config` tests.
124
135
 
136
+
137
+
125
138
  There are also model-specific markers for configuration QA tests, e.g., `access_om2`, `access_esm1p5`, `access_om3` and `access_esm1p6`. For a list of all available markers,
126
139
  run:
127
140
 
@@ -10,11 +10,19 @@ Code from these pytests is adapted from COSIMAS's ACCESS-OM2's [bit reproducibil
10
10
 
11
11
  ### How to run pytests manually on NCI
12
12
 
13
- 1. Load payu module - this provides the dependencies needed to run the model
13
+ 1. Load payu module - this provides the dependencies needed to run the model.
14
14
 
15
15
  ```sh
16
16
  module use /g/data/vk83/modules
17
- module load payu/1.1.6
17
+ module load payu
18
+ ```
19
+
20
+ Some model configurations may require a minimum payu version, specified in `config.yaml` as `payu_minimum_version`. Please ensure that your loaded payu module meets the requirement.
21
+ If you need to run the model with a development version of payu, please use `payu/dev` instead:
22
+
23
+ ```sh
24
+ module use /g/data/vk83/modules
25
+ module load payu/dev
18
26
  ```
19
27
 
20
28
  2. Create and activate a python virtual environment for installing and running tests
@@ -24,10 +32,10 @@ Code from these pytests is adapted from COSIMAS's ACCESS-OM2's [bit reproducibil
24
32
  source <path/to/test-venv>/bin/activate
25
33
  ```
26
34
 
27
- 3. Either pip install a released version of `model-config-tests`,
35
+ 3. Either pip install the latest released version of `model-config-tests`,
28
36
 
29
37
  ```sh
30
- pip install model-config-tests==0.1.1
38
+ pip install model-config-tests
31
39
  ```
32
40
 
33
41
  Or to install `model-config-tests` in "editable" mode, first clone the repository, and then run pip install from the repository. This means any changes to the code are reflected in the installed package.
@@ -90,10 +98,15 @@ Running all tests in the pytest suite on a configuration will likely fail as the
90
98
  - `repro_determinism`: Determinism test that confirms repeated model runs give the same result.
91
99
  - `repro_determinism_restart`: Determinism test that confirms repeated experiments with two consecutive runs give the same result.
92
100
  - `repro_restart`: Restart reproducibility test that confirms two short consecutive model runs give the same result as a longer single model run.
101
+ - `repro_payu_setup`: Test payu setup reproducibility; fail if MD5 of any file in manifest is changed.
102
+ - `manifests_unchanged`: Uses `git diff` to check manifests are up-to-date. If only fast hashes (e.g. `binhash`) are different, the manifests are reproducible, but `payu setup` may take longer to run as `md5` hashes need to be recalculated. This test is not intended for tagged configurations.
103
+ - `manifests`: A shortcut to run both `manifests_unchanged` and `repro_payu_setup`.
93
104
  - `slow`: Tests that are slow to run
94
105
  - `dev_config`: General configuration QA tests.
95
106
  - `config`: Configuration QA tests for released branches. This includes the `dev_config` tests.
96
107
 
108
+
109
+
97
110
  There are also model-specific markers for configuration QA tests, e.g., `access_om2`, `access_esm1p5`, `access_om3` and `access_esm1p6`. For a list of all available markers,
98
111
  run:
99
112
 
@@ -112,6 +112,8 @@ tag_prefix = "v"
112
112
  parentdir_prefix = "model_config_tests-"
113
113
 
114
114
  [tool.coverage.run]
115
+ patch = ["subprocess"]
115
116
  omit = [
116
- "src/model_config_tests/_version.py"
117
+ "*/model_config_tests/_version.py",
118
+ "src/model_config_tests/_version.py",
117
119
  ]
@@ -8,11 +8,11 @@ import json
8
8
 
9
9
  version_json = '''
10
10
  {
11
- "date": "2025-10-21T07:48:35+1100",
11
+ "date": "2026-06-02T16:10:30+1000",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "5b34e0c7b8cb513b5204a8baa5f97b22f84c2407",
15
- "version": "0.2.2"
14
+ "full-revisionid": "ee4cb743816648753c36e15e6a630bedc2859853",
15
+ "version": "0.2.4"
16
16
  }
17
17
  ''' # END VERSION_JSON
18
18
 
@@ -131,6 +131,18 @@ def pytest_configure(config):
131
131
  "markers",
132
132
  "repro_determinism_restart: mark tests that check determinism restart",
133
133
  )
134
+ config.addinivalue_line(
135
+ "markers",
136
+ "repro_payu_setup: mark tests that check payu setup reproducibility",
137
+ )
138
+ config.addinivalue_line(
139
+ "markers",
140
+ "manifests: mark tests that check payu setup does not change manifests files or md5",
141
+ )
142
+ config.addinivalue_line(
143
+ "markers",
144
+ "manifests_unchanged: mark tests that check payu setup does not change manifests files",
145
+ )
134
146
  config.addinivalue_line("markers", "slow: mark tests that are slow to run")
135
147
  config.addinivalue_line(
136
148
  "markers",
@@ -27,8 +27,9 @@ ACCESS_ESM1P6_REPOSITORY_NAME = "ACCESS-ESM1.6"
27
27
  VALID_REALMS: set[str] = {"atmos", "land", "ocean", "ocnBgchem", "seaIce"}
28
28
  VALID_KEYWORDS: set[str] = {"global", "access-esm1.6"}
29
29
  VALID_NOMINAL_RESOLUTION: str = "100 km"
30
- # TODO: Add back in when valid DOI for ESM1.6 is obtained
31
- # VALID_REFERENCE: str = "https://doi.org/10.1071/ES19035"
30
+ # TODO: Update this reference when ESM1.6 paper is ready
31
+ VALID_REFERENCE_1p6: str = "https://doi.org/10.5281/zenodo.17490072"
32
+ VALID_URL: str = "https://github.com/ACCESS-NRI/access-esm1.6-configs.git"
32
33
  VALID_RUNTIME: dict[str, int] = {"years": 1, "months": 0, "days": 0}
33
34
  VALID_RESTART_FREQ: str = "10YS"
34
35
  VALID_MPPNCCOMBINE_EXE: str = "mppnccombine.spack"
@@ -150,8 +151,9 @@ class TestAccessEsm1p6:
150
151
  "field,expected",
151
152
  [
152
153
  ("nominal_resolution", VALID_NOMINAL_RESOLUTION),
153
- # TODO: Add back in when valid DOI for ESM1.6 is obtained (see commented constant above)
154
- # ("reference", VALID_REFERENCE),
154
+ ("model", ACCESS_ESM1P6_REPOSITORY_NAME),
155
+ ("url", VALID_URL),
156
+ ("reference", VALID_REFERENCE_1p6),
155
157
  ],
156
158
  )
157
159
  def test_metadata_field_equal_expected_value(self, field, expected, metadata):
@@ -159,6 +161,19 @@ class TestAccessEsm1p6:
159
161
  field, "metadata.yaml", expected
160
162
  )
161
163
 
164
+ @pytest.mark.parametrize(
165
+ "field",
166
+ [
167
+ ("description"),
168
+ ("notes"),
169
+ ],
170
+ )
171
+ def test_metadata_not_contain_esm1p5(self, field, metadata):
172
+ """Check that some fields in metadata do not contain 'ESM1.5', e.g., notes and description."""
173
+ assert (
174
+ field in metadata and "ESM1.5" not in metadata[field]
175
+ ), f"Field '{field}' in metadata.yaml should not contain 'ESM1.5'. "
176
+
162
177
  def test_config_runtime(self, config):
163
178
  assert (
164
179
  "calendar" in config
@@ -66,10 +66,13 @@ class AccessOM2Branch:
66
66
  self.set_resolution()
67
67
 
68
68
  self.is_high_resolution = self.resolution in ["025deg", "01deg"]
69
- self.is_bgc = "bgc" in branch_name
69
+ is_bgc_old = "bgc" in branch_name
70
+ is_bgc_new = "wombat" in branch_name
71
+ self.is_bgc = is_bgc_old or is_bgc_new
70
72
 
71
73
  # Set expected module and model repository names
72
- if self.is_bgc:
74
+ if is_bgc_old:
75
+ # Pre-generic-tracers BGC uses a separate exe
73
76
  self.module_name = ACCESS_OM2_BGC_MODULE_NAME
74
77
  self.model_repository_name = ACCESS_OM2_BGC_REPOSITORY_NAME
75
78
  else:
@@ -116,7 +119,7 @@ class TestAccessOM2:
116
119
 
117
120
  def test_mppncombine_fast_collate_exe(self, config, branch):
118
121
  if branch.is_high_resolution:
119
- pattern = r"/g/data/vk83/apps/mppnccombine-fast/.*/bin/mppnccombine-fast"
122
+ pattern = r".*mppnccombine-fast"
120
123
  if "collate" in config:
121
124
  assert re.match(
122
125
  pattern, config["collate"]["exe"]
@@ -126,19 +129,6 @@ class TestAccessOM2:
126
129
  "mpi"
127
130
  ], "Expect `mpi: true` when using mppnccombine-fast"
128
131
 
129
- def test_sync_userscript_ice_concatenation(self, config):
130
- # This script runs in the sync pbs job before syncing output to a
131
- # remote location
132
- script = "/g/data/vk83/apps/om2-scripts/concatenate_ice/concat_ice_daily.sh"
133
- assert (
134
- "userscripts" in config
135
- and "sync" in config["userscripts"]
136
- and config["userscripts"]["sync"] == script
137
- ), (
138
- "Expect sync userscript set to ice-concatenation script."
139
- + f"\nuserscript:\n sync: {script}"
140
- )
141
-
142
132
  def test_metadata_realm(self, metadata, branch):
143
133
  expected_realms = {"ocean", "seaIce"}
144
134
  expected_config = "realm:\n - ocean\n - seaIce"
@@ -225,11 +225,18 @@ class TestConfig:
225
225
  "enable"
226
226
  ], "Sync to remote archive should not be enabled"
227
227
 
228
- def test_sync_path_is_not_set(self, config):
228
+ def test_sync_base_path_is_not_set(self, config):
229
229
  if "sync" in config:
230
230
  assert not (
231
- "path" in config["sync"] and config["sync"]["path"] is not None
232
- ), "Sync path to remote archive should not be set"
231
+ "base_path" in config["sync"]
232
+ and config["sync"]["base_path"] is not None
233
+ ), "Sync base path to remote archive should not be configured"
234
+
235
+ def test_sync_path_not_exists(self, config):
236
+ if "sync" in config:
237
+ assert (
238
+ "path" not in config["sync"]
239
+ ), "Sync path should not exist since base_path is preferred"
233
240
 
234
241
  def test_experiment_name_is_not_defined(self, config):
235
242
  assert "experiment" not in config, (
@@ -9,7 +9,7 @@ from typing import Optional
9
9
 
10
10
  import pytest
11
11
 
12
- from model_config_tests.exp_test_helper import Experiments
12
+ from model_config_tests.exp_test_helper import Experiments, ExpTestHelper, setup_exp
13
13
  from model_config_tests.util import DAY_IN_SECONDS, HOUR_IN_SECONDS
14
14
 
15
15
  # Names of shared experiments
@@ -147,6 +147,20 @@ def experiments(
147
147
  return _experiments(experiments_markers, output_path, control_path, keep_archive)
148
148
 
149
149
 
150
+ @pytest.fixture
151
+ def requested_experiments(request, experiments: Experiments):
152
+ """Fixture to check that requested experiments have run successfully
153
+ and return a dictionary of ExpTestHelper instances for each experiment."""
154
+ exp_marker = request.node.get_closest_marker("experiments").args[0]
155
+ requested_exps = {}
156
+ for exp_name in exp_marker:
157
+ # Check experiment has run successfully - this will raise an
158
+ # error if there are any non-zero exit codes in the outputs
159
+ experiments.check_experiment(exp_name)
160
+ requested_exps[exp_name] = experiments.get_experiment(exp_name)
161
+ return requested_exps
162
+
163
+
150
164
  class TestBitReproducibility:
151
165
 
152
166
  @pytest.mark.repro
@@ -160,7 +174,7 @@ class TestBitReproducibility:
160
174
  self,
161
175
  output_path: Path,
162
176
  control_path: Path,
163
- experiments: Experiments,
177
+ requested_experiments: dict[str, ExpTestHelper],
164
178
  checksum_path: Optional[Path],
165
179
  ):
166
180
  """
@@ -178,9 +192,9 @@ class TestBitReproducibility:
178
192
  Path to the model configuration to test. This is copied for
179
193
  for control directories in experiments. Default is set in
180
194
  conftests.py.
181
- experiments: Experiments
182
- Class that manages the shared experiments. This is a fixture
183
- defined in this file.
195
+ requested_experiments: dict[str, ExpTestHelper]
196
+ A dictionary of requested experiments, where the key is the
197
+ experiment name and the value is an instance of ExpTestHelper.
184
198
  checksum_path: Optional[Path]
185
199
  Path to checksums to compare model output against. Default is
186
200
  set to checksums saved on model configuration. This is a
@@ -190,12 +204,7 @@ class TestBitReproducibility:
190
204
  checksum_output_dir = set_checksum_output_dir(output_path=output_path)
191
205
 
192
206
  # Use default runtime experiment to get the historical checksums
193
- experiments.check_experiments([EXP_DEFAULT_RUNTIME])
194
- exp = experiments.get_experiment(EXP_DEFAULT_RUNTIME)
195
-
196
- assert (
197
- exp.model.output_exists()
198
- ), "Output file required for model checksums does not exist"
207
+ exp = requested_experiments.get(EXP_DEFAULT_RUNTIME)
199
208
 
200
209
  # Set the checksum output filename using the model default runtime
201
210
  runtime_hours = exp.model.default_runtime_seconds // HOUR_IN_SECONDS
@@ -235,20 +244,16 @@ class TestBitReproducibility:
235
244
  EXP_1D_RUNTIME_REPEAT: {"n_runs": 1, "model_runtime": DAY_IN_SECONDS},
236
245
  }
237
246
  )
238
- def test_repro_determinism(self, experiments: Experiments):
247
+ def test_repro_determinism(self, requested_experiments: dict[str, ExpTestHelper]):
239
248
  """
240
249
  Determinism test that confirms repeated model runs for 1 day
241
250
  give the same results
242
251
  """
243
- experiments.check_experiments([EXP_1D_RUNTIME, EXP_1D_RUNTIME_REPEAT])
244
- exp_1d_runtime = experiments.get_experiment(EXP_1D_RUNTIME)
245
- exp_1d_runtime_repeat = experiments.get_experiment(EXP_1D_RUNTIME_REPEAT)
252
+ exp_1d_runtime = requested_experiments.get(EXP_1D_RUNTIME)
253
+ exp_1d_runtime_repeat = requested_experiments.get(EXP_1D_RUNTIME_REPEAT)
246
254
 
247
255
  # Compare expected to produced.
248
- assert exp_1d_runtime.model.output_exists()
249
256
  expected = exp_1d_runtime.extract_checksums()
250
-
251
- assert exp_1d_runtime_repeat.model.output_exists()
252
257
  produced = exp_1d_runtime_repeat.extract_checksums()
253
258
 
254
259
  assert produced == expected
@@ -262,16 +267,17 @@ class TestBitReproducibility:
262
267
  EXP_2D_RUNTIME: {"n_runs": 1, "model_runtime": 2 * DAY_IN_SECONDS},
263
268
  }
264
269
  )
265
- def test_repro_restart(self, output_path: Path, experiments: Experiments):
270
+ def test_repro_restart(
271
+ self, output_path: Path, requested_experiments: dict[str, ExpTestHelper]
272
+ ):
266
273
  """
267
274
  Restart reproducibility test that confirms two short consecutive
268
275
  1-day model runs give the same results as a longer single 2-day model
269
276
  run.
270
277
  """
271
278
  # Get experiments with 2x1 day and 2 day runtimes
272
- experiments.check_experiments([EXP_1D_RUNTIME, EXP_2D_RUNTIME])
273
- exp_1d_runtime = experiments.get_experiment(EXP_1D_RUNTIME)
274
- exp_2d_runtime = experiments.get_experiment(EXP_2D_RUNTIME)
279
+ exp_1d_runtime = requested_experiments.get(EXP_1D_RUNTIME)
280
+ exp_2d_runtime = requested_experiments.get(EXP_2D_RUNTIME)
275
281
 
276
282
  # Now compare the output between our two short and one long run.
277
283
  checksums_1d_0 = exp_1d_runtime.extract_checksums()
@@ -305,14 +311,15 @@ class TestBitReproducibility:
305
311
  EXP_1D_RUNTIME_REPEAT: {"n_runs": 2, "model_runtime": DAY_IN_SECONDS},
306
312
  }
307
313
  )
308
- def test_repro_determinism_restart(self, experiments: Experiments):
314
+ def test_repro_determinism_restart(
315
+ self, requested_experiments: dict[str, ExpTestHelper]
316
+ ):
309
317
  """
310
318
  Determinism test that confirms repeated experiments with two
311
319
  consecutive 1-day model runs give the same results
312
320
  """
313
- experiments.check_experiments([EXP_1D_RUNTIME, EXP_1D_RUNTIME_REPEAT])
314
- exp_1d_runtime = experiments.get_experiment(EXP_1D_RUNTIME)
315
- exp_1d_runtime_repeat = experiments.get_experiment(EXP_1D_RUNTIME_REPEAT)
321
+ exp_1d_runtime = requested_experiments.get(EXP_1D_RUNTIME)
322
+ exp_1d_runtime_repeat = requested_experiments.get(EXP_1D_RUNTIME_REPEAT)
316
323
 
317
324
  # Extract checksums, using the output from the second model run
318
325
  expected = exp_1d_runtime.extract_checksums(exp_1d_runtime.model.output_1)
@@ -321,3 +328,32 @@ class TestBitReproducibility:
321
328
  )
322
329
 
323
330
  assert produced == expected
331
+
332
+
333
+ @pytest.mark.repro
334
+ @pytest.mark.manifests
335
+ @pytest.mark.repro_payu_setup
336
+ def test_repro_payu_setup(control_path, output_path):
337
+ """
338
+ Test payu setup with `--repro` flag which errors if md5 of any files in payu manifests are changed.
339
+ """
340
+ experiment = setup_exp(control_path, output_path, exp_name="repro_payu_setup")
341
+ try:
342
+ experiment.setup_reproduce()
343
+ except Exception as error:
344
+ pytest.fail(f"{error}")
345
+
346
+
347
+ @pytest.mark.manifests
348
+ @pytest.mark.manifests_unchanged
349
+ def test_manifests_unchanged(control_path, output_path):
350
+ """
351
+ Test payu setup with `git diff` which errors if any files in payu manifests are changed.
352
+ """
353
+ experiment = setup_exp(
354
+ control_path, output_path, exp_name="setup_unchanged_manifests"
355
+ )
356
+ try:
357
+ experiment.setup_manifests_unchanged()
358
+ except Exception as error:
359
+ pytest.fail(f"{error}")
@@ -82,6 +82,93 @@ class ExpTestHelper:
82
82
  """
83
83
  return self.model.output_exists()
84
84
 
85
+ def setup(self, reproduce=False):
86
+ """
87
+ Run payu setup command. If reproduce is True, run with --reproduce flag
88
+ to check if md5 hashes have changed in the manifests.
89
+ """
90
+ owd = Path.cwd()
91
+ # Change to experiment directory and run.
92
+ os.chdir(self.control_path)
93
+
94
+ try:
95
+ setup_command = [
96
+ "payu",
97
+ "setup",
98
+ "--lab",
99
+ str(self.lab_path),
100
+ ]
101
+ if reproduce:
102
+ setup_command.append("--reproduce")
103
+ print(f"Running payu setup command: {setup_command}")
104
+ result = sp.run(setup_command, capture_output=True, text=True)
105
+
106
+ finally:
107
+ # Change back to original working directory
108
+ os.chdir(owd)
109
+
110
+ if result.returncode != 0:
111
+ raise RuntimeError(
112
+ "Failed to run payu setup"
113
+ + (" with --reproduce.\n" if reproduce else ".\n")
114
+ + f"{'='*10}STDOUT{'='*10}\n {result.stdout}\n"
115
+ f"{'='*10}STDERR{'='*10}\n {result.stderr}\n"
116
+ )
117
+
118
+ def run_git_diff(self, path, extra_args=None):
119
+ """
120
+ Run git diff command on the given path and return the output.
121
+ """
122
+ command = ["git", "-C", str(path), "diff"] + extra_args if extra_args else []
123
+
124
+ result = sp.run(command, capture_output=True, text=True)
125
+
126
+ if result.returncode != 0:
127
+ raise RuntimeError(
128
+ f"Git command failed with exit code {result.returncode}.\n"
129
+ f"{'='*10}STDOUT{'='*10}\n {result.stdout}\n"
130
+ f"{'='*10}STDERR{'='*10}\n {result.stderr}\n"
131
+ )
132
+
133
+ return result.stdout
134
+
135
+ def setup_reproduce(self):
136
+ """
137
+ Run payu setup with `--repro` flag to check if md5 hashes have changed in the manifests.
138
+ """
139
+ self.setup(reproduce=True)
140
+
141
+ def setup_manifests_unchanged(self):
142
+ """
143
+ Run payu setup command and check if manifests files have been changed with `git diff`.
144
+ """
145
+ self.setup(reproduce=False)
146
+
147
+ result = self.run_git_diff(
148
+ self.control_path, extra_args=["--name-only", "manifests/"]
149
+ )
150
+ if result != "":
151
+ # Collect and display the top 10 lines of the diff for each modified file
152
+ files = result.strip().split("\n")
153
+ error_message = "Modifications are detected in file:\n"
154
+ error_message += "\n".join(" - " + file for file in files) + "\n"
155
+ error_message += "\nIf md5 hashes have changed, this indicates file contents being different."
156
+ error_message += """
157
+ If binhashes/paths have changed but md5's are the same,
158
+ this will mean the configuration can reproduce the manifests
159
+ but `payu setup` will take longer to run as it needs to re-calculate all the md5 hashes.
160
+ """
161
+ for file in files:
162
+ diff_details = self.run_git_diff(
163
+ self.control_path, extra_args=[f"{file}"]
164
+ )
165
+ diff_lines = diff_details.splitlines()
166
+ top_lines = "\n".join(diff_lines[2:12])
167
+ if len(diff_lines) > 12:
168
+ top_lines += "\n... (truncated)"
169
+ error_message += f"\n{'='*10} Diff for {file} {'='*10}\n{top_lines}\n"
170
+ raise RuntimeError(f"{error_message}")
171
+
85
172
  def setup_for_test_run(self):
86
173
  """
87
174
  Various config.yaml settings need to be modified in order to run in the
@@ -136,9 +223,30 @@ class ExpTestHelper:
136
223
  # Change to experiment directory and run.
137
224
  os.chdir(self.control_path)
138
225
 
139
- print("Running payu setup and payu sweep commands")
140
- sp.run(["payu", "setup", "--lab", str(self.lab_path)], check=True)
141
- sp.run(["payu", "sweep", "--lab", str(self.lab_path)], check=True)
226
+ print("Running payu setup")
227
+ result = sp.run(
228
+ ["payu", "setup", "--lab", str(self.lab_path)],
229
+ capture_output=True,
230
+ text=True,
231
+ )
232
+ if result.returncode != 0:
233
+ # Add additional error messaging for debugging
234
+ error_msg = (
235
+ "Failed to run payu setup:\n"
236
+ f"Return code: {result.returncode}\n"
237
+ f"--- stdout ---\n{result.stdout}\n"
238
+ f"--- stderr ---\n{result.stderr}"
239
+ )
240
+ print(error_msg)
241
+ raise RuntimeError(error_msg)
242
+
243
+ print("Running payu sweep")
244
+ sp.run(
245
+ ["payu", "sweep", "--lab", str(self.lab_path)],
246
+ capture_output=True,
247
+ text=True,
248
+ check=True,
249
+ )
142
250
 
143
251
  run_command = ["payu", "run", "--lab", str(self.lab_path)]
144
252
  if n_runs:
@@ -208,7 +316,7 @@ class Experiments:
208
316
  self.output_path = output_path
209
317
  self.keep_archive = keep_archive
210
318
  self.experiments = {}
211
- self.successful_experiments = []
319
+ self.experiment_errors = {}
212
320
 
213
321
  def setup_and_submit(
214
322
  self,
@@ -282,22 +390,27 @@ class Experiments:
282
390
  try:
283
391
  exp.wait_for_payu_run()
284
392
  print(f"Experiment {exp_name} completed successfully")
285
- self.successful_experiments.append(exp_name)
286
393
  except RuntimeError as e:
394
+ self.experiment_errors[exp_name] = str(e)
287
395
  if catch_errors:
288
- print(f"Error in experiment {exp_name}: {e}")
396
+ print(f"Error running experiment {exp_name}: {e}")
289
397
  else:
290
- raise e
398
+ raise
291
399
 
292
- def check_experiments(self, exp_names=list[str]) -> None:
400
+ def check_experiment(self, exp_name: str) -> None:
293
401
  """
294
- Check whether given experiments names have run successfully
402
+ Check whether given experiment name has run successfully
295
403
  """
296
- for exp_name in exp_names:
297
- # TODO: Is there other useful information to display here?
298
- assert (
299
- exp_name in self.successful_experiments
300
- ), f"There was an error running experiment: {exp_name}"
404
+ if exp_name in self.experiment_errors:
405
+ raise RuntimeError(
406
+ f"There was an error running experiment {exp_name}:"
407
+ f" {self.experiment_errors[exp_name]}"
408
+ )
409
+
410
+ # Double check if the required experiment output exists
411
+ exp = self.experiments.get(exp_name)
412
+ if not exp.model.output_exists():
413
+ raise RuntimeError(f"Experiment {exp_name} output file does not exist.")
301
414
 
302
415
 
303
416
  def setup_exp(
@@ -519,13 +632,13 @@ def wait_for_qsub_job(
519
632
  # Check whether the run job was successful
520
633
  exit_status = parse_exit_status_from_file(stdout)
521
634
  if exit_status != 0:
522
- print(
635
+ raise RuntimeError(
636
+ f"Payu {job_type} job failed with exit status {exit_status}:\n"
523
637
  f"Job_ID: {job_id}\n"
524
638
  f"Output files: {output_files}\n"
525
639
  f"--- stdout ---\n{stdout}\n"
526
640
  f"--- stderr ---\n{stderr}\n"
527
641
  )
528
- raise RuntimeError(f"Payu {job_type} job failed with exit status {exit_status}")
529
642
 
530
643
  return stdout, stderr, output_files
531
644
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model_config_tests
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Test for ACCESS model (payu) configurations
5
5
  Author: ACCESS-NRI
6
6
  License: Apache-2.0
@@ -38,11 +38,19 @@ Code from these pytests is adapted from COSIMAS's ACCESS-OM2's [bit reproducibil
38
38
 
39
39
  ### How to run pytests manually on NCI
40
40
 
41
- 1. Load payu module - this provides the dependencies needed to run the model
41
+ 1. Load payu module - this provides the dependencies needed to run the model.
42
42
 
43
43
  ```sh
44
44
  module use /g/data/vk83/modules
45
- module load payu/1.1.6
45
+ module load payu
46
+ ```
47
+
48
+ Some model configurations may require a minimum payu version, specified in `config.yaml` as `payu_minimum_version`. Please ensure that your loaded payu module meets the requirement.
49
+ If you need to run the model with a development version of payu, please use `payu/dev` instead:
50
+
51
+ ```sh
52
+ module use /g/data/vk83/modules
53
+ module load payu/dev
46
54
  ```
47
55
 
48
56
  2. Create and activate a python virtual environment for installing and running tests
@@ -52,10 +60,10 @@ Code from these pytests is adapted from COSIMAS's ACCESS-OM2's [bit reproducibil
52
60
  source <path/to/test-venv>/bin/activate
53
61
  ```
54
62
 
55
- 3. Either pip install a released version of `model-config-tests`,
63
+ 3. Either pip install the latest released version of `model-config-tests`,
56
64
 
57
65
  ```sh
58
- pip install model-config-tests==0.1.1
66
+ pip install model-config-tests
59
67
  ```
60
68
 
61
69
  Or to install `model-config-tests` in "editable" mode, first clone the repository, and then run pip install from the repository. This means any changes to the code are reflected in the installed package.
@@ -118,10 +126,15 @@ Running all tests in the pytest suite on a configuration will likely fail as the
118
126
  - `repro_determinism`: Determinism test that confirms repeated model runs give the same result.
119
127
  - `repro_determinism_restart`: Determinism test that confirms repeated experiments with two consecutive runs give the same result.
120
128
  - `repro_restart`: Restart reproducibility test that confirms two short consecutive model runs give the same result as a longer single model run.
129
+ - `repro_payu_setup`: Test payu setup reproducibility; fail if MD5 of any file in manifest is changed.
130
+ - `manifests_unchanged`: Uses `git diff` to check manifests are up-to-date. If only fast hashes (e.g. `binhash`) are different, the manifests are reproducible, but `payu setup` may take longer to run as `md5` hashes need to be recalculated. This test is not intended for tagged configurations.
131
+ - `manifests`: A shortcut to run both `manifests_unchanged` and `repro_payu_setup`.
121
132
  - `slow`: Tests that are slow to run
122
133
  - `dev_config`: General configuration QA tests.
123
134
  - `config`: Configuration QA tests for released branches. This includes the `dev_config` tests.
124
135
 
136
+
137
+
125
138
  There are also model-specific markers for configuration QA tests, e.g., `access_om2`, `access_esm1p5`, `access_om3` and `access_esm1p6`. For a list of all available markers,
126
139
  run:
127
140
 
@@ -1,13 +1,14 @@
1
1
  import shutil
2
2
  import subprocess
3
3
  from pathlib import Path
4
- from unittest.mock import patch
4
+ from unittest.mock import MagicMock, Mock, patch
5
5
 
6
6
  import pytest
7
7
  import yaml
8
8
  from netCDF4 import Dataset
9
9
 
10
10
  from model_config_tests.exp_test_helper import (
11
+ Experiments,
11
12
  ExpTestHelper,
12
13
  parse_exit_status_from_file,
13
14
  parse_gadi_pbs_ids,
@@ -127,6 +128,7 @@ def test_experiment_setup_for_test_run_remove_postprocessing(exp, tmp_path):
127
128
  @patch("subprocess.run")
128
129
  def test_experiment_submit_payu_run(mock_run, exp):
129
130
  mock_run.return_value.stdout = "1234567.gadi-pbs\nsome other output"
131
+ mock_run.return_value.returncode = 0
130
132
 
131
133
  current_working_dir = Path.cwd()
132
134
  exp.submit_payu_run()
@@ -150,6 +152,7 @@ def test_experiment_submit_payu_run(mock_run, exp):
150
152
  def test_experiment_submit_payu_run_n_runs(mock_run, exp):
151
153
  """Test --n-runs is added to the payu run command"""
152
154
  mock_run.return_value.stdout = "1234567.gadi-pbs\nsome other output"
155
+ mock_run.return_value.returncode = 0
153
156
 
154
157
  exp.submit_payu_run(n_runs=2)
155
158
 
@@ -172,13 +175,33 @@ def test_experiment_submit_payu_run_disabled(mock_run, exp):
172
175
  assert job_id is None
173
176
 
174
177
 
178
+ @patch("subprocess.run")
179
+ def test_experiment_submit_payu_run_setup_error(mock_run, exp):
180
+ """Test that an error is raised when payu setup fails"""
181
+ mock_run.return_value.stdout = "Some output"
182
+ mock_run.return_value.stderr = "Some error"
183
+ mock_run.return_value.returncode = 1
184
+
185
+ with pytest.raises(RuntimeError, match="Failed to run payu setup*"):
186
+ exp.submit_payu_run()
187
+
188
+ assert exp.run_id is None
189
+
190
+
175
191
  @patch("subprocess.run")
176
192
  def test_experiment_submit_payu_run_error(mock_run, exp):
177
- """Test that an error is raised when any payu command fails"""
178
- mock_run.side_effect = subprocess.CalledProcessError(
179
- returncode=1, cmd="payu setup", output="Some error"
180
- )
181
- mock_run.return_value.stdout = "Some error"
193
+ """Test that an RuntimeError is raised with CalledProcessError"""
194
+ # Mock the first call to payu setup to succeed
195
+ # and subsequent payu command to fail
196
+ setup_success = Mock()
197
+ setup_success.stdout = "Setup successful"
198
+ setup_success.returncode = 0
199
+ mock_run.side_effect = [
200
+ setup_success,
201
+ subprocess.CalledProcessError(
202
+ returncode=1, cmd="payu run", output="Some error"
203
+ ),
204
+ ]
182
205
 
183
206
  with pytest.raises(RuntimeError, match="Failed to submit payu run.*"):
184
207
  exp.submit_payu_run()
@@ -490,3 +513,136 @@ def test_extract_checksums_split_uses_first_tile(exp_with_restarts):
490
513
 
491
514
  checksums = exp_accessom3.extract_checksums(output_directory=exp_accessom3.output_0)
492
515
  assert checksums["output"]["DTBT"][0] == "AC87F8AC28BD1436"
516
+
517
+
518
+ def test_experiments_check_experiment_error(tmp_path):
519
+ with patch("model_config_tests.exp_test_helper.setup_exp") as mock_setup_exp:
520
+ # Create an experiment that will error later on
521
+ mock_error_exp = Mock(autospec=ExpTestHelper)
522
+ mock_setup_exp.return_value = mock_error_exp
523
+ mock_error_exp.wait_for_payu_run.side_effect = RuntimeError(
524
+ "Payu run job failed with exit status 1"
525
+ )
526
+
527
+ exps = Experiments(
528
+ control_path=tmp_path / "control",
529
+ output_path=tmp_path / "output",
530
+ keep_archive=True,
531
+ )
532
+ exps.setup_and_submit(exp_name="error_exp")
533
+ assert exps.experiments["error_exp"] == mock_error_exp
534
+
535
+ # Add a second experiment that will succeed
536
+ mock_success_exp = Mock(autospec=ExpTestHelper)
537
+ mock_success_exp.wait_for_payu_run.return_value = None
538
+ mock_setup_exp.return_value = mock_success_exp
539
+
540
+ exps.setup_and_submit(exp_name="success_exp")
541
+ assert exps.experiments["success_exp"] == mock_success_exp
542
+
543
+ # Check no errors are raised here
544
+ exps.wait_for_all_experiments(catch_errors=True)
545
+ assert exps.experiment_errors == {
546
+ "error_exp": "Payu run job failed with exit status 1"
547
+ }
548
+
549
+ # Check no errors with successful experiment
550
+ exps.check_experiment("success_exp")
551
+
552
+ # Check error raised for the failed experiment
553
+ error_msg = (
554
+ "There was an error running experiment error_exp: "
555
+ "Payu run job failed with exit status 1"
556
+ )
557
+ with pytest.raises(RuntimeError, match=error_msg):
558
+ exps.check_experiment("error_exp")
559
+
560
+
561
+ @patch("subprocess.run")
562
+ def test_setup_reproduce_error(mock_run, exp):
563
+ """Test that payu setup --repro fails raises an error and return to original work directory"""
564
+ # Mock the payu setup --repro to fail
565
+ mock_result = MagicMock()
566
+ mock_result.returncode = 1
567
+ mock_result.stderr = "MD5 mismatch"
568
+ mock_result.stdout = "Check manifest"
569
+ mock_run.return_value = mock_result
570
+
571
+ # Store original current working directory
572
+ owd = Path.cwd()
573
+
574
+ with pytest.raises(RuntimeError) as excinfo:
575
+ exp.setup_reproduce()
576
+
577
+ assert "Failed to run payu setup with --reproduce.\n" in str(excinfo.value)
578
+ assert f"{'='*10}STDOUT{'='*10}\n {mock_result.stdout}\n" in str(excinfo.value)
579
+
580
+ # assert returning to the original work directory
581
+ assert Path.cwd() == owd
582
+
583
+
584
+ @patch("subprocess.run")
585
+ def test_setup_manifests_unchanged_fail_setup(mock_run, exp):
586
+ """Test that an error is raised when payu setup fails in setup_manifests_unchanged()"""
587
+ # Mock the payu setup --repro to fail with unchanged manifests
588
+ mock_result = MagicMock()
589
+ mock_result.returncode = 1
590
+ mock_result.stderr = "Setup failed"
591
+ mock_result.stdout = "Payu setup output"
592
+ mock_run.return_value = mock_result
593
+
594
+ # Store original current working directory
595
+ owd = Path.cwd()
596
+
597
+ with pytest.raises(RuntimeError) as excinfo:
598
+ exp.setup_manifests_unchanged()
599
+
600
+ assert "Failed to run payu setup" in str(excinfo.value)
601
+ assert f"{'='*10}STDOUT{'='*10}\n {mock_result.stdout}\n" in str(excinfo.value)
602
+
603
+ # assert returning to the original work directory
604
+ assert Path.cwd() == owd
605
+
606
+
607
+ @patch("subprocess.run")
608
+ def test_setup_manifests_unchanged_show_changes(mock_run, exp):
609
+ """Test that when manifests are changed, the `git diff` results are printed to stdout"""
610
+ # Mock the `payu setup` succeed first
611
+ setup_success = MagicMock(returncode=0, stdout="Payu setup succeeded")
612
+
613
+ top_lines = """--- a/{diff_file}
614
+ +++ b/{diff_file}
615
+ +new line
616
+ -old line
617
+ """
618
+ diff_file = "manifests/input.yaml"
619
+ # Then mock the `git diff --name-only` to show which files are changed
620
+ git_diff_name_only = MagicMock(returncode=0, stdout=diff_file)
621
+
622
+ # Mock the `git diff` to show the detailed changes in the file
623
+ git_diff_run = MagicMock(
624
+ returncode=0,
625
+ stdout=(
626
+ f"""diff --git a/{diff_file} b/{diff_file}
627
+ index abc123...zyx789 100111
628
+ """
629
+ )
630
+ + top_lines,
631
+ )
632
+
633
+ # Run these mocks in sequence
634
+ mock_run.side_effect = [setup_success, git_diff_name_only, git_diff_run]
635
+
636
+ # Store original current working directory
637
+ owd = Path.cwd()
638
+
639
+ with pytest.raises(RuntimeError) as excinfo:
640
+ exp.setup_manifests_unchanged()
641
+
642
+ assert "Modifications are detected in file:\n" in str(excinfo.value)
643
+ assert f"\n{'='*10} Diff for {diff_file} {'='*10}\n{top_lines}\n" in str(
644
+ excinfo.value
645
+ )
646
+
647
+ # assert returning to the original work directory
648
+ assert Path.cwd() == owd