Skip to content

Commit 25bcb61

Browse files
authored
Merge pull request #110 from ACCESS-NRI/86-better-om3-checksums
ACCESS-OM3: use per-variable checksums from mom restart files
2 parents 3306f0a + dfcaa49 commit 25bcb61

25 files changed

+1327
-195
lines changed

src/model_config_tests/exp_test_helper.py

+2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ def __init__(self, control_path: Path, lab_path: Path, disable_payu_run=False):
2727
self.work_path = lab_path / "work" / self.exp_name
2828
self.output000 = self.archive_path / "output000"
2929
self.output001 = self.archive_path / "output001"
30+
self.restart000 = self.archive_path / "restart000"
31+
self.restart001 = self.archive_path / "restart001"
3032

3133
with open(self.config_path) as f:
3234
self.config = yaml.safe_load(f)

src/model_config_tests/models/accessesm1p5.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def __init__(self, experiment):
1919
# Override model default runtime
2020
self.default_runtime_seconds = DEFAULT_RUNTIME_SECONDS
2121

22-
self.output_file = self.experiment.output000 / "access.out"
22+
self.output_file = self.output_0 / "access.out"
2323

2424
def set_model_runtime(
2525
self, years: int = 0, months: int = 0, seconds: int = DEFAULT_RUNTIME_SECONDS

src/model_config_tests/models/accessom2.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
class AccessOm2(Model):
1717
def __init__(self, experiment):
1818
super().__init__(experiment)
19-
self.output_file = self.experiment.output000 / "access-om2.out"
19+
self.output_file = self.output_0 / "access-om2.out"
2020

2121
self.accessom2_config = experiment.control_path / "accessom2.nml"
2222
self.ocean_config = experiment.control_path / "ocean" / "input.nml"

src/model_config_tests/models/accessom3.py

+43-35
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
11
"""Specific Access-OM3 Model setup and post-processing"""
22

3-
import re
43
from collections import defaultdict
54
from pathlib import Path
65
from typing import Any
76

7+
import f90nml
8+
from netCDF4 import Dataset
89
from payu.models.cesm_cmeps import Runconfig
910

1011
from model_config_tests.models.model import (
1112
DEFAULT_RUNTIME_SECONDS,
1213
SCHEMA_VERSION_1_0_0,
1314
Model,
1415
)
15-
from model_config_tests.util import DAY_IN_SECONDS
1616

1717

1818
class AccessOm3(Model):
1919
def __init__(self, experiment):
2020
super().__init__(experiment)
21-
self.output_file = self.experiment.output000 / "ocean.stats"
2221

22+
# ACCESS-OM3 uses restarts for repro testing
23+
self.output_0 = self.experiment.restart000
24+
self.output_1 = self.experiment.restart001
25+
26+
self.mom_restart_pointer = self.output_0 / "rpointer.ocn"
2327
self.runconfig = experiment.control_path / "nuopc.runconfig"
24-
self.mom_override = experiment.control_path / "MOM_override"
25-
self.ocean_config = experiment.control_path / "input.nml"
28+
self.wav_in = experiment.control_path / "wav_in"
2629

2730
def set_model_runtime(
2831
self, years: int = 0, months: int = 0, seconds: int = DEFAULT_RUNTIME_SECONDS
@@ -31,19 +34,20 @@ def set_model_runtime(
3134
Default is 3 hours"""
3235
runconfig = Runconfig(self.runconfig)
3336

37+
# Check that ocean model component is MOM since checksums are obtained from
38+
# MOM6 restarts. Fail early if not
39+
ocn_model = runconfig.get("ALLCOMP_attributes", "OCN_model")
40+
if ocn_model != "mom":
41+
raise ValueError(
42+
"ACCESS-OM3 reproducibility checks utilize checksums written in MOM6 "
43+
"restarts and hence can only be used with ACCESS-OM3 configurations that "
44+
f"use MOM6. This configuration uses OCN_model = {ocn_model}."
45+
)
46+
3447
if years == months == 0:
3548
freq = "nseconds"
3649
n = str(seconds)
3750

38-
# Ensure that ocean.stats are written at the end of the run
39-
if seconds < DAY_IN_SECONDS:
40-
with open(self.mom_override, "a") as f:
41-
f.writelines(
42-
[
43-
f"\n#override TIMEUNIT = {n}",
44-
"\n#override ENERGYSAVEDAYS = 1.0",
45-
]
46-
)
4751
elif seconds == 0:
4852
freq = "nmonths"
4953
n = str(12 * years + months)
@@ -59,38 +63,42 @@ def set_model_runtime(
5963

6064
runconfig.write()
6165

66+
# Unfortunately WW3 doesn't (yet) obey the nuopc.runconfig. This should change in a
67+
# future release, but for now we have to set WW3 runtime in wav_in. See
68+
# https://github.com/COSIMA/access-om3/issues/239
69+
if self.wav_in.exists():
70+
with open(self.wav_in) as f:
71+
nml = f90nml.read(f)
72+
73+
nml["output_date_nml"]["date"]["restart"]["stride"] = int(n)
74+
nml.write(self.wav_in, force=True)
75+
6276
def output_exists(self) -> bool:
6377
"""Check for existing output file"""
64-
return self.output_file.exists()
78+
return self.mom_restart_pointer.exists()
6579

6680
def extract_checksums(
6781
self, output_directory: Path = None, schema_version: str = None
6882
) -> dict[str, Any]:
6983
"""Parse output file and create checksum using defined schema"""
7084
if output_directory:
71-
output_filename = output_directory / "ocean.stats"
85+
mom_restart_pointer = output_directory / "rpointer.ocn"
7286
else:
73-
output_filename = self.output_file
74-
75-
# ocean.stats is used for regression testing in MOM6's own test suite
76-
# See https://github.com/mom-ocean/MOM6/blob/2ab885eddfc47fc0c8c0bae46bc61531104428d5/.testing/Makefile#L495-L501
77-
# Rows in ocean.stats look like:
78-
# 0, 693135.000, 0, En 3.0745627134675957E-23, CFL 0.00000, ...
79-
# where the first three columns are Step, Day, Truncs and the remaining
80-
# columns include a label for what they are (e.g. En = Energy/Mass)
81-
# Header info is only included for new runs so can't be relied on
87+
mom_restart_pointer = self.mom_restart_pointer
88+
89+
# MOM6 saves checksums for each variable in its restart files. Extract these
90+
# attributes for each restart
8291
output_checksums: dict[str, list[any]] = defaultdict(list)
8392

84-
with open(output_filename) as f:
85-
lines = f.readlines()
86-
# Skip header if it exists (for new runs)
87-
istart = 2 if "Step" in lines[0] else 0
88-
for line in lines[istart:]:
89-
for col in line.split(","):
90-
# Only keep columns with labels (ie not Step, Day, Truncs)
91-
col = re.split(" +", col.strip().rstrip("\n"))
92-
if len(col) > 1:
93-
output_checksums[col[0]].append(col[-1])
93+
with open(mom_restart_pointer) as f:
94+
for restart_file in f.readlines():
95+
restart = mom_restart_pointer.parent / restart_file.rstrip()
96+
rootgrp = Dataset(restart, "r")
97+
for variable in sorted(rootgrp.variables):
98+
var = rootgrp[variable]
99+
if "checksum" in var.ncattrs():
100+
output_checksums[variable.strip()].append(var.checksum.strip())
101+
rootgrp.close()
94102

95103
if schema_version is None:
96104
schema_version = self.default_schema_version

src/model_config_tests/models/model.py

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ def __init__(self, experiment):
2323

2424
self.default_runtime_seconds = DEFAULT_RUNTIME_SECONDS
2525

26+
self.output_0 = self.experiment.output000
27+
self.output_1 = self.experiment.output001
28+
2629
def extract_checksums(self, output_directory: Path, schema_version: str):
2730
"""Extract checksums from output directory"""
2831
raise NotImplementedError

src/model_config_tests/test_bit_reproducibility.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def test_restart_repro(self, output_path: Path, control_path: Path):
179179

180180
# Now compare the output between our two short and one long run.
181181
checksums_1d_0 = exp_2x1day.extract_checksums()
182-
checksums_1d_1 = exp_2x1day.extract_checksums(exp_2x1day.output001)
182+
checksums_1d_1 = exp_2x1day.extract_checksums(exp_2x1day.model.output_1)
183183

184184
checksums_2d = exp_2day.extract_checksums()
185185

tests/qa/test_test_access_esm1p5_config.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ def test_test_access_esm1p5_config_release_release_preindustrial():
99
access_esm1p5_configs = RESOURCES_DIR / "access" / "configurations"
1010
test_config = access_esm1p5_configs / "release-preindustrial+concentrations"
1111

12-
assert test_config.exists()
12+
if not test_config.exists():
13+
raise FileNotFoundError(f"The test configuration {test_config} does not exist.")
1314

1415
test_cmd = (
1516
"model-config-tests -s "

tests/qa/test_test_access_esm1p6_config.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ def test_test_access_esm1p6_config_release_release_preindustrial():
1111
# access_esm1p6_configs = RESOURCES_DIR / "access" / "configurations"
1212
# test_config = access_esm1p6_configs / "release-preindustrial+concentrations"
1313

14-
# assert test_config.exists()
14+
# if not test_config.exists():
15+
# raise FileNotFoundError(f"The test configuration {test_config} does not exist.")
1516

1617
# test_cmd = (
1718
# "model-config-tests -s "

tests/qa/test_test_access_om2_config.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ def test_test_access_om2_config_release_1deg_jra55_ryf():
1212
access_om2_configs = RESOURCES_DIR / "access-om2" / "configurations"
1313
test_config = access_om2_configs / "release-1deg_jra55_ryf"
1414

15-
assert test_config.exists()
15+
if not test_config.exists():
16+
raise FileNotFoundError(f"The test configuration {test_config} does not exist.")
1617

1718
test_cmd = (
1819
"model-config-tests -s "
+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import shlex
2+
import shutil
3+
import subprocess
4+
5+
import yaml
6+
7+
from tests.common import RESOURCES_DIR
8+
9+
10+
def test_test_access_om3_config_release_1deg_jra55_ryf():
11+
"""Test ACCESS-OM3 specific config tests"""
12+
access_om3_configs = RESOURCES_DIR / "access-om3" / "configurations"
13+
test_config = access_om3_configs / "om3-dev-1deg_jra55do_ryf"
14+
15+
if not test_config.exists():
16+
raise FileNotFoundError(f"The test configuration {test_config} does not exist.")
17+
18+
test_cmd = (
19+
"model-config-tests -s "
20+
# Run all access_om3 specific tests
21+
"-m access_om3 "
22+
f"--control-path {test_config} "
23+
# Use target branch as can't mock get_git_branch function in utils
24+
f"--target-branch om3-dev-1deg_jra55do_ryf"
25+
)
26+
27+
result = subprocess.run(shlex.split(test_cmd), capture_output=True, text=True)
28+
29+
# Expect the tests to have passed
30+
if result.returncode:
31+
# Print out test logs if there are errors
32+
print(f"Test stdout: {result.stdout}\nTest stderr: {result.stderr}")
33+
34+
assert result.returncode == 0
35+
36+
37+
def test_test_access_om3_config_modified_module_version(tmp_path):
38+
"""Test changing model module version in config.yaml,
39+
will cause tests to fail if paths in exe manifests don't
40+
match released spack.location file"""
41+
access_om3_configs = RESOURCES_DIR / "access-om3" / "configurations"
42+
43+
# Copy test configuration
44+
test_config = access_om3_configs / "om3-dev-1deg_jra55do_ryf"
45+
mock_control_path = tmp_path / "mock_control_path"
46+
shutil.copytree(test_config, mock_control_path)
47+
48+
mock_config = mock_control_path / "config.yaml"
49+
50+
with open(mock_config) as f:
51+
config = yaml.safe_load(f)
52+
53+
# Use a different released version of access-om3 module
54+
config["modules"]["load"] = ["access-om3/2024.09.0"]
55+
56+
with open(mock_config, "w") as f:
57+
yaml.dump(config, f)
58+
59+
test_cmd = (
60+
"model-config-tests -s "
61+
# Only test the manifest exe in release spack location test
62+
"-k test_access_om3_manifest_exe_in_release_spack_location "
63+
f"--control-path {mock_control_path} "
64+
# Use target branch as can't mock get_git_branch function in utils
65+
f"--target-branch om3-dev-1deg_jra55do_ryf"
66+
)
67+
68+
result = subprocess.run(shlex.split(test_cmd), capture_output=True, text=True)
69+
70+
# Expect test to have failed
71+
assert result.returncode == 1
72+
error_msg = "Expected exe path in exe manifest to match an install path in released spack.location"
73+
assert error_msg in result.stdout

tests/qa/test_test_config.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ def test_test_config_access_om2():
1010
access_om2_configs = RESOURCES_DIR / "access-om2" / "configurations"
1111
test_config = access_om2_configs / branch_name
1212

13-
assert test_config.exists()
13+
if not test_config.exists():
14+
raise FileNotFoundError(f"The test configuration {test_config} does not exist.")
1415

1516
test_cmd = (
1617
"model-config-tests -s "

0 commit comments

Comments
 (0)