From c3261d52bcfa5c240b61ede7b69e8fd3e039ce0f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:03:11 +0000 Subject: [PATCH 01/64] chore(deps): update dependency mypy to v2 --- uv.lock | 324 ++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 220 insertions(+), 104 deletions(-) diff --git a/uv.lock b/uv.lock index fd6a4fff1..7f8e43990 100644 --- a/uv.lock +++ b/uv.lock @@ -3,14 +3,14 @@ revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15' and platform_machine == 'ARM64' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", - "python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.15' and platform_machine != 'ARM64' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and platform_machine == 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and platform_machine == 'ARM64' and sys_platform == 'win32'", "python_full_version < '3.14' and platform_machine != 'ARM64' and sys_platform == 'win32'", "python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -163,6 +163,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] + [[package]] name = "asttokens" version = "3.0.1" @@ -2196,62 +2260,103 @@ wheels = [ [[package]] name = "librt" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, - { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, - { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, - { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, - { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, - { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, - { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, - { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, - { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, - { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, - { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, - { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, - { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] [[package]] @@ -2702,45 +2807,56 @@ wheels = [ [[package]] name = "mypy" -version = "1.20.2" +version = "2.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, - { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, - { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, - { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, - { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, - { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, - { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, - { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, - { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, - { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, - { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, - { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, - { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, ] [[package]] @@ -3086,16 +3202,16 @@ name = "parsl" version = "2026.2.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dill", marker = "sys_platform != 'win32'" }, - { name = "filelock", marker = "sys_platform != 'win32'" }, - { name = "psutil", marker = "sys_platform != 'win32'" }, - { name = "pyzmq", marker = "sys_platform != 'win32'" }, - { name = "requests", marker = "sys_platform != 'win32'" }, - { name = "setproctitle", marker = "sys_platform != 'win32'" }, - { name = "sortedcontainers", marker = "sys_platform != 'win32'" }, - { name = "tblib", marker = "sys_platform != 'win32'" }, - { name = "typeguard", marker = "sys_platform != 'win32'" }, - { name = "typing-extensions", marker = "sys_platform != 'win32'" }, + { name = "dill" }, + { name = "filelock" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "requests" }, + { name = "setproctitle" }, + { name = "sortedcontainers" }, + { name = "tblib" }, + { name = "typeguard" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d6/ba/40228c8e434304d959eb4d47924d60967bdf490401720efb8af81dd52db2/parsl-2026.2.16.tar.gz", hash = "sha256:dc34015c07d8a05157f0e62b88eda42087be86856fe1fbc1f513109fcd38b7b8", size = 375580, upload-time = "2026-02-16T22:51:04.583Z" } wheels = [ @@ -3150,7 +3266,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -4718,7 +4834,7 @@ name = "typeguard" version = "4.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform != 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2b/e8/66e25efcc18542d58706ce4e50415710593721aae26e794ab1dec34fb66f/typeguard-4.5.1.tar.gz", hash = "sha256:f6f8ecbbc819c9bc749983cc67c02391e16a9b43b8b27f15dc70ed7c4a007274", size = 80121, upload-time = "2026-02-19T16:09:03.392Z" } wheels = [ From b07129320768046a30f4b870066a3fb56e314057 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:30:04 +0000 Subject: [PATCH 02/64] chore(deps): bump tornado from 6.5.7 to 6.5.8 Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.5.7 to 6.5.8. - [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst) - [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.7...v6.5.8) --- updated-dependencies: - dependency-name: tornado dependency-version: 6.5.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/uv.lock b/uv.lock index 55c5245d1..85ca9080a 100644 --- a/uv.lock +++ b/uv.lock @@ -4664,19 +4664,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, - { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, - { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, - { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +version = "6.5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" }, + { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" }, + { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" }, ] [[package]] From db8ed6ae395130195bac9612515e760249e13cd4 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 16:07:28 +1000 Subject: [PATCH 03/64] fix: remove the fixed depth limit when discovering CMIP7 files The CMIP7 DRS nests files 12 levels below the archive root, so the limit of 10 silently discovered nothing. Bumping the number would break again whenever the root given sits above MIP-DRS7, so discovery now accepts `depth=None` to walk the whole tree and the CMIP7 adapter uses it. --- .../climate_ref/datasets/catalog_builder.py | 20 ++++++++------- .../src/climate_ref/datasets/cmip7.py | 6 +++-- .../unit/datasets/test_catalog_builder.py | 25 +++++++++++++++++++ 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py b/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py index 8d6d37d08..fe4fdd922 100644 --- a/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py +++ b/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py @@ -26,7 +26,7 @@ def discover_files( paths: list[str], include_patterns: list[str] | None = None, - depth: int = 0, + depth: int | None = 0, ) -> list[str]: """ Discover files matching the given glob patterns within the specified paths @@ -41,6 +41,8 @@ def discover_files( depth Maximum directory depth below each root to search. ``0`` means only files directly inside the root directory. + ``None`` searches the whole tree, which is needed when the depth of the + files below the given root is not known ahead of time. Returns ------- @@ -62,7 +64,7 @@ def discover_files( for dirpath, dirnames, filenames in os.walk(root): current_depth = len(Path(dirpath).relative_to(root).parts) - if current_depth >= depth: + if depth is not None and current_depth >= depth: # Still process files at this level, but don't descend further dirnames.clear() @@ -155,7 +157,7 @@ def build_catalog( paths: list[str], parsing_func: DatasetParsingFunction, include_patterns: list[str] | None = None, - depth: int = 0, + depth: int | None = 0, n_jobs: int = 1, ) -> pd.DataFrame: """ @@ -174,7 +176,7 @@ def build_catalog( include_patterns Glob patterns to include (e.g. ``["*.nc"]``) depth - Maximum directory depth to search + Maximum directory depth to search, or ``None`` to search the whole tree n_jobs Number of parallel workers for parsing. ``1`` = sequential, ``-1`` = all CPUs, ``>1`` = that many worker processes. @@ -208,7 +210,7 @@ def build_catalog( def iter_discovered_chunks( paths: list[str], include_patterns: list[str] | None = None, - depth: int = 0, + depth: int | None = 0, chunk_size: int = 10_000, ) -> Iterator[list[str]]: """ @@ -226,7 +228,7 @@ def iter_discovered_chunks( Glob patterns to include (e.g. ``["*.nc"]``). Defaults to ``["*"]``. depth - Maximum directory depth below each root to search. + Maximum directory depth below each root to search, or ``None`` for the whole tree. chunk_size Soft target for the number of files per batch. A batch may exceed this if a single directory contains more matching files. @@ -260,7 +262,7 @@ def _flush() -> Iterator[list[str]]: for dirpath, dirnames, filenames in os.walk(root): dirnames.sort() current_depth = len(Path(dirpath).relative_to(root).parts) - if current_depth >= depth: + if depth is not None and current_depth >= depth: dirnames.clear() matched = [ @@ -300,7 +302,7 @@ def iter_built_catalogs( # noqa: PLR0913 paths: list[str], parsing_func: DatasetParsingFunction, include_patterns: list[str] | None = None, - depth: int = 0, + depth: int | None = 0, n_jobs: int = 1, chunk_size: int = 10_000, ) -> Iterator[pd.DataFrame]: @@ -320,7 +322,7 @@ def iter_built_catalogs( # noqa: PLR0913 include_patterns Glob patterns to include (e.g. ``["*.nc"]``). depth - Maximum directory depth to search. + Maximum directory depth to search, or ``None`` to search the whole tree. n_jobs Number of parallel workers per chunk for parsing. chunk_size diff --git a/packages/climate-ref/src/climate_ref/datasets/cmip7.py b/packages/climate-ref/src/climate_ref/datasets/cmip7.py index 73779aad3..fb871e73b 100644 --- a/packages/climate-ref/src/climate_ref/datasets/cmip7.py +++ b/packages/climate-ref/src/climate_ref/datasets/cmip7.py @@ -280,7 +280,8 @@ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame: paths=[str(file_or_directory)], parsing_func=parsing_function, include_patterns=["*.nc"], - depth=10, + # The DRS nests files 12 levels down, and the root given may sit above MIP-DRS7. + depth=None, n_jobs=self.n_jobs, ) @@ -317,7 +318,8 @@ def iter_local_datasets( paths=[str(file_or_directory)], parsing_func=parsing_function, include_patterns=["*.nc"], - depth=10, + # The DRS nests files 12 levels down, and the root given may sit above MIP-DRS7. + depth=None, n_jobs=self.n_jobs, chunk_size=chunk_size, ): diff --git a/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py b/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py index b5e3191ce..d10546d4a 100644 --- a/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py +++ b/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py @@ -85,6 +85,19 @@ def test_depth_limiting(self, tmp_tree): names_d2 = sorted(Path(f).name for f in files_d2) assert names_d2 == ["a.nc", "b.nc", "root.nc"] + def test_unlimited_depth(self, tmp_path): + # A tree deeper than any previous fixed limit, matching the CMIP7 DRS nesting. + leaf = tmp_path + for level in range(14): + leaf = leaf / f"level{level}" + leaf.mkdir(parents=True) + (leaf / "deep.nc").touch() + + assert discover_files([str(tmp_path)], include_patterns=["*.nc"], depth=10) == [] + + files = discover_files([str(tmp_path)], include_patterns=["*.nc"], depth=None) + assert files == [str(leaf / "deep.nc")] + def test_nonexistent_path(self): files = discover_files(["/nonexistent/path"], include_patterns=["*.nc"], depth=5) assert files == [] @@ -331,6 +344,18 @@ def test_chunk_size_respected_at_directory_boundaries(self, tmp_path): total = sum(len(c) for c in chunks) assert total == 20 + def test_unlimited_depth(self, tmp_path): + leaf = tmp_path + for level in range(14): + leaf = leaf / f"level{level}" + leaf.mkdir(parents=True) + (leaf / "deep.nc").touch() + + chunks = list( + iter_discovered_chunks([str(tmp_path)], include_patterns=["*.nc"], depth=None, chunk_size=10) + ) + assert chunks == [[str(leaf / "deep.nc")]] + def test_empty_root_yields_nothing(self, tmp_path): empty = tmp_path / "empty" empty.mkdir() From e83afc8389bf9bafd0f3bca41b8871987032ee63 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 16:07:55 +1000 Subject: [PATCH 04/64] chore: add changelog fragment --- changelog/897.fix.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/897.fix.md diff --git a/changelog/897.fix.md b/changelog/897.fix.md new file mode 100644 index 000000000..83f993e87 --- /dev/null +++ b/changelog/897.fix.md @@ -0,0 +1,3 @@ +Removes the fixed directory depth limit when discovering CMIP7 files. +The CMIP7 DRS nests files 12 levels below the archive root, so the old limit of 10 silently found nothing. +File discovery now accepts `depth=None` to walk the whole tree, and the CMIP7 adapter uses it. From cf9fb96aed9164e6c0bb2b7679f8d5546503fa98 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 16:12:42 +1000 Subject: [PATCH 05/64] refactor: share the depth-limited walk between both discovery paths Adds a test that the CMIP7 adapter reaches files from the bare archive root, which fails if either call site goes back to a fixed depth. A discovery run that matches nothing now warns rather than logging at info, so the streaming path stops failing quietly. --- changelog/897.fix.md | 6 +-- .../climate_ref/datasets/catalog_builder.py | 42 +++++++++++++------ .../src/climate_ref/datasets/cmip7.py | 4 +- .../unit/datasets/test_catalog_builder.py | 1 - .../tests/unit/datasets/test_cmip7.py | 16 +++++-- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/changelog/897.fix.md b/changelog/897.fix.md index 83f993e87..6dcc2fb22 100644 --- a/changelog/897.fix.md +++ b/changelog/897.fix.md @@ -1,3 +1,3 @@ -Removes the fixed directory depth limit when discovering CMIP7 files. -The CMIP7 DRS nests files 12 levels below the archive root, so the old limit of 10 silently found nothing. -File discovery now accepts `depth=None` to walk the whole tree, and the CMIP7 adapter uses it. +CMIP7 file discovery now walks the whole directory tree. +The DRS nests files deeper than the previous limit of 10 directories, +so ingesting a `MIP-DRS7` root found no files at all. diff --git a/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py b/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py index fe4fdd922..f53d6cd23 100644 --- a/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py +++ b/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py @@ -23,6 +23,32 @@ TRACEBACK = "TRACEBACK" +def _walk_within_depth(root: Path, depth: int | None) -> Iterator[tuple[str, list[str]]]: + """ + Walk ``root`` in sorted order, yielding ``(dirpath, filenames)`` for each directory visited. + + Files at the depth limit are still yielded, the walk just stops descending past it. + + Parameters + ---------- + root + Directory to walk. + depth + Maximum directory depth below ``root``, or ``None`` to walk the whole tree. + ``0`` visits only ``root`` itself. + + Yields + ------ + : + The directory path and the names of the files directly inside it. + """ + for dirpath, dirnames, filenames in os.walk(root): + dirnames.sort() + if depth is not None and len(Path(dirpath).relative_to(root).parts) >= depth: + dirnames.clear() + yield dirpath, filenames + + def discover_files( paths: list[str], include_patterns: list[str] | None = None, @@ -62,12 +88,7 @@ def discover_files( assets.append(str(root)) continue - for dirpath, dirnames, filenames in os.walk(root): - current_depth = len(Path(dirpath).relative_to(root).parts) - if depth is not None and current_depth >= depth: - # Still process files at this level, but don't descend further - dirnames.clear() - + for dirpath, filenames in _walk_within_depth(root, depth): for filename in filenames: if any(fnmatch.fnmatch(filename, pat) for pat in include_patterns): assets.append(os.path.join(dirpath, filename)) @@ -259,12 +280,7 @@ def _flush() -> Iterator[list[str]]: yield from _flush() continue - for dirpath, dirnames, filenames in os.walk(root): - dirnames.sort() - current_depth = len(Path(dirpath).relative_to(root).parts) - if depth is not None and current_depth >= depth: - dirnames.clear() - + for dirpath, filenames in _walk_within_depth(root, depth): matched = [ os.path.join(dirpath, fn) for fn in filenames @@ -351,4 +367,4 @@ def iter_built_catalogs( # noqa: PLR0913 yield df if not any_emitted: - logger.info(f"No valid files found in {paths} matching {include_patterns}") + logger.warning(f"No valid files found in {paths} matching {include_patterns}") diff --git a/packages/climate-ref/src/climate_ref/datasets/cmip7.py b/packages/climate-ref/src/climate_ref/datasets/cmip7.py index fb871e73b..e212ee397 100644 --- a/packages/climate-ref/src/climate_ref/datasets/cmip7.py +++ b/packages/climate-ref/src/climate_ref/datasets/cmip7.py @@ -280,7 +280,7 @@ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame: paths=[str(file_or_directory)], parsing_func=parsing_function, include_patterns=["*.nc"], - # The DRS nests files 12 levels down, and the root given may sit above MIP-DRS7. + # Unbounded: how deep the DRS sits below the root given is not known ahead of time. depth=None, n_jobs=self.n_jobs, ) @@ -318,7 +318,7 @@ def iter_local_datasets( paths=[str(file_or_directory)], parsing_func=parsing_function, include_patterns=["*.nc"], - # The DRS nests files 12 levels down, and the root given may sit above MIP-DRS7. + # Unbounded: how deep the DRS sits below the root given is not known ahead of time. depth=None, n_jobs=self.n_jobs, chunk_size=chunk_size, diff --git a/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py b/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py index d10546d4a..05eaf67aa 100644 --- a/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py +++ b/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py @@ -86,7 +86,6 @@ def test_depth_limiting(self, tmp_tree): assert names_d2 == ["a.nc", "b.nc", "root.nc"] def test_unlimited_depth(self, tmp_path): - # A tree deeper than any previous fixed limit, matching the CMIP7 DRS nesting. leaf = tmp_path for level in range(14): leaf = leaf / f"level{level}" diff --git a/packages/climate-ref/tests/unit/datasets/test_cmip7.py b/packages/climate-ref/tests/unit/datasets/test_cmip7.py index 40b668ec3..9bbef594c 100644 --- a/packages/climate-ref/tests/unit/datasets/test_cmip7.py +++ b/packages/climate-ref/tests/unit/datasets/test_cmip7.py @@ -284,9 +284,7 @@ def test_streaming_matches_whole_tree(self, tmp_path, config): config.cmip7_parser = "drs" adapter = CMIP7DatasetAdapter(config=config) _build_cmip7_archive(tmp_path / "archive", self.DATASETS) - # CMIP7DatasetAdapter walks with depth=10, so point it at the - # activity_id parent (MIP-DRS7/CMIP7) rather than the bare archive root. - root = tmp_path / "archive" / "MIP-DRS7" / "CMIP7" / "CMIP" + root = tmp_path / "archive" whole = adapter.find_local_datasets(root) streamed = pd.concat(list(adapter.iter_local_datasets(root, chunk_size=2))) @@ -296,6 +294,18 @@ def test_streaming_matches_whole_tree(self, tmp_path, config): sort_data_catalog(streamed.reset_index(drop=True)), ) + def test_finds_datasets_from_the_archive_root(self, tmp_path, config): + """Both entry points must reach files nested a full DRS below the given root.""" + config.cmip7_parser = "drs" + adapter = CMIP7DatasetAdapter(config=config) + _build_cmip7_archive(tmp_path / "archive", self.DATASETS) + root = tmp_path / "archive" + + expected = sum(len(dataset["time_ranges"]) for dataset in self.DATASETS) + + assert len(adapter.find_local_datasets(root)) == expected + assert sum(len(chunk) for chunk in adapter.iter_local_datasets(root)) == expected + def test_streaming_yields_nonempty_chunks(self, tmp_path, config): config.cmip7_parser = "drs" adapter = CMIP7DatasetAdapter(config=config) From be10e02637d454ce6dabae6a1298e999ba8a6df6 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 16:14:09 +1000 Subject: [PATCH 06/64] docs: clarify what the walk helper sorts --- .../climate-ref/src/climate_ref/datasets/catalog_builder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py b/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py index f53d6cd23..1b5d520ea 100644 --- a/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py +++ b/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py @@ -25,7 +25,9 @@ def _walk_within_depth(root: Path, depth: int | None) -> Iterator[tuple[str, list[str]]]: """ - Walk ``root`` in sorted order, yielding ``(dirpath, filenames)`` for each directory visited. + Walk ``root``, descending into subdirectories in sorted order. + + Yields ``(dirpath, filenames)`` for each directory visited. Files at the depth limit are still yielded, the walk just stops descending past it. From 24c903836716e7f02eb06ea46f1801eae99e4a18 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 16:44:38 +1000 Subject: [PATCH 07/64] refactor: drop the directory depth limit from dataset discovery --- changelog/897.fix.md | 5 +- .../climate_ref/datasets/catalog_builder.py | 36 ++------ .../src/climate_ref/datasets/cmip6.py | 2 - .../src/climate_ref/datasets/cmip7.py | 4 - .../datasets/esmvaltool_reference.py | 1 - .../src/climate_ref/datasets/obs4mips.py | 1 - .../unit/datasets/test_catalog_builder.py | 86 ++++--------------- 7 files changed, 26 insertions(+), 109 deletions(-) diff --git a/changelog/897.fix.md b/changelog/897.fix.md index 6dcc2fb22..e722aa81b 100644 --- a/changelog/897.fix.md +++ b/changelog/897.fix.md @@ -1,3 +1,2 @@ -CMIP7 file discovery now walks the whole directory tree. -The DRS nests files deeper than the previous limit of 10 directories, -so ingesting a `MIP-DRS7` root found no files at all. +Dataset discovery now walks the whole directory tree instead of stopping 10 directories below the root. +The CMIP7 DRS nests files deeper than that, so ingesting a `MIP-DRS7` root found no files at all. diff --git a/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py b/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py index 1b5d520ea..941ccd75c 100644 --- a/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py +++ b/packages/climate-ref/src/climate_ref/datasets/catalog_builder.py @@ -23,21 +23,14 @@ TRACEBACK = "TRACEBACK" -def _walk_within_depth(root: Path, depth: int | None) -> Iterator[tuple[str, list[str]]]: +def _walk_sorted(root: Path) -> Iterator[tuple[str, list[str]]]: """ Walk ``root``, descending into subdirectories in sorted order. - Yields ``(dirpath, filenames)`` for each directory visited. - - Files at the depth limit are still yielded, the walk just stops descending past it. - Parameters ---------- root Directory to walk. - depth - Maximum directory depth below ``root``, or ``None`` to walk the whole tree. - ``0`` visits only ``root`` itself. Yields ------ @@ -46,15 +39,12 @@ def _walk_within_depth(root: Path, depth: int | None) -> Iterator[tuple[str, lis """ for dirpath, dirnames, filenames in os.walk(root): dirnames.sort() - if depth is not None and len(Path(dirpath).relative_to(root).parts) >= depth: - dirnames.clear() yield dirpath, filenames def discover_files( paths: list[str], include_patterns: list[str] | None = None, - depth: int | None = 0, ) -> list[str]: """ Discover files matching the given glob patterns within the specified paths @@ -66,11 +56,6 @@ def discover_files( include_patterns Glob patterns to include (e.g. ``["*.nc"]``). Defaults to ``["*"]`` if not provided. - depth - Maximum directory depth below each root to search. - ``0`` means only files directly inside the root directory. - ``None`` searches the whole tree, which is needed when the depth of the - files below the given root is not known ahead of time. Returns ------- @@ -90,7 +75,7 @@ def discover_files( assets.append(str(root)) continue - for dirpath, filenames in _walk_within_depth(root, depth): + for dirpath, filenames in _walk_sorted(root): for filename in filenames: if any(fnmatch.fnmatch(filename, pat) for pat in include_patterns): assets.append(os.path.join(dirpath, filename)) @@ -180,7 +165,6 @@ def build_catalog( paths: list[str], parsing_func: DatasetParsingFunction, include_patterns: list[str] | None = None, - depth: int | None = 0, n_jobs: int = 1, ) -> pd.DataFrame: """ @@ -198,8 +182,6 @@ def build_catalog( Must return a dict with an ``INVALID_ASSET`` key on failure. include_patterns Glob patterns to include (e.g. ``["*.nc"]``) - depth - Maximum directory depth to search, or ``None`` to search the whole tree n_jobs Number of parallel workers for parsing. ``1`` = sequential, ``-1`` = all CPUs, ``>1`` = that many worker processes. @@ -214,7 +196,7 @@ def build_catalog( ValueError If no files matching the include patterns are found in the specified paths """ - assets = discover_files(paths, include_patterns=include_patterns, depth=depth) + assets = discover_files(paths, include_patterns=include_patterns) if not assets: raise ValueError(f"No files matching {include_patterns} found in {paths}") @@ -233,7 +215,6 @@ def build_catalog( def iter_discovered_chunks( paths: list[str], include_patterns: list[str] | None = None, - depth: int | None = 0, chunk_size: int = 10_000, ) -> Iterator[list[str]]: """ @@ -250,8 +231,6 @@ def iter_discovered_chunks( include_patterns Glob patterns to include (e.g. ``["*.nc"]``). Defaults to ``["*"]``. - depth - Maximum directory depth below each root to search, or ``None`` for the whole tree. chunk_size Soft target for the number of files per batch. A batch may exceed this if a single directory contains more matching files. @@ -282,7 +261,7 @@ def _flush() -> Iterator[list[str]]: yield from _flush() continue - for dirpath, filenames in _walk_within_depth(root, depth): + for dirpath, filenames in _walk_sorted(root): matched = [ os.path.join(dirpath, fn) for fn in filenames @@ -316,11 +295,10 @@ def _filter_invalid_rows(df: pd.DataFrame) -> pd.DataFrame: return df[df[INVALID_ASSET].isnull()].drop(columns=[INVALID_ASSET, TRACEBACK]) -def iter_built_catalogs( # noqa: PLR0913 +def iter_built_catalogs( paths: list[str], parsing_func: DatasetParsingFunction, include_patterns: list[str] | None = None, - depth: int | None = 0, n_jobs: int = 1, chunk_size: int = 10_000, ) -> Iterator[pd.DataFrame]: @@ -339,8 +317,6 @@ def iter_built_catalogs( # noqa: PLR0913 Must return a dict with an ``INVALID_ASSET`` key on failure. include_patterns Glob patterns to include (e.g. ``["*.nc"]``). - depth - Maximum directory depth to search, or ``None`` to search the whole tree. n_jobs Number of parallel workers per chunk for parsing. chunk_size @@ -354,7 +330,7 @@ def iter_built_catalogs( # noqa: PLR0913 """ any_emitted = False for chunk_paths in iter_discovered_chunks( - paths, include_patterns=include_patterns, depth=depth, chunk_size=chunk_size + paths, include_patterns=include_patterns, chunk_size=chunk_size ): logger.info(f"Parsing chunk of {len(chunk_paths)} files") entries = parse_files(chunk_paths, parsing_func, n_jobs=n_jobs) diff --git a/packages/climate-ref/src/climate_ref/datasets/cmip6.py b/packages/climate-ref/src/climate_ref/datasets/cmip6.py index 6465881df..a54bb4990 100644 --- a/packages/climate-ref/src/climate_ref/datasets/cmip6.py +++ b/packages/climate-ref/src/climate_ref/datasets/cmip6.py @@ -235,7 +235,6 @@ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame: paths=[str(file_or_directory)], parsing_func=parsing_function, include_patterns=["*.nc"], - depth=10, n_jobs=self.n_jobs, ) @@ -272,7 +271,6 @@ def iter_local_datasets( paths=[str(file_or_directory)], parsing_func=parsing_function, include_patterns=["*.nc"], - depth=10, n_jobs=self.n_jobs, chunk_size=chunk_size, ): diff --git a/packages/climate-ref/src/climate_ref/datasets/cmip7.py b/packages/climate-ref/src/climate_ref/datasets/cmip7.py index e212ee397..b112a6e4d 100644 --- a/packages/climate-ref/src/climate_ref/datasets/cmip7.py +++ b/packages/climate-ref/src/climate_ref/datasets/cmip7.py @@ -280,8 +280,6 @@ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame: paths=[str(file_or_directory)], parsing_func=parsing_function, include_patterns=["*.nc"], - # Unbounded: how deep the DRS sits below the root given is not known ahead of time. - depth=None, n_jobs=self.n_jobs, ) @@ -318,8 +316,6 @@ def iter_local_datasets( paths=[str(file_or_directory)], parsing_func=parsing_function, include_patterns=["*.nc"], - # Unbounded: how deep the DRS sits below the root given is not known ahead of time. - depth=None, n_jobs=self.n_jobs, chunk_size=chunk_size, ): diff --git a/packages/climate-ref/src/climate_ref/datasets/esmvaltool_reference.py b/packages/climate-ref/src/climate_ref/datasets/esmvaltool_reference.py index fc076130a..c8d027c84 100644 --- a/packages/climate-ref/src/climate_ref/datasets/esmvaltool_reference.py +++ b/packages/climate-ref/src/climate_ref/datasets/esmvaltool_reference.py @@ -103,7 +103,6 @@ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame: paths=[str(file_or_directory)], parsing_func=parse_esmvaltool_reference, include_patterns=["*.nc"], - depth=10, n_jobs=self.n_jobs, ) if datasets.empty: diff --git a/packages/climate-ref/src/climate_ref/datasets/obs4mips.py b/packages/climate-ref/src/climate_ref/datasets/obs4mips.py index 71e4d8dcc..dbc6cd6e3 100644 --- a/packages/climate-ref/src/climate_ref/datasets/obs4mips.py +++ b/packages/climate-ref/src/climate_ref/datasets/obs4mips.py @@ -201,7 +201,6 @@ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame: paths=[str(file_or_directory)], parsing_func=functools.partial(parse_obs4mips, accepted_activity_ids=self.accepted_activity_ids), include_patterns=["*.nc"], - depth=10, n_jobs=self.n_jobs, ) if datasets.empty: diff --git a/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py b/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py index 05eaf67aa..d205ae72d 100644 --- a/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py +++ b/packages/climate-ref/tests/unit/datasets/test_catalog_builder.py @@ -60,7 +60,7 @@ def empty_dir(tmp_path): class TestDiscoverFiles: def test_finds_nc_files(self, tmp_tree): - files = discover_files([str(tmp_tree)], include_patterns=["*.nc"], depth=10) + files = discover_files([str(tmp_tree)], include_patterns=["*.nc"]) names = [Path(f).name for f in files] assert "root.nc" in names assert "a.nc" in names @@ -69,53 +69,34 @@ def test_finds_nc_files(self, tmp_tree): # .txt should be excluded assert "root.txt" not in names - def test_depth_limiting(self, tmp_tree): - # depth=0: only root directory - files_d0 = discover_files([str(tmp_tree)], include_patterns=["*.nc"], depth=0) - names_d0 = [Path(f).name for f in files_d0] - assert names_d0 == ["root.nc"] - - # depth=1: root + sub - files_d1 = discover_files([str(tmp_tree)], include_patterns=["*.nc"], depth=1) - names_d1 = sorted(Path(f).name for f in files_d1) - assert names_d1 == ["a.nc", "root.nc"] - - # depth=2: root + sub + deep - files_d2 = discover_files([str(tmp_tree)], include_patterns=["*.nc"], depth=2) - names_d2 = sorted(Path(f).name for f in files_d2) - assert names_d2 == ["a.nc", "b.nc", "root.nc"] - - def test_unlimited_depth(self, tmp_path): + def test_walks_arbitrarily_deep_trees(self, tmp_path): leaf = tmp_path for level in range(14): leaf = leaf / f"level{level}" leaf.mkdir(parents=True) (leaf / "deep.nc").touch() - assert discover_files([str(tmp_path)], include_patterns=["*.nc"], depth=10) == [] - - files = discover_files([str(tmp_path)], include_patterns=["*.nc"], depth=None) - assert files == [str(leaf / "deep.nc")] + assert discover_files([str(tmp_path)], include_patterns=["*.nc"]) == [str(leaf / "deep.nc")] def test_nonexistent_path(self): - files = discover_files(["/nonexistent/path"], include_patterns=["*.nc"], depth=5) + files = discover_files(["/nonexistent/path"], include_patterns=["*.nc"]) assert files == [] def test_single_file_path(self, tmp_tree): nc_file = str(tmp_tree / "root.nc") - files = discover_files([nc_file], include_patterns=["*.nc"], depth=0) + files = discover_files([nc_file], include_patterns=["*.nc"]) assert files == [nc_file] # Non-matching pattern - files = discover_files([nc_file], include_patterns=["*.txt"], depth=0) + files = discover_files([nc_file], include_patterns=["*.txt"]) assert files == [] def test_empty_directory(self, empty_dir): - files = discover_files([str(empty_dir)], include_patterns=["*.nc"], depth=5) + files = discover_files([str(empty_dir)], include_patterns=["*.nc"]) assert files == [] def test_default_include_all(self, tmp_tree): - files = discover_files([str(tmp_tree)], depth=0) + files = discover_files([str(tmp_tree)]) names = sorted(Path(f).name for f in files) assert "root.nc" in names assert "root.txt" in names @@ -157,7 +138,6 @@ def test_sequential(self, tmp_tree): paths=[str(tmp_tree)], parsing_func=_good_parser, include_patterns=["*.nc"], - depth=10, n_jobs=1, ) assert isinstance(df, pd.DataFrame) @@ -170,7 +150,6 @@ def test_parallel(self, tmp_tree): paths=[str(tmp_tree)], parsing_func=_good_parser, include_patterns=["*.nc"], - depth=10, n_jobs=2, ) assert len(df) == 4 @@ -180,7 +159,6 @@ def test_parallel_all_cpus(self, tmp_tree): paths=[str(tmp_tree)], parsing_func=_good_parser, include_patterns=["*.nc"], - depth=10, n_jobs=-1, ) assert len(df) == 4 @@ -192,11 +170,10 @@ def test_invalid_asset_filtering(self, tmp_tree): paths=[str(tmp_tree)], parsing_func=_mixed_parser, include_patterns=["*.nc", "*.txt"], - depth=0, n_jobs=1, ) - # Only root.nc should survive (depth=0, .txt filtered as INVALID) - assert len(df) == 1 + # The four .nc files survive, root.txt is filtered as INVALID + assert len(df) == 4 assert "INVALID_ASSET" not in df.columns assert "TRACEBACK" not in df.columns @@ -206,7 +183,6 @@ def test_empty_directory_raises(self, empty_dir): paths=[str(empty_dir)], parsing_func=_good_parser, include_patterns=["*.nc"], - depth=5, ) def test_all_invalid_returns_empty(self, tmp_tree): @@ -218,7 +194,6 @@ def _all_invalid(file: str, **kwargs: Any) -> dict[str, Any]: paths=[str(tmp_tree)], parsing_func=_all_invalid, include_patterns=["*.nc"], - depth=10, ) assert df.empty @@ -235,7 +210,7 @@ class TestParallelParsingSafety: def test_parse_files_runs_in_worker_processes(self, tmp_path): """n_jobs > 1 must execute the parser out-of-process, not in threads.""" root = _flat_tree(tmp_path, n=8) - assets = discover_files([str(root)], include_patterns=["*.nc"], depth=5) + assets = discover_files([str(root)], include_patterns=["*.nc"]) results = parse_files(assets, _pid_parser, n_jobs=2) @@ -248,7 +223,7 @@ def test_parse_files_runs_in_worker_processes(self, tmp_path): def test_parse_files_sequential_runs_in_process(self, tmp_path): """n_jobs == 1 stays in-process (no pool overhead).""" root = _flat_tree(tmp_path, n=4) - assets = discover_files([str(root)], include_patterns=["*.nc"], depth=5) + assets = discover_files([str(root)], include_patterns=["*.nc"]) results = parse_files(assets, _pid_parser, n_jobs=1) @@ -272,7 +247,6 @@ def test_complete_parser_parallel_does_not_crash(self, tmp_path, n_jobs): paths=[str(root)], parsing_func=parse_cmip6_complete, include_patterns=["*.nc"], - depth=5, n_jobs=n_jobs, ) @@ -291,7 +265,6 @@ def test_complete_parser_parallel_matches_sequential(self, tmp_path): paths=[str(root)], parsing_func=parse_cmip6_complete, include_patterns=["*.nc"], - depth=5, ) sequential = build_catalog(n_jobs=1, **kwargs).sort_values("path").reset_index(drop=True) parallel = build_catalog(n_jobs=4, **kwargs).sort_values("path").reset_index(drop=True) @@ -323,7 +296,7 @@ def _wide_tree(tmp_path: Path, num_dirs: int, files_per_dir: int) -> Path: class TestIterDiscoveredChunks: def test_yields_all_files(self, tmp_path): root = _flat_tree(tmp_path, n=25) - chunks = list(iter_discovered_chunks([str(root)], include_patterns=["*.nc"], depth=5, chunk_size=10)) + chunks = list(iter_discovered_chunks([str(root)], include_patterns=["*.nc"], chunk_size=10)) flat = [p for chunk in chunks for p in chunk] assert len(flat) == 25 assert all(p.endswith(".nc") for p in flat) @@ -331,7 +304,7 @@ def test_yields_all_files(self, tmp_path): def test_chunk_size_respected_at_directory_boundaries(self, tmp_path): # 5 directories x 4 files = 20 files. chunk_size=8 forces splits between dirs. root = _wide_tree(tmp_path, num_dirs=5, files_per_dir=4) - chunks = list(iter_discovered_chunks([str(root)], include_patterns=["*.nc"], depth=5, chunk_size=8)) + chunks = list(iter_discovered_chunks([str(root)], include_patterns=["*.nc"], chunk_size=8)) # Every chunk groups whole directories together (no directory split across chunks). for chunk in chunks: dirs = {str(Path(p).parent) for p in chunk} @@ -343,29 +316,15 @@ def test_chunk_size_respected_at_directory_boundaries(self, tmp_path): total = sum(len(c) for c in chunks) assert total == 20 - def test_unlimited_depth(self, tmp_path): - leaf = tmp_path - for level in range(14): - leaf = leaf / f"level{level}" - leaf.mkdir(parents=True) - (leaf / "deep.nc").touch() - - chunks = list( - iter_discovered_chunks([str(tmp_path)], include_patterns=["*.nc"], depth=None, chunk_size=10) - ) - assert chunks == [[str(leaf / "deep.nc")]] - def test_empty_root_yields_nothing(self, tmp_path): empty = tmp_path / "empty" empty.mkdir() - chunks = list(iter_discovered_chunks([str(empty)], include_patterns=["*.nc"], depth=5, chunk_size=10)) + chunks = list(iter_discovered_chunks([str(empty)], include_patterns=["*.nc"], chunk_size=10)) assert chunks == [] def test_single_file_path(self, tmp_tree): nc_file = tmp_tree / "root.nc" - chunks = list( - iter_discovered_chunks([str(nc_file)], include_patterns=["*.nc"], depth=0, chunk_size=10) - ) + chunks = list(iter_discovered_chunks([str(nc_file)], include_patterns=["*.nc"], chunk_size=10)) assert chunks == [[str(nc_file)]] @@ -377,7 +336,6 @@ def test_streams_chunks(self, tmp_path): paths=[str(root)], parsing_func=_good_parser, include_patterns=["*.nc"], - depth=5, n_jobs=1, chunk_size=5, ) @@ -397,7 +355,6 @@ def test_invalid_rows_filtered_per_chunk(self, tmp_tree): paths=[str(tmp_tree)], parsing_func=_mixed_parser, include_patterns=["*.nc", "*.txt"], - depth=10, chunk_size=2, ) ) @@ -416,7 +373,6 @@ def test_empty_input_yields_nothing(self, tmp_path): paths=[str(empty)], parsing_func=_good_parser, include_patterns=["*.nc"], - depth=5, chunk_size=10, ) ) @@ -429,7 +385,6 @@ def test_chunk_larger_than_total_yields_single_chunk(self, tmp_path): paths=[str(root)], parsing_func=_good_parser, include_patterns=["*.nc"], - depth=5, n_jobs=1, chunk_size=100, ) @@ -447,7 +402,6 @@ def _all_invalid(file: str, **kwargs: Any) -> dict[str, Any]: paths=[str(tmp_tree)], parsing_func=_all_invalid, include_patterns=["*.nc"], - depth=10, chunk_size=2, ) ) @@ -462,7 +416,6 @@ def test_no_invalid_column_passes_through(self, tmp_path): paths=[str(root)], parsing_func=_good_parser, include_patterns=["*.nc"], - depth=5, chunk_size=2, ) ) @@ -477,7 +430,6 @@ def test_iter_discovered_chunks_skips_nonexistent_paths(self, tmp_path): iter_discovered_chunks( ["/does/not/exist", str(root)], include_patterns=["*.nc"], - depth=5, chunk_size=10, ) ) @@ -491,7 +443,7 @@ def test_iter_discovered_chunks_directory_overflow_kept_together(self, tmp_path) for i in range(6): (root / f"f_{i}.nc").touch() - chunks = list(iter_discovered_chunks([str(root)], include_patterns=["*.nc"], depth=5, chunk_size=2)) + chunks = list(iter_discovered_chunks([str(root)], include_patterns=["*.nc"], chunk_size=2)) # All six files come from one directory; the buffer flushes once at the end. assert sum(len(c) for c in chunks) == 6 for chunk in chunks: @@ -506,8 +458,6 @@ def test_iter_discovered_chunks_multiple_roots(self, tmp_path): b.mkdir() (b / "2.nc").touch() - chunks = list( - iter_discovered_chunks([str(a), str(b)], include_patterns=["*.nc"], depth=5, chunk_size=10) - ) + chunks = list(iter_discovered_chunks([str(a), str(b)], include_patterns=["*.nc"], chunk_size=10)) flat = [p for chunk in chunks for p in chunk] assert len(flat) == 2 From 42bd444ffe1e01f1d0b4c4173be12e0f633c5095 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 17:09:13 +1000 Subject: [PATCH 08/64] feat: ingest obs4REF under its own source type The obs4REF collection was ingested as obs4MIPs, so the catalog could not show which datasets came from the registry and which from the archive. Ingests it as obs4ref instead. The solver folds the obs4REF catalog into the obs4MIPs one before matching, so diagnostics keep asking for obs4MIPs and a dataset held by both is taken from obs4MIPs. --- docs/development.md | 2 +- docs/getting-started/02-download-datasets.md | 20 +- docs/getting-started/03-ingest.md | 17 +- docs/getting-started/quickstart.md | 2 +- docs/how-to-guides/diagnose-a-deployment.md | 14 +- docs/how-to-guides/docker_deployment.md | 2 +- .../src/climate_ref_core/reference_data.py | 4 +- .../tests/unit/test_datasets.py | 4 +- .../dataset_collection_obs4mips_hash.yml | 2 +- .../tests/unit/test_reference_data.py | 3 +- .../surface_temperature.py | 4 +- .../global-sst-bias/cmip7/catalog.yaml | 4 +- .../global-sst-bias/default/catalog.yaml | 4 +- .../src/climate_ref_ilamb/standard.py | 11 +- .../burntfractionall-gfed/cmip6/catalog.yaml | 4 +- .../burntfractionall-gfed/cmip7/catalog.yaml | 4 +- .../test-data/csoil-hwsd2/cmip6/catalog.yaml | 2 +- .../test-data/csoil-hwsd2/cmip7/catalog.yaml | 2 +- .../test-data/gpp-wecann/cmip6/catalog.yaml | 2 +- .../test-data/gpp-wecann/cmip7/catalog.yaml | 2 +- .../test-data/mrro-lora/cmip6/catalog.yaml | 2 +- .../test-data/mrro-lora/cmip7/catalog.yaml | 2 +- .../test-data/nbp-hoffman/cmip6/catalog.yaml | 4 +- .../test-data/nbp-hoffman/cmip7/catalog.yaml | 4 +- .../test-data/snc-esacci/cmip6/catalog.yaml | 2 +- .../test-data/snc-esacci/cmip7/catalog.yaml | 2 +- .../so-woa2023-surface/cmip6/catalog.yaml | 4 +- .../so-woa2023-surface/cmip7/catalog.yaml | 4 +- .../thetao-woa2023-surface/cmip6/catalog.yaml | 4 +- .../thetao-woa2023-surface/cmip7/catalog.yaml | 4 +- .../test_solve_regression_amoc_rapid_.yml | 58 +- ...olve_regression_burntfractionall_gfed_.yml | 40 +- .../test_solve_regression_csoil_hwsd2_.yml | 140 ++-- .../test_solve_regression_gpp_wecann_.yml | 180 ++--- .../test_solve_regression_mrro_lora_.yml | 264 +++---- .../test_solve_regression_nbp_hoffman_.yml | 148 ++-- .../test_solve_regression_snc_esacci_.yml | 208 +++--- ...t_solve_regression_so_woa2023_surface_.yml | 308 ++++---- ...lve_regression_thetao_woa2023_surface_.yml | 338 ++++----- packages/climate-ref-pmp/conftest.py | 3 +- .../src/climate_ref_pmp/diagnostics/enso.py | 4 +- .../diagnostics/variability_modes.py | 4 +- .../test-data/enso_proc/cmip6/catalog.yaml | 38 +- .../test-data/enso_proc/cmip7/catalog.yaml | 38 +- .../test-data/enso_tel/cmip6/catalog.yaml | 14 +- .../test-data/enso_tel/cmip7/catalog.yaml | 14 +- .../cmip6/catalog.yaml | 4 +- .../cmip7/catalog.yaml | 4 +- .../cmip6/catalog.yaml | 4 +- .../cmip7/catalog.yaml | 4 +- packages/climate-ref/conftest.py | 20 +- .../src/climate_ref/conftest_plugin.py | 13 +- .../src/climate_ref/datasets/obs4mips.py | 75 +- .../src/climate_ref/doctor/checks/data.py | 212 +++++- .../climate-ref/src/climate_ref/solver.py | 93 ++- .../test_obs4mips/obs4mips_catalog_db.yml | 687 ------------------ .../tests/unit/datasets/test_obs4ref.py | 56 +- .../climate-ref/tests/unit/test_doctor.py | 179 ++++- .../tests/unit/test_doctor_registry.py | 3 + .../climate-ref/tests/unit/test_solver.py | 66 +- .../unit/test_solver/test_solve_metrics.yml | 2 +- scripts/generate_esgf_catalog.py | 14 + .../esgf-catalog/obs4mips_catalog.parquet | Bin 23438 -> 21356 bytes .../esgf-catalog/obs4ref_catalog.parquet | Bin 0 -> 16326 bytes 64 files changed, 1608 insertions(+), 1768 deletions(-) create mode 100644 tests/test-data/esgf-catalog/obs4ref_catalog.parquet diff --git a/docs/development.md b/docs/development.md index a29448fc9..cf5735f88 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ For development, we use a consistent set of decimated sample data. ```bash make fetch-test-data uv run ref datasets ingest --source-type cmip6 $PWD/tests/test-data/sample-data/CMIP6/ -uv run ref datasets ingest --source-type obs4mips $PWD/tests/test-data/sample-data/obs4REF/ +uv run ref datasets ingest --source-type obs4ref $PWD/tests/test-data/sample-data/obs4REF/ ``` This is enough to run a `solve` against. diff --git a/docs/getting-started/02-download-datasets.md b/docs/getting-started/02-download-datasets.md index 19945ecec..ec67b64e5 100644 --- a/docs/getting-started/02-download-datasets.md +++ b/docs/getting-started/02-download-datasets.md @@ -102,25 +102,19 @@ but it also re-fetches four datasets the obs4REF registry already provides, so only use it if you are **not** using that registry — see the warning below. Files land in the [intake-esgf `local_cache`](https://intake-esgf.readthedocs.io/en/latest/configure.html), -and are ingested with the `obs4mips` source type, the same as the obs4REF collection. +and are ingested with the `obs4mips` source type. +The obs4REF collection is ingested with `obs4ref` instead, so the two are never confused. -/// admonition | Do not fetch these twice - type: warning +/// admonition | Fetching these twice + type: note The script also fetches `CERES-EBAF-4-2`, `GPCP-Monthly-3-2`, `HadISST-1-1` and `TropFlux-1-0`. These are the ESGF-published copies of datasets that were curated for the REF before publication, so the obs4REF registry ships them as well. -**If you have already fetched the obs4REF registry, do not fetch these from ESGF as well.** -Where the two copies carry the same version they share an `instance_id` and ingest as a single -dataset holding *both* sets of files, which covers the record twice. -`GPCP-Monthly-3-2` `pr` `v20231205` is the clearest case: obs4REF ships one file spanning 1983-2023 and ESGF ships 41 yearly files spanning the same period, giving one dataset of 42 files. -A diagnostic reading it sees every time step twice. - -Where the published copy carries a *newer* version, there is no such problem: -the two ingest as separate datasets and the catalog uses the later version. - -This is temporary until we split the obs4REF ingest from the obs4MIPs ingest. +If you fetch these from ESGF as well as from the obs4REF registry, the ESGF copy is the one used. +obs4MIPs is the official home of the reference data, and the registry only fills in what is not published yet. +`ref doctor` lists the registry copies that have been superseded this way. /// ### Future work diff --git a/docs/getting-started/03-ingest.md b/docs/getting-started/03-ingest.md index 74520a90e..21d8f68a2 100644 --- a/docs/getting-started/03-ingest.md +++ b/docs/getting-started/03-ingest.md @@ -10,14 +10,27 @@ Before you begin, ensure you have: ## 1. Ingest reference datasets -The `obs4REF` collection we downloaded in the previous step uses the `obs4mips` source type as the data are obs4MIPs compatible. This command will extract metadata from the files and store it in the Climate-REF catalog, and print a summary of the ingested datasets. +The `obs4REF` collection we downloaded in the previous step is ingested under the `obs4ref` source type. +The files follow the obs4MIPs conventions. + +Where a dataset is ingested from both, the obs4MIPs copy is used, and then it falls back to obs4REF. +This command will extract metadata from the files and store it in the Climate-REF catalog, and print a summary of the ingested datasets. ```bash -ref datasets ingest --source-type obs4mips $REF_CONFIGURATION/datasets/obs4ref +ref datasets ingest --source-type obs4ref $REF_CONFIGURATION/datasets/obs4ref ``` Replace `$REF_CONFIGURATION/datasets/obs4ref` with the directory used when [fetched the obs4REF data](02-download-datasets.md#fetch-obs4ref-datasets). +/// admonition | Upgrading from an earlier release + type: note + +Earlier releases ingested this collection with `--source-type obs4mips`. +Those datasets still solve, but the catalog cannot tell them apart from published obs4MIPs data. +Re-ingest the collection with `--source-type obs4ref`, then retract the old rows. +`ref doctor` lists them under `misfiled-obs4ref`. +/// + ## 2. Ingest CMIP6 data To ingest CMIP6 files, point the CLI at a directory of netCDF files and set `cmip6` as the source type: diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index b4b37da29..2a870c622 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -109,7 +109,7 @@ The model data is CMIP6; the observation uses the `obs4mips` source type: ```bash ref datasets ingest --source-type cmip6 $REF_CONFIGURATION/datasets/sample-data/CMIP6 -ref datasets ingest --source-type obs4mips $REF_CONFIGURATION/datasets/quickstart/obs4REF +ref datasets ingest --source-type obs4ref $REF_CONFIGURATION/datasets/quickstart/obs4REF ``` Check the catalog: diff --git a/docs/how-to-guides/diagnose-a-deployment.md b/docs/how-to-guides/diagnose-a-deployment.md index 0c25bfd9a..d1abcc5c8 100644 --- a/docs/how-to-guides/diagnose-a-deployment.md +++ b/docs/how-to-guides/diagnose-a-deployment.md @@ -1,9 +1,13 @@ # Diagnose a deployment -`ref doctor` looks for the problems that a solve hides rather than reports: reference data that is missing, -so its diagnostics quietly plan no executions; -data ingested under a source type no diagnostic requires, so nothing selects it; -and datasets whose files cover the same period twice, so a diagnostic reads that period more than once. +`ref doctor` looks for the problems that a solve hides rather than reports: + +- reference data that is missing, so its diagnostics quietly plan no executions. +- data ingested under a source type no diagnostic requires, so nothing selects it. +- obs4REF data ingested as `obs4mips`, so the catalog cannot say where it came from. +- obs4REF datasets that obs4MIPs has since published, so the registry copy is no longer used. +- datasets whose files cover the same period twice, so a diagnostic reads that period more than once. +- diagnostics the ingested data cannot solve at all, with the requirement that goes unmet. ```bash ref doctor @@ -14,7 +18,7 @@ under the remedy they have in common, so a deployment missing twenty reference datasets reads as one instruction and twenty names: ```text -3 findings from 4 checks: 3 warnings +3 findings from 7 checks: 3 warnings missing-reference-data 3 warnings Fetch these, then ingest the directory they land in. diff --git a/docs/how-to-guides/docker_deployment.md b/docs/how-to-guides/docker_deployment.md index 239c7b85f..4fae42ef5 100644 --- a/docs/how-to-guides/docker_deployment.md +++ b/docs/how-to-guides/docker_deployment.md @@ -43,7 +43,7 @@ These data can be ingested into the REF using the following commands (note that ```bash docker-compose run --rm climate-ref datasets ingest --source-type cmip6 /ref/data/CMIP6 -docker-compose run --rm climate-ref datasets ingest --source-type obs4mips /ref/data/obs4ref +docker-compose run --rm climate-ref datasets ingest --source-type obs4ref /ref/data/obs4ref ``` After the data has been ingested, the REF can be run using the following command. diff --git a/packages/climate-ref-core/src/climate_ref_core/reference_data.py b/packages/climate-ref-core/src/climate_ref_core/reference_data.py index 57dec4c34..c690922d6 100644 --- a/packages/climate-ref-core/src/climate_ref_core/reference_data.py +++ b/packages/climate-ref-core/src/climate_ref_core/reference_data.py @@ -141,8 +141,8 @@ def source_ids_by_registry( parser = _registry_key_parser(entry.source_type) if parser is None: continue - # obs4REF data is ingested under the obs4MIPs source type, so a registry declaring - # obs4REF supplies requirements written against either. + # The obs4REF registry carries what obs4MIPs has not published yet, + # so it supplies requirements written against either. source_types = {entry.source_type.value} if entry.source_type is SourceDatasetType.obs4REF: source_types.add(SourceDatasetType.obs4MIPs.value) diff --git a/packages/climate-ref-core/tests/unit/test_datasets.py b/packages/climate-ref-core/tests/unit/test_datasets.py index e2e6c9a58..4676e112a 100644 --- a/packages/climate-ref-core/tests/unit/test_datasets.py +++ b/packages/climate-ref-core/tests/unit/test_datasets.py @@ -143,8 +143,8 @@ def test_get_attr(self, dataset_collection_obs4mips): expected = dataset_collection_obs4mips.datasets.instance_id assert dataset_collection_obs4mips.instance_id.equals(expected) - def test_hash(self, dataset_collection_obs4mips, obs4mips_data_catalog, data_regression): - ts_datasets = obs4mips_data_catalog[obs4mips_data_catalog.variable_id == "ts"] + def test_hash(self, dataset_collection_obs4mips, obs4ref_data_catalog, data_regression): + ts_datasets = obs4ref_data_catalog[obs4ref_data_catalog.variable_id == "ts"] dataset_hash = hash(DatasetCollection(ts_datasets, "instance_id")) assert isinstance(dataset_hash, int) diff --git a/packages/climate-ref-core/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml b/packages/climate-ref-core/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml index 747f8fbd8..0be85b6d5 100644 --- a/packages/climate-ref-core/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +++ b/packages/climate-ref-core/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml @@ -1,2 +1,2 @@ -2084895140671242963 +128483424965463584 ... diff --git a/packages/climate-ref-core/tests/unit/test_reference_data.py b/packages/climate-ref-core/tests/unit/test_reference_data.py index a57c3b165..110b631fc 100644 --- a/packages/climate-ref-core/tests/unit/test_reference_data.py +++ b/packages/climate-ref-core/tests/unit/test_reference_data.py @@ -78,8 +78,7 @@ def test_keys_are_read_per_source_type(self): found = source_ids_by_registry(manager) - # An obs4REF registry answers for obs4MIPs requirements too, since that is the - # source type its data is ingested under. + # The obs4REF registry supplies obs4MIPs requirements too assert found[(SourceDatasetType.obs4REF.value, "WECANN-1-0")] == ["obs4ref"] assert found[(SourceDatasetType.obs4MIPs.value, "WECANN-1-0")] == ["obs4ref"] diff --git a/packages/climate-ref-example/src/climate_ref_example/surface_temperature.py b/packages/climate-ref-example/src/climate_ref_example/surface_temperature.py index d73ad4607..d3e202608 100644 --- a/packages/climate-ref-example/src/climate_ref_example/surface_temperature.py +++ b/packages/climate-ref-example/src/climate_ref_example/surface_temperature.py @@ -390,7 +390,7 @@ class GlobalMeanSurfaceTemperatureBias(Diagnostic): RegistryRequest( slug="surface-temperature-obs", registry_name="obs4ref", - source_type="obs4MIPs", + source_type="obs4REF", facets={"source_id": _REFERENCE_SOURCE_ID, "variable_id": _REFERENCE_VARIABLE}, ), CMIP6Request( @@ -423,7 +423,7 @@ class GlobalMeanSurfaceTemperatureBias(Diagnostic): RegistryRequest( slug="surface-temperature-obs-cmip7", registry_name="obs4ref", - source_type="obs4MIPs", + source_type="obs4REF", facets={"source_id": _REFERENCE_SOURCE_ID, "variable_id": _REFERENCE_VARIABLE}, ), CMIP7Request( diff --git a/packages/climate-ref-example/tests/test-data/global-sst-bias/cmip7/catalog.yaml b/packages/climate-ref-example/tests/test-data/global-sst-bias/cmip7/catalog.yaml index 939f13a59..c82f7fe2d 100644 --- a/packages/climate-ref-example/tests/test-data/global-sst-bias/cmip7/catalog.yaml +++ b/packages/climate-ref-example/tests/test-data/global-sst-bias/cmip7/catalog.yaml @@ -88,14 +88,14 @@ obs4mips: source_id: HadISST-1-1 variable_id: ts datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2025-01-16 12:00:00' filename: ts_mon_HadISST-1-1_PCMDI_gn_187001-202501.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 institution_id: MOHC long_name: Surface Temperature nominal_resolution: 250 km diff --git a/packages/climate-ref-example/tests/test-data/global-sst-bias/default/catalog.yaml b/packages/climate-ref-example/tests/test-data/global-sst-bias/default/catalog.yaml index 0980d0297..4acdf8d51 100644 --- a/packages/climate-ref-example/tests/test-data/global-sst-bias/default/catalog.yaml +++ b/packages/climate-ref-example/tests/test-data/global-sst-bias/default/catalog.yaml @@ -92,14 +92,14 @@ obs4mips: source_id: HadISST-1-1 variable_id: ts datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2025-01-16 12:00:00' filename: ts_mon_HadISST-1-1_PCMDI_gn_187001-202501.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 institution_id: MOHC long_name: Surface Temperature nominal_resolution: 250 km diff --git a/packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py b/packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py index 921dd9051..3c3e9431c 100644 --- a/packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py +++ b/packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py @@ -690,7 +690,7 @@ def _build_test_data_spec( # noqa: PLR0913 slug=slug, registry_name="obs4ref", facets=obs_filters, - source_type="obs4MIPs", + source_type="obs4REF", ), ) elif obs_source == "obs4mips": @@ -947,12 +947,13 @@ def __init__( # noqa: PLR0915, PLR0912 is_land=is_land, ) - # obs4MIPs data requirement, normally ilamb3 expects the `sources` to + # Observational data requirement, normally ilamb3 expects the `sources` to # resolve to keys in one of its data registries. If instead we find a # dictionary, then assume that these keys are meant to be keywords in a # REF data requirement. - # obs_source key is used to determine whether to fetch from ESGF - # or use the pre-fetched obs4REF registry. + # obs_source names where the test data is fetched from: + # "obs4ref" for the obs4REF registry, "obs4mips" for ESGF. + # The requirement itself asks for obs4MIPs, and obs4REF fills in what ESGF lacks. filters: dict[str, tuple[str, ...]] = {} obs_source = None for _, source in sources.items(): @@ -1030,7 +1031,7 @@ def execute(self, definition: ExecutionDefinition) -> None: # data not yet available in obs4{MIPs,REF}. This logic allows for # DataRequirement filters to be added as a 'source' in the ilamb # configure file. If a dictionary instead of a string was found, we - # populate an obs4MIPs requirement. + # populate an observational requirement. if SourceDatasetType.obs4MIPs in definition.datasets: # ilamb3 will expect the reference dataset dataframe to have a `key` # column that uniquely describes each dataset. Create one using the diff --git a/packages/climate-ref-ilamb/tests/test-data/burntfractionall-gfed/cmip6/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/burntfractionall-gfed/cmip6/catalog.yaml index 5ec81644a..5e3748ce6 100644 --- a/packages/climate-ref-ilamb/tests/test-data/burntfractionall-gfed/cmip6/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/burntfractionall-gfed/cmip6/catalog.yaml @@ -134,14 +134,14 @@ obs4mips: variable_id: burntFractionAll version: v20260128 datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2020-12-16 12:00:00' filename: burntFractionAll_mon_GFED-5-0_REF_gr_199701-202012.nc finalised: true frequency: mon grid: 0.25x0.25 degree latitude x longitude grid_label: gr - instance_id: obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + instance_id: obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 institution_id: NASA-NOSR long_name: Percentage of Entire Grid Cell That Is Covered by Burnt Vegetation (All Classes) diff --git a/packages/climate-ref-ilamb/tests/test-data/burntfractionall-gfed/cmip7/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/burntfractionall-gfed/cmip7/catalog.yaml index a807468b0..daf41fc7b 100644 --- a/packages/climate-ref-ilamb/tests/test-data/burntfractionall-gfed/cmip7/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/burntfractionall-gfed/cmip7/catalog.yaml @@ -128,14 +128,14 @@ obs4mips: variable_id: burntFractionAll version: v20260128 datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2020-12-16 12:00:00' filename: burntFractionAll_mon_GFED-5-0_REF_gr_199701-202012.nc finalised: true frequency: mon grid: 0.25x0.25 degree latitude x longitude grid_label: gr - instance_id: obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + instance_id: obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 institution_id: NASA-NOSR long_name: Percentage of Entire Grid Cell That Is Covered by Burnt Vegetation (All Classes) diff --git a/packages/climate-ref-ilamb/tests/test-data/csoil-hwsd2/cmip6/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/csoil-hwsd2/cmip6/catalog.yaml index 809a63bd9..234c8d690 100644 --- a/packages/climate-ref-ilamb/tests/test-data/csoil-hwsd2/cmip6/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/csoil-hwsd2/cmip6/catalog.yaml @@ -143,7 +143,7 @@ obs4mips: frequency: fx grid: 0.5x0.5 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + instance_id: obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 institution_id: IIASA-FAO long_name: Carbon Mass in Model Soil Pool nominal_resolution: 0.5x0.5 degree diff --git a/packages/climate-ref-ilamb/tests/test-data/csoil-hwsd2/cmip7/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/csoil-hwsd2/cmip7/catalog.yaml index 0c7b23cde..7baf0d875 100644 --- a/packages/climate-ref-ilamb/tests/test-data/csoil-hwsd2/cmip7/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/csoil-hwsd2/cmip7/catalog.yaml @@ -134,7 +134,7 @@ obs4mips: frequency: fx grid: 0.5x0.5 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + instance_id: obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 institution_id: IIASA-FAO long_name: Carbon Mass in Model Soil Pool nominal_resolution: 0.5x0.5 degree diff --git a/packages/climate-ref-ilamb/tests/test-data/gpp-wecann/cmip6/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/gpp-wecann/cmip6/catalog.yaml index e9cf7c88a..ea13d37de 100644 --- a/packages/climate-ref-ilamb/tests/test-data/gpp-wecann/cmip6/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/gpp-wecann/cmip6/catalog.yaml @@ -224,7 +224,7 @@ obs4mips: frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + instance_id: obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 institution_id: ColumbiaU long_name: Carbon Mass Flux out of Atmosphere due to Gross Primary Production on Land diff --git a/packages/climate-ref-ilamb/tests/test-data/gpp-wecann/cmip7/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/gpp-wecann/cmip7/catalog.yaml index 39dd0347a..383b26a95 100644 --- a/packages/climate-ref-ilamb/tests/test-data/gpp-wecann/cmip7/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/gpp-wecann/cmip7/catalog.yaml @@ -209,7 +209,7 @@ obs4mips: frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + instance_id: obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 institution_id: ColumbiaU long_name: Carbon Mass Flux out of Atmosphere due to Gross Primary Production on Land diff --git a/packages/climate-ref-ilamb/tests/test-data/mrro-lora/cmip6/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/mrro-lora/cmip6/catalog.yaml index 9373f5355..ea31db393 100644 --- a/packages/climate-ref-ilamb/tests/test-data/mrro-lora/cmip6/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/mrro-lora/cmip6/catalog.yaml @@ -143,7 +143,7 @@ obs4mips: frequency: mon grid: 0.5x0.5 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + instance_id: obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 institution_id: ARCCSS long_name: Total Runoff nominal_resolution: 50 km diff --git a/packages/climate-ref-ilamb/tests/test-data/mrro-lora/cmip7/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/mrro-lora/cmip7/catalog.yaml index b56652403..59ec7e816 100644 --- a/packages/climate-ref-ilamb/tests/test-data/mrro-lora/cmip7/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/mrro-lora/cmip7/catalog.yaml @@ -134,7 +134,7 @@ obs4mips: frequency: mon grid: 0.5x0.5 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + instance_id: obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 institution_id: ARCCSS long_name: Total Runoff nominal_resolution: 50 km diff --git a/packages/climate-ref-ilamb/tests/test-data/nbp-hoffman/cmip6/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/nbp-hoffman/cmip6/catalog.yaml index 6a7078d56..bbff58cee 100644 --- a/packages/climate-ref-ilamb/tests/test-data/nbp-hoffman/cmip6/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/nbp-hoffman/cmip6/catalog.yaml @@ -137,14 +137,14 @@ obs4mips: variable_id: nbp version: v20251117 datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2010-05-24 12:00:00' filename: nbp_yr_Hoffman-1-0_REF_gm_1850-2010.nc finalised: true frequency: yr grid: global mean data grid_label: gm - instance_id: obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + instance_id: obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 institution_id: UCI-ORNL long_name: Carbon Mass Flux out of Atmosphere Due to Net Biospheric Production on Land diff --git a/packages/climate-ref-ilamb/tests/test-data/nbp-hoffman/cmip7/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/nbp-hoffman/cmip7/catalog.yaml index fd13cfce1..21305cf19 100644 --- a/packages/climate-ref-ilamb/tests/test-data/nbp-hoffman/cmip7/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/nbp-hoffman/cmip7/catalog.yaml @@ -128,14 +128,14 @@ obs4mips: variable_id: nbp version: v20251117 datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2010-05-24 12:00:00' filename: nbp_yr_Hoffman-1-0_REF_gm_1850-2010.nc finalised: true frequency: yr grid: global mean data grid_label: gm - instance_id: obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + instance_id: obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 institution_id: UCI-ORNL long_name: Carbon Mass Flux out of Atmosphere Due to Net Biospheric Production on Land diff --git a/packages/climate-ref-ilamb/tests/test-data/snc-esacci/cmip6/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/snc-esacci/cmip6/catalog.yaml index c85a7f672..ffe38e03c 100644 --- a/packages/climate-ref-ilamb/tests/test-data/snc-esacci/cmip6/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/snc-esacci/cmip6/catalog.yaml @@ -143,7 +143,7 @@ obs4mips: frequency: mon grid: 0.5x0.5 degree grid_label: gn - instance_id: obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + instance_id: obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 institution_id: ESACCI long_name: Fractional Snow Cover nominal_resolution: 0.5x0.5 degree diff --git a/packages/climate-ref-ilamb/tests/test-data/snc-esacci/cmip7/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/snc-esacci/cmip7/catalog.yaml index 2b6155a6b..a610cf3a8 100644 --- a/packages/climate-ref-ilamb/tests/test-data/snc-esacci/cmip7/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/snc-esacci/cmip7/catalog.yaml @@ -134,7 +134,7 @@ obs4mips: frequency: mon grid: 0.5x0.5 degree grid_label: gn - instance_id: obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + instance_id: obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 institution_id: ESACCI long_name: Fractional Snow Cover nominal_resolution: 0.5x0.5 degree diff --git a/packages/climate-ref-ilamb/tests/test-data/so-woa2023-surface/cmip6/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/so-woa2023-surface/cmip6/catalog.yaml index b0718866d..0e0127fa1 100644 --- a/packages/climate-ref-ilamb/tests/test-data/so-woa2023-surface/cmip6/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/so-woa2023-surface/cmip6/catalog.yaml @@ -836,14 +836,14 @@ obs4mips: variable_id: so version: v20251024 datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '1988-12-15 00:00:00' filename: so_monC_WOA-23_REF_gn_200501-202212.nc finalised: true frequency: monC grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + instance_id: obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 institution_id: NOAA-NCEI-OCL long_name: Objectively analyzed mean fields for sea_water_practical_salinity at standard depth levels. diff --git a/packages/climate-ref-ilamb/tests/test-data/so-woa2023-surface/cmip7/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/so-woa2023-surface/cmip7/catalog.yaml index 361eb1ada..79fd75e28 100644 --- a/packages/climate-ref-ilamb/tests/test-data/so-woa2023-surface/cmip7/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/so-woa2023-surface/cmip7/catalog.yaml @@ -756,14 +756,14 @@ obs4mips: variable_id: so version: v20251024 datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '1988-12-15 00:00:00' filename: so_monC_WOA-23_REF_gn_200501-202212.nc finalised: true frequency: monC grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + instance_id: obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 institution_id: NOAA-NCEI-OCL long_name: Objectively analyzed mean fields for sea_water_practical_salinity at standard depth levels. diff --git a/packages/climate-ref-ilamb/tests/test-data/thetao-woa2023-surface/cmip6/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/thetao-woa2023-surface/cmip6/catalog.yaml index ef4486cf4..a29ca2795 100644 --- a/packages/climate-ref-ilamb/tests/test-data/thetao-woa2023-surface/cmip6/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/thetao-woa2023-surface/cmip6/catalog.yaml @@ -836,14 +836,14 @@ obs4mips: variable_id: thetao version: v20251024 datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '1988-12-15 00:00:00' filename: thetao_monC_WOA-23_REF_gn_200501-202212.nc finalised: true frequency: monC grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + instance_id: obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 institution_id: NOAA-NCEI-OCL long_name: Objectively analyzed mean fields for sea_water_temperature at standard depth levels. diff --git a/packages/climate-ref-ilamb/tests/test-data/thetao-woa2023-surface/cmip7/catalog.yaml b/packages/climate-ref-ilamb/tests/test-data/thetao-woa2023-surface/cmip7/catalog.yaml index 6233b4c1c..ed0e9062f 100644 --- a/packages/climate-ref-ilamb/tests/test-data/thetao-woa2023-surface/cmip7/catalog.yaml +++ b/packages/climate-ref-ilamb/tests/test-data/thetao-woa2023-surface/cmip7/catalog.yaml @@ -756,14 +756,14 @@ obs4mips: variable_id: thetao version: v20251024 datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '1988-12-15 00:00:00' filename: thetao_monC_WOA-23_REF_gn_200501-202212.nc finalised: true frequency: monC grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + instance_id: obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 institution_id: NOAA-NCEI-OCL long_name: Objectively analyzed mean fields for sea_water_temperature at standard depth levels. diff --git a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_amoc_rapid_.yml b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_amoc_rapid_.yml index 124066dd3..ebf013c75 100644 --- a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_amoc_rapid_.yml +++ b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_amoc_rapid_.yml @@ -4,173 +4,173 @@ cmip6_historical_gn_r1i1p101f1_GISS-E3-G__obs4mips_gm_RAPID-2023-1a_msftmz_v2025 - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.Ofx.sftof.gn.v20230320 - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.Omon.msftmz.gn.v20230320 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_ACCESS-CM2__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.Ofx.areacello.gn.v20191108 - CMIP6.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.Ofx.sftof.gn.v20191108 - CMIP6.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.Omon.msftmz.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Ofx.areacello.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Ofx.sftof.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Omon.msftmz.gn.v20191115 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_CAS-ESM2-0__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Ofx.areacello.gn.v20201228 - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Ofx.volcello.gn.v20201228 - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Omon.msftmz.gn.v20201229 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_CESM2-FV2__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Ofx.areacello.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Ofx.sftof.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Omon.msftmz.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM-FV2__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Ofx.areacello.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Ofx.sftof.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Omon.msftmz.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Ofx.sftof.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Omon.msftmz.gn.v20190917 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_CESM2__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Ofx.areacello.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Ofx.sftof.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Omon.msftmz.gn.v20190919 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_CanESM5-1__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Ofx.areacello.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Ofx.volcello.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Omon.msftmz.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_CanESM5__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Ofx.areacello.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Ofx.sftof.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Omon.msftmz.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_FGOALS-g3__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.Ofx.areacello.gn.v20200917 - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.Ofx.volcello.gn.v20200917 - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.Omon.msftmz.gn.v20191012 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G-CC__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.Omon.msftmz.gn.v20190815 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-G__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.Omon.msftmz.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_ICON-ESM-LR__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.Ofx.areacello.gn.v20210215 - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.Omon.msftmz.gn.v20210215 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_MPI-ESM-1-2-HAM__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Ofx.areacello.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Ofx.sftof.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Omon.msftmz.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-HR__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.Ofx.areacello.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.Ofx.sftof.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.Omon.msftmz.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-LR__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Ofx.areacello.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Ofx.sftof.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Omon.msftmz.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p1f1_SAM0-UNICON__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.Ofx.areacello.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.Omon.msftmz.gn.v20190323 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gn_r1i1p2f1_CanESM5-CanOE__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Ofx.areacello.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Ofx.sftof.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Omon.msftmz.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gr1_r1i1p1f1_INM-CM4-8__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.Omon.msftmz.gr1.v20190530 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gr1_r1i1p1f1_INM-CM5-0__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.Omon.msftmz.gr1.v20190610 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gr2z_r1i1p1f1_MRI-ESM2-0__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.Omon.msftmz.gr2z.v20191210 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gr_r1i1p1f1_E3SM-1-1-ECA__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.Omon.msftmz.gr.v20200917 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gr_r1i1p1f1_E3SM-1-1__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.Omon.msftmz.gr.v20200917 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gr_r1i1p1f1_MIROC6__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.Omon.msftmz.gr.v20200421 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_gr_r1i1p1f2_MIROC-ES2L__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Omon.msftmz.gr.v20210317 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_grz_r1i1p1f1_NorCPM1__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.Omon.msftmz.grz.v20200724 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_grz_r1i1p1f1_NorESM2-MM__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Omon.msftmz.grz.v20191108 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 cmip6_historical_grz_r1i1p4f1_NorESM2-LM__obs4mips_gm_RAPID-2023-1a_msftmz_v20250902: cmip6: - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p4f1.Omon.msftmz.grz.v20230616 obs4mips: - - obs4MIPs.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 + - obs4REF.obs4REF.NOC.RAPID-2023-1a.mon.msftmz.site.gm.v20250902 diff --git a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_burntfractionall_gfed_.yml b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_burntfractionall_gfed_.yml index 9b1fa95dc..5e568fb53 100644 --- a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_burntfractionall_gfed_.yml +++ b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_burntfractionall_gfed_.yml @@ -4,133 +4,133 @@ cmip6_historical_gn_r1i1p1f1_CESM2-FV2__obs4mips_gr_GFED-5-0_burntFractionAll_v2 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.areacella.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.sftlf.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM-FV2__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Lmon.burntFractionAll.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.fx.areacella.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.fx.sftlf.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Lmon.burntFractionAll.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.areacella.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.sftlf.gn.v20190227 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip6_historical_gn_r1i1p1f1_CESM2__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Lmon.burntFractionAll.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.sftlf.gn.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-SR5__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.Lmon.burntFractionAll.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.areacella.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.sftlf.gn.v20200616 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip6_historical_gn_r1i1p1f1_CMCC-ESM2__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip6: - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.Lmon.burntFractionAll.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.areacella.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.sftlf.gn.v20210114 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip6_historical_gn_r1i1p1f1_NorESM2-LM__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip6: - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.Lmon.burntFractionAll.gn.v20210818 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.fx.areacella.gn.v20190815 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip6_historical_gn_r1i1p1f1_NorESM2-MM__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip6: - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Lmon.burntFractionAll.gn.v20210818 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.areacella.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip6_historical_gr_r1i1p1f1_EC-Earth3-CC__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Lmon.burntFractionAll.gr.v20210113 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.fx.areacella.gr.v20210616 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip6_historical_gr_r1i1p1f2_CNRM-ESM2-1__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.fx.areacella.gr.v20250328 - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Lmon.burntFractionAll.gr.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip7_historical_gn_CESM2-FV2_r1i1p1f1__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip7: - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.mon.burntFractionAll.tavg-u-hxy-u.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip7_historical_gn_CESM2-WACCM-FV2_r1i1p1f1__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.mon.burntFractionAll.tavg-u-hxy-u.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip7_historical_gn_CESM2-WACCM_r1i1p1f1__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.mon.burntFractionAll.tavg-u-hxy-u.gn.v20190227 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip7_historical_gn_CESM2_r1i1p1f1__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip7: - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.mon.burntFractionAll.tavg-u-hxy-u.gn.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip7_historical_gn_CMCC-CM2-SR5_r1i1p1f1__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.mon.burntFractionAll.tavg-u-hxy-u.gn.v20200616 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip7_historical_gn_CMCC-ESM2_r1i1p1f1__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip7: - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.mon.burntFractionAll.tavg-u-hxy-u.gn.v20210114 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip7_historical_gn_NorESM2-LM_r1i1p1f1__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip7: - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190815 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.mon.burntFractionAll.tavg-u-hxy-u.gn.v20210818 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip7_historical_gn_NorESM2-MM_r1i1p1f1__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip7: - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.mon.burntFractionAll.tavg-u-hxy-u.gn.v20210818 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip7_historical_gr_CNRM-ESM2-1_r1i1p1f2__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20250328 - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.mon.burntFractionAll.tavg-u-hxy-u.gr.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 cmip7_historical_gr_EC-Earth3-CC_r1i1p1f1__obs4mips_gr_GFED-5-0_burntFractionAll_v20260128: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20210616 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.mon.burntFractionAll.tavg-u-hxy-u.gr.v20210113 obs4mips: - - obs4MIPs.obs4MIPs.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 + - obs4REF.obs4REF.NASA-NOSR.GFED-5-0.mon.burntFractionAll.25km.gr.v20260128 diff --git a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_csoil_hwsd2_.yml b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_csoil_hwsd2_.yml index c07e5375a..990c3caf7 100644 --- a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_csoil_hwsd2_.yml +++ b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_csoil_hwsd2_.yml @@ -4,447 +4,447 @@ cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_gn_HWSD-2-0_cSoil_v20250903 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.sftlf.gn.v20191115 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_AWI-ESM-1-1-LR__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.Emon.cSoil.gn.v20200212 - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.fx.areacella.gn.v20200212 - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.fx.sftlf.gn.v20200909 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_BCC-CSM2-MR__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.Emon.cSoil.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_BCC-ESM1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.BCC.BCC-ESM1.1pctCO2.r1i1p1f1.fx.areacella.gn.v20190613 - CMIP6.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.Emon.cSoil.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_CESM2-FV2__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Emon.cSoil.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.areacella.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.sftlf.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM-FV2__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Emon.cSoil.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.fx.areacella.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.fx.sftlf.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Emon.cSoil.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.areacella.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.sftlf.gn.v20190227 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_CESM2__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Emon.cSoil.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.sftlf.gn.v20190308 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-SR5__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.Emon.cSoil.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.areacella.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.sftlf.gn.v20200616 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_CMCC-ESM2__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.Emon.cSoil.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.areacella.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.sftlf.gn.v20210114 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_CanESM5-1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Emon.cSoil.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_CanESM5__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Emon.cSoil.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G-CC__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.Emon.cSoil.gn.v20190815 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.Emon.cSoil.gn.v20181015 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-H__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.Emon.cSoil.gn.v20190403 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_MPI-ESM-1-2-HAM__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Emon.cSoil.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.fx.areacella.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.fx.sftlf.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-LR__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Emon.cSoil.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.fx.areacella.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.fx.sftlf.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_NorCPM1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.Emon.cSoil.gn.v20200724 - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.fx.areacella.gn.v20200724 - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.fx.sftlf.gn.v20200724 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_NorESM2-LM__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.C4MIP CDRMIP.NCC.NorESM2-LM.esm-1pct-brch-1000PgC.r1i1p1f1.fx.areacella.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.Emon.cSoil.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_NorESM2-MM__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Emon.cSoil.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.areacella.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_SAM0-UNICON__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.Emon.cSoil.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.fx.areacella.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.fx.sftlf.gn.v20190323 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f1_TaiESM1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.Emon.cSoil.gn.v20200624 - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.fx.areacella.gn.v20200624 - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.fx.sftlf.gn.v20200624 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f2_MIROC-ES2L__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Emon.cSoil.gn.v20200124 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.fx.areacella.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.fx.sftlf.gn.v20190823 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f2_UKESM1-0-LL__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.Emon.cSoil.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gn_r1i1p1f2_UKESM1-1-LL__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.Emon.cSoil.gn.v20220512 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gr1_r1i1p1f1_GFDL-ESM4__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Emon.cSoil.gr1.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.fx.areacella.gr1.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.fx.sftlf.gr1.v20190726 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gr1_r1i1p1f1_KIOST-ESM__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.Emon.cSoil.gr1.v20210601 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gr_r1i1p101f1_GISS-E3-G__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.Emon.cSoil.gr.v20230320 - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.fx.areacella.gr.v20230320 - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.fx.sftlf.gr.v20230320 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gr_r1i1p1f1_EC-Earth3-CC__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Emon.cSoil.gr.v20210113 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.fx.areacella.gr.v20210616 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg-LR__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.Emon.cSoil.gr.v20201116 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.fx.areacella.gr.v20200217 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.fx.sftlf.gr.v20200217 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r11i1p1f1.fx.areacella.gr.v20230203 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.Emon.cSoil.gr.v20211207 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.fx.sftlf.gr.v20211207 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gr_r1i1p1f1_IPSL-CM5A2-INCA__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.Emon.cSoil.gr.v20200729 - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.fx.areacella.gr.v20200729 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gr_r1i1p1f1_IPSL-CM6A-LR-INCA__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.Emon.cSoil.gr.v20210216 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gr_r1i1p1f1_IPSL-CM6A-LR__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.Emon.cSoil.gr.v20180803 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.fx.areacella.gr.v20180803 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.fx.sftlf.gr.v20180803 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip6_historical_gr_r1i1p1f2_CNRM-ESM2-1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.fx.areacella.gr.v20250328 - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Emon.cSoil.gr.v20181206 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_ACCESS-ESM1-5_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191115 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191115 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20191115 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_AWI-ESM-1-1-LR_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200212 - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200909 - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20200212 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_BCC-CSM2-MR_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_BCC-ESM1_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.BCC.BCC-ESM1.1pctCO2.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190613 - CMIP7.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_CESM2-FV2_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_CESM2-WACCM-FV2_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_CESM2-WACCM_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20190227 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_CESM2_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20190308 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_CMCC-CM2-SR5_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20200616 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_CMCC-ESM2_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20210114 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_CanESM5-1_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_CanESM5_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_GISS-E2-1-G-CC_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20190815 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_GISS-E2-1-G_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20181015 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_GISS-E2-1-H_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20190403 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_MIROC-ES2L_r1i1p1f2__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.areacella.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.sftlf.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20200124 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_MPI-ESM-1-2-HAM_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_MPI-ESM1-2-LR_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_NorCPM1_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200724 - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200724 - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20200724 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_NorESM2-LM_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190815 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_NorESM2-MM_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_SAM0-UNICON_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190323 - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190323 - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20190323 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_TaiESM1_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20200624 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_UKESM1-0-LL_r1i1p1f2__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gn_UKESM1-1-LL_r1i1p1f2__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.glb.mon.cSoil.tavg-u-hxy-lnd.gn.v20220512 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gr1_GFDL-ESM4_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr1.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gr1.v20190726 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gr1_KIOST-ESM_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gr1.v20210601 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gr_CNRM-ESM2-1_r1i1p1f2__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20250328 - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.mon.cSoil.tavg-u-hxy-lnd.gr.v20181206 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gr_EC-Earth3-CC_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20210616 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gr.v20210113 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gr_EC-Earth3-Veg-LR_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200217 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20200217 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gr.v20201116 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gr_EC-Earth3-Veg_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r11i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20230203 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20211207 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gr.v20211207 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gr_GISS-E3-G_r1i1p101f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.fx.areacella.ti-u-hxy-u.gr.v20230320 - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20230320 - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.mon.cSoil.tavg-u-hxy-lnd.gr.v20230320 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gr_IPSL-CM5A2-INCA_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200729 - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gr.v20200729 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gr_IPSL-CM6A-LR-INCA_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gr.v20210216 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 cmip7_historical_gr_IPSL-CM6A-LR_r1i1p1f1__obs4mips_gn_HWSD-2-0_cSoil_v20250903: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20180803 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20180803 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.mon.cSoil.tavg-u-hxy-lnd.gr.v20180803 obs4mips: - - obs4MIPs.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 + - obs4REF.obs4REF.IIASA-FAO.HWSD-2-0.fx.cSoil.0.5x0.5degree.gn.v20250903 diff --git a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_gpp_wecann_.yml b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_gpp_wecann_.yml index c16ae15d6..6cfd4e834 100644 --- a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_gpp_wecann_.yml +++ b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_gpp_wecann_.yml @@ -6,7 +6,7 @@ cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.sftlf.gn.v20191115 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_AWI-ESM-1-1-LR__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.Amon.pr.gn.v20200212 @@ -15,14 +15,14 @@ cmip6_historical_gn_r1i1p1f1_AWI-ESM-1-1-LR__obs4mips_gn_WECANN-1-0_gpp_v2025090 - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.fx.areacella.gn.v20200212 - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.fx.sftlf.gn.v20200909 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_BCC-CSM2-MR__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.Amon.pr.gn.v20181126 - CMIP6.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.Amon.tas.gn.v20181126 - CMIP6.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.Lmon.gpp.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_BCC-ESM1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.BCC.BCC-ESM1.1pctCO2.r1i1p1f1.fx.areacella.gn.v20190613 @@ -30,14 +30,14 @@ cmip6_historical_gn_r1i1p1f1_BCC-ESM1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.Amon.tas.gn.v20181214 - CMIP6.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.Lmon.gpp.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CAS-ESM2-0__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Amon.pr.gn.v20201227 - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Amon.tas.gn.v20201227 - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Lmon.gpp.gn.v20201224 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CESM2-FV2__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Amon.pr.gn.v20191120 @@ -46,7 +46,7 @@ cmip6_historical_gn_r1i1p1f1_CESM2-FV2__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.areacella.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.sftlf.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Amon.pr.gn.v20190415 @@ -55,7 +55,7 @@ cmip6_historical_gn_r1i1p1f1_CESM2-WACCM__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.areacella.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.sftlf.gn.v20190227 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CESM2__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.pr.gn.v20190401 @@ -64,7 +64,7 @@ cmip6_historical_gn_r1i1p1f1_CESM2__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.sftlf.gn.v20190308 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-SR5__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.Amon.pr.gn.v20200616 @@ -73,7 +73,7 @@ cmip6_historical_gn_r1i1p1f1_CMCC-CM2-SR5__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.areacella.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.sftlf.gn.v20200616 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CMCC-ESM2__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.Amon.pr.gn.v20210114 @@ -82,7 +82,7 @@ cmip6_historical_gn_r1i1p1f1_CMCC-ESM2__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.areacella.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.sftlf.gn.v20210114 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CanESM5-1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Amon.pr.gn.v20190429 @@ -91,7 +91,7 @@ cmip6_historical_gn_r1i1p1f1_CanESM5-1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CanESM5__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Amon.pr.gn.v20190429 @@ -100,42 +100,42 @@ cmip6_historical_gn_r1i1p1f1_CanESM5__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G-CC__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.Amon.pr.gn.v20190815 - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.Amon.tas.gn.v20190815 - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.Lmon.gpp.gn.v20190815 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.Amon.pr.gn.v20180827 - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.Amon.tas.gn.v20180827 - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.Lmon.gpp.gn.v20181015 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-H__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.Amon.pr.gn.v20190403 - CMIP6.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.Amon.tas.gn.v20190403 - CMIP6.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.Lmon.gpp.gn.v20190403 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-G__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.Amon.pr.gn.v20191120 - CMIP6.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.Amon.tas.gn.v20191120 - CMIP6.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.Lmon.gpp.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-H__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.Amon.pr.gn.v20191120 - CMIP6.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.Amon.tas.gn.v20191120 - CMIP6.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.Lmon.gpp.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_ICON-ESM-LR__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.Amon.pr.gn.v20210215 @@ -144,7 +144,7 @@ cmip6_historical_gn_r1i1p1f1_ICON-ESM-LR__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.fx.areacella.gn.v20220111 - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.fx.sftlf.gn.v20220111 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_MPI-ESM-1-2-HAM__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Amon.pr.gn.v20190627 @@ -153,7 +153,7 @@ cmip6_historical_gn_r1i1p1f1_MPI-ESM-1-2-HAM__obs4mips_gn_WECANN-1-0_gpp_v202509 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.fx.areacella.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.fx.sftlf.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-HR__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.Amon.pr.gn.v20190710 @@ -162,7 +162,7 @@ cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-HR__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.fx.areacella.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.fx.sftlf.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-LR__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Amon.pr.gn.v20190710 @@ -171,7 +171,7 @@ cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-LR__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.fx.areacella.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.fx.sftlf.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_NorCPM1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.Amon.pr.gn.v20200724 @@ -180,7 +180,7 @@ cmip6_historical_gn_r1i1p1f1_NorCPM1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.fx.areacella.gn.v20200724 - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.fx.sftlf.gn.v20200724 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_NorESM2-LM__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.Amon.pr.gn.v20190815 @@ -189,7 +189,7 @@ cmip6_historical_gn_r1i1p1f1_NorESM2-LM__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.fx.areacella.gn.v20190815 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_NorESM2-MM__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Amon.pr.gn.v20191108 @@ -198,7 +198,7 @@ cmip6_historical_gn_r1i1p1f1_NorESM2-MM__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.areacella.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_SAM0-UNICON__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.Amon.pr.gn.v20190323 @@ -207,7 +207,7 @@ cmip6_historical_gn_r1i1p1f1_SAM0-UNICON__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.fx.areacella.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.fx.sftlf.gn.v20190323 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_TaiESM1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.Amon.pr.gn.v20200623 @@ -216,7 +216,7 @@ cmip6_historical_gn_r1i1p1f1_TaiESM1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.fx.areacella.gn.v20200624 - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.fx.sftlf.gn.v20200624 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f2_MIROC-ES2L__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Amon.pr.gn.v20190823 @@ -225,21 +225,21 @@ cmip6_historical_gn_r1i1p1f2_MIROC-ES2L__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.fx.areacella.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.fx.sftlf.gn.v20190823 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f2_UKESM1-0-LL__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.Amon.pr.gn.v20190406 - CMIP6.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.Amon.tas.gn.v20190406 - CMIP6.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.Lmon.gpp.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p1f2_UKESM1-1-LL__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.Amon.pr.gn.v20220512 - CMIP6.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.Amon.tas.gn.v20220512 - CMIP6.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.Lmon.gpp.gn.v20220512 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gn_r1i1p2f1_CanESM5-CanOE__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Amon.pr.gn.v20190429 @@ -248,7 +248,7 @@ cmip6_historical_gn_r1i1p2f1_CanESM5-CanOE__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr1_r1i1p1f1_GFDL-ESM4__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Amon.pr.gr1.v20190726 @@ -257,7 +257,7 @@ cmip6_historical_gr1_r1i1p1f1_GFDL-ESM4__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.fx.areacella.gr1.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.fx.sftlf.gr1.v20190726 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr1_r1i1p1f1_INM-CM4-8__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.INM.INM-CM4-8.1pctCO2.r1i1p1f1.fx.areacella.gr1.v20190530 @@ -266,7 +266,7 @@ cmip6_historical_gr1_r1i1p1f1_INM-CM4-8__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.Lmon.gpp.gr1.v20190530 - CMIP6.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.fx.sftlf.gr1.v20190528 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr1_r1i1p1f1_INM-CM5-0__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.Amon.pr.gr1.v20190610 @@ -275,14 +275,14 @@ cmip6_historical_gr1_r1i1p1f1_INM-CM5-0__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.fx.areacella.gr1.v20190610 - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.fx.sftlf.gr1.v20190610 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr1_r1i1p1f1_KIOST-ESM__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.Amon.pr.gr1.v20210928 - CMIP6.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.Amon.tas.gr1.v20210601 - CMIP6.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.Lmon.gpp.gr1.v20210601 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr_r1i1p101f1_GISS-E3-G__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.Amon.pr.gr.v20230320 @@ -291,7 +291,7 @@ cmip6_historical_gr_r1i1p101f1_GISS-E3-G__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.fx.areacella.gr.v20230320 - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.fx.sftlf.gr.v20230320 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_E3SM-1-1-ECA__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.Amon.pr.gr.v20200623 @@ -300,7 +300,7 @@ cmip6_historical_gr_r1i1p1f1_E3SM-1-1-ECA__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.fx.areacella.gr.v20200116 - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.fx.sftlf.gr.v20201015 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_E3SM-1-1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.Amon.pr.gr.v20191211 @@ -309,7 +309,7 @@ cmip6_historical_gr_r1i1p1f1_E3SM-1-1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.fx.areacella.gr.v20191212 - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.fx.sftlf.gr.v20201015 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_EC-Earth3-CC__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Amon.pr.gr.v20210113 @@ -317,7 +317,7 @@ cmip6_historical_gr_r1i1p1f1_EC-Earth3-CC__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Lmon.gpp.gr.v20210113 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.fx.areacella.gr.v20210616 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg-LR__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.Amon.pr.gr.v20200217 @@ -326,7 +326,7 @@ cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg-LR__obs4mips_gn_WECANN-1-0_gpp_v20250 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.fx.areacella.gr.v20200217 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.fx.sftlf.gr.v20200217 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r11i1p1f1.fx.areacella.gr.v20230203 @@ -335,14 +335,14 @@ cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.Lmon.gpp.gr.v20211207 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.fx.sftlf.gr.v20211207 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_IPSL-CM6A-LR-INCA__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.Amon.pr.gr.v20210216 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.Amon.tas.gr.v20210216 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.Lmon.gpp.gr.v20210216 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_IPSL-CM6A-LR__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.Amon.pr.gr.v20180803 @@ -351,7 +351,7 @@ cmip6_historical_gr_r1i1p1f1_IPSL-CM6A-LR__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.fx.areacella.gr.v20180803 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.fx.sftlf.gr.v20180803 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr_r1i1p1f2_CNRM-CM6-1-HR__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.Amon.pr.gr.v20191021 @@ -360,7 +360,7 @@ cmip6_historical_gr_r1i1p1f2_CNRM-CM6-1-HR__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.fx.areacella.gr.v20191021 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.fx.sftlf.gr.v20191021 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr_r1i1p1f2_CNRM-CM6-1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.Amon.pr.gr.v20180917 @@ -369,7 +369,7 @@ cmip6_historical_gr_r1i1p1f2_CNRM-CM6-1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.fx.areacella.gr.v20180917 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.fx.sftlf.gr.v20180917 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip6_historical_gr_r1i1p1f2_CNRM-ESM2-1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.fx.areacella.gr.v20250328 @@ -377,7 +377,7 @@ cmip6_historical_gr_r1i1p1f2_CNRM-ESM2-1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Amon.tas.gr.v20181206 - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Lmon.gpp.gr.v20181206 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_ACCESS-ESM1-5_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191115 @@ -386,7 +386,7 @@ cmip7_historical_gn_ACCESS-ESM1-5_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20191115 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20191115 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_AWI-ESM-1-1-LR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200212 @@ -395,14 +395,14 @@ cmip7_historical_gn_AWI-ESM-1-1-LR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v2025090 - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20200212 - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20200212 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_BCC-CSM2-MR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.glb.mon.gpp.tavg-u-hxy-lnd.gn.v20181114 - CMIP7.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20181126 - CMIP7.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20181126 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_BCC-ESM1_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.BCC.BCC-ESM1.1pctCO2.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190613 @@ -410,14 +410,14 @@ cmip7_historical_gn_BCC-ESM1_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20181214 - CMIP7.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20181214 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_CAS-ESM2-0_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.glb.mon.gpp.tavg-u-hxy-lnd.gn.v20201224 - CMIP7.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20201227 - CMIP7.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20201227 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_CESM2-FV2_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191120 @@ -426,7 +426,7 @@ cmip7_historical_gn_CESM2-FV2_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_CESM2-WACCM_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190227 @@ -435,7 +435,7 @@ cmip7_historical_gn_CESM2-WACCM_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190415 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190227 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_CESM2_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190308 @@ -444,7 +444,7 @@ cmip7_historical_gn_CESM2_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190401 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190308 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_CMCC-CM2-SR5_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200616 @@ -453,7 +453,7 @@ cmip7_historical_gn_CMCC-CM2-SR5_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20200616 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_CMCC-ESM2_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20210114 @@ -462,7 +462,7 @@ cmip7_historical_gn_CMCC-ESM2_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20210114 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_CanESM5-1_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 @@ -471,7 +471,7 @@ cmip7_historical_gn_CanESM5-1_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_CanESM5-CanOE_r1i1p2f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 @@ -480,7 +480,7 @@ cmip7_historical_gn_CanESM5-CanOE_r1i1p2f1__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_CanESM5_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 @@ -489,42 +489,42 @@ cmip7_historical_gn_CanESM5_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_GISS-E2-1-G-CC_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.glb.mon.gpp.tavg-u-hxy-lnd.gn.v20190815 - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190815 - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190815 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_GISS-E2-1-G_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.glb.mon.gpp.tavg-u-hxy-lnd.gn.v20181015 - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20180827 - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20180827 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_GISS-E2-1-H_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.glb.mon.gpp.tavg-u-hxy-lnd.gn.v20190403 - CMIP7.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190403 - CMIP7.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190403 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_GISS-E2-2-G_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.glb.mon.gpp.tavg-u-hxy-lnd.gn.v20191120 - CMIP7.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_GISS-E2-2-H_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.glb.mon.gpp.tavg-u-hxy-lnd.gn.v20191120 - CMIP7.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_ICON-ESM-LR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20220111 @@ -533,7 +533,7 @@ cmip7_historical_gn_ICON-ESM-LR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20210215 - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20210215 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_MIROC-ES2L_r1i1p1f2__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.areacella.ti-u-hxy-u.gn.v20190823 @@ -542,7 +542,7 @@ cmip7_historical_gn_MIROC-ES2L_r1i1p1f2__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.mon.pr.tavg-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190823 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_MPI-ESM-1-2-HAM_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190627 @@ -551,7 +551,7 @@ cmip7_historical_gn_MPI-ESM-1-2-HAM_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v202509 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_MPI-ESM1-2-HR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190710 @@ -560,7 +560,7 @@ cmip7_historical_gn_MPI-ESM1-2-HR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_MPI-ESM1-2-LR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190710 @@ -569,7 +569,7 @@ cmip7_historical_gn_MPI-ESM1-2-LR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_NorCPM1_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200724 @@ -578,7 +578,7 @@ cmip7_historical_gn_NorCPM1_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20200724 - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20200724 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_NorESM2-LM_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190815 @@ -587,7 +587,7 @@ cmip7_historical_gn_NorESM2-LM_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190815 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190815 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_NorESM2-MM_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191108 @@ -596,7 +596,7 @@ cmip7_historical_gn_NorESM2-MM_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_SAM0-UNICON_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190323 @@ -605,7 +605,7 @@ cmip7_historical_gn_SAM0-UNICON_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20190323 - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190323 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_TaiESM1_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200624 @@ -614,21 +614,21 @@ cmip7_historical_gn_TaiESM1_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gn.v20200623 - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gn.v20200623 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_UKESM1-0-LL_r1i1p1f2__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.glb.mon.gpp.tavg-u-hxy-lnd.gn.v20190627 - CMIP7.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.glb.mon.pr.tavg-u-hxy-u.gn.v20190406 - CMIP7.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.glb.mon.tas.tavg-h2m-hxy-u.gn.v20190406 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gn_UKESM1-1-LL_r1i1p1f2__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.glb.mon.gpp.tavg-u-hxy-lnd.gn.v20220512 - CMIP7.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.glb.mon.pr.tavg-u-hxy-u.gn.v20220512 - CMIP7.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.glb.mon.tas.tavg-h2m-hxy-u.gn.v20220512 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr1_GFDL-ESM4_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20190726 @@ -637,7 +637,7 @@ cmip7_historical_gr1_GFDL-ESM4_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gr1.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gr1.v20190726 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr1_INM-CM4-8_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20190528 @@ -646,7 +646,7 @@ cmip7_historical_gr1_INM-CM4-8_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gr1.v20190530 - CMIP7.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gr1.v20190530 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr1_INM-CM5-0_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20190610 @@ -655,14 +655,14 @@ cmip7_historical_gr1_INM-CM5-0_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gr1.v20190610 - CMIP7.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gr1.v20190610 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr1_KIOST-ESM_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.glb.mon.gpp.tavg-u-hxy-lnd.gr1.v20210601 - CMIP7.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gr1.v20210928 - CMIP7.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gr1.v20210601 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr_CNRM-CM6-1-HR_r1i1p1f2__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20191021 @@ -671,7 +671,7 @@ cmip7_historical_gr_CNRM-CM6-1-HR_r1i1p1f2__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.mon.pr.tavg-u-hxy-u.gr.v20191021 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.mon.tas.tavg-h2m-hxy-u.gr.v20191021 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr_CNRM-CM6-1_r1i1p1f2__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20180917 @@ -680,7 +680,7 @@ cmip7_historical_gr_CNRM-CM6-1_r1i1p1f2__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.mon.pr.tavg-u-hxy-u.gr.v20180917 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.mon.tas.tavg-h2m-hxy-u.gr.v20180917 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr_CNRM-ESM2-1_r1i1p1f2__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20250328 @@ -688,7 +688,7 @@ cmip7_historical_gr_CNRM-ESM2-1_r1i1p1f2__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.mon.pr.tavg-u-hxy-u.gr.v20181206 - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.mon.tas.tavg-h2m-hxy-u.gr.v20181206 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr_E3SM-1-1-ECA_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200116 @@ -697,7 +697,7 @@ cmip7_historical_gr_E3SM-1-1-ECA_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gr.v20200623 - CMIP7.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gr.v20200623 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr_E3SM-1-1_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20191212 @@ -706,7 +706,7 @@ cmip7_historical_gr_E3SM-1-1_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gr.v20191211 - CMIP7.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gr.v20191211 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr_EC-Earth3-CC_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20210616 @@ -714,7 +714,7 @@ cmip7_historical_gr_EC-Earth3-CC_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gr.v20210113 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gr.v20210113 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr_EC-Earth3-Veg-LR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200217 @@ -723,7 +723,7 @@ cmip7_historical_gr_EC-Earth3-Veg-LR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gr.v20200217 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gr.v20200217 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr_EC-Earth3-Veg_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r11i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20230203 @@ -732,7 +732,7 @@ cmip7_historical_gr_EC-Earth3-Veg_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gr.v20211207 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gr.v20211207 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr_GISS-E3-G_r1i1p101f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.fx.areacella.ti-u-hxy-u.gr.v20230320 @@ -741,14 +741,14 @@ cmip7_historical_gr_GISS-E3-G_r1i1p101f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.mon.pr.tavg-u-hxy-u.gr.v20230320 - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.mon.tas.tavg-h2m-hxy-u.gr.v20230320 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr_IPSL-CM6A-LR-INCA_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.glb.mon.gpp.tavg-u-hxy-lnd.gr.v20210216 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gr.v20210216 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gr.v20210216 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 cmip7_historical_gr_IPSL-CM6A-LR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20180803 @@ -757,4 +757,4 @@ cmip7_historical_gr_IPSL-CM6A-LR_r1i1p1f1__obs4mips_gn_WECANN-1-0_gpp_v20250902: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.mon.pr.tavg-u-hxy-u.gr.v20180803 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.mon.tas.tavg-h2m-hxy-u.gr.v20180803 obs4mips: - - obs4MIPs.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 + - obs4REF.obs4REF.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250902 diff --git a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_mrro_lora_.yml b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_mrro_lora_.yml index 6c74ae480..b0d6fc837 100644 --- a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_mrro_lora_.yml +++ b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_mrro_lora_.yml @@ -4,837 +4,837 @@ cmip6_historical_gn_r1i1p1f1_ACCESS-CM2__obs4mips_gn_LORA-1-0_mrro_v20250902: - CMIP6.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.fx.areacella.gn.v20191108 - CMIP6.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Lmon.mrro.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.sftlf.gn.v20191115 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_AWI-ESM-1-1-LR__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.Lmon.mrro.gn.v20200212 - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.fx.areacella.gn.v20200212 - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.fx.sftlf.gn.v20200909 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_AWI-ESM-1-REcoM__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.AWI.AWI-ESM-1-REcoM.historical.r1i1p1f1.Lmon.mrro.gn.v20230314 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_BCC-CSM2-MR__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.Lmon.mrro.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_BCC-ESM1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.BCC.BCC-ESM1.1pctCO2.r1i1p1f1.fx.areacella.gn.v20190613 - CMIP6.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.Lmon.mrro.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CAS-ESM2-0__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Lmon.mrro.gn.v20201224 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CESM2-FV2__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Lmon.mrro.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.areacella.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.sftlf.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM-FV2__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Lmon.mrro.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.fx.areacella.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.fx.sftlf.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Lmon.mrro.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.areacella.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.sftlf.gn.v20190227 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CESM2__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Lmon.mrro.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.sftlf.gn.v20190308 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-HR4__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.Lmon.mrro.gn.v20200904 - CMIP6.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.fx.sftlf.gn.v20200904 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-SR5__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.Lmon.mrro.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.areacella.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.sftlf.gn.v20200616 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CMCC-ESM2__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.Lmon.mrro.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.areacella.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.sftlf.gn.v20210114 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CanESM5-1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Lmon.mrro.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_CanESM5__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Lmon.mrro.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_FGOALS-f3-L__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CAS.FGOALS-f3-L.historical.r1i1p1f1.Lmon.mrro.gn.v20190821 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_FGOALS-g3__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.Lmon.mrro.gn.v20190820 - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.fx.areacella.gn.v20210615 - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.fx.sftlf.gn.v20200305 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_FIO-ESM-2-0__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.FIO-QLNM.FIO-ESM-2-0.historical.r1i1p1f1.Lmon.mrro.gn.v20191127 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G-CC__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.Lmon.mrro.gn.v20190815 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.Lmon.mrro.gn.v20181015 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-H__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.Lmon.mrro.gn.v20190403 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-G__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.Lmon.mrro.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-H__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.Lmon.mrro.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_ICON-ESM-LR__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.Lmon.mrro.gn.v20210215 - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.fx.areacella.gn.v20220111 - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.fx.sftlf.gn.v20220111 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_MCM-UA-1-0__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.Lmon.mrro.gn.v20190731 - CMIP6.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.fx.areacella.gn.v20190731 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_MIROC6__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.Lmon.mrro.gn.v20181212 - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.fx.areacella.gn.v20190311 - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.fx.sftlf.gn.v20190311 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_MPI-ESM-1-2-HAM__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Lmon.mrro.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.fx.areacella.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.fx.sftlf.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-HR__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.Lmon.mrro.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.fx.areacella.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.fx.sftlf.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-LR__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Lmon.mrro.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.fx.areacella.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.fx.sftlf.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_MRI-ESM2-0__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.Lmon.mrro.gn.v20190603 - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.fx.areacella.gn.v20190603 - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.fx.sftlf.gn.v20190603 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_NorESM2-LM__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.Lmon.mrro.gn.v20190917 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.fx.areacella.gn.v20190815 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_NorESM2-MM__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Lmon.mrro.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.areacella.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_SAM0-UNICON__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.Lmon.mrro.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.fx.areacella.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.fx.sftlf.gn.v20190323 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f1_TaiESM1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.Lmon.mrro.gn.v20200624 - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.fx.areacella.gn.v20200624 - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.fx.sftlf.gn.v20200624 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f2_MIROC-ES2L__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Lmon.mrro.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.fx.areacella.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.fx.sftlf.gn.v20190823 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f2_UKESM1-0-LL__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.Lmon.mrro.gn.v20190406 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f2_UKESM1-1-LL__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.Lmon.mrro.gn.v20220512 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f3_HadGEM3-GC31-LL__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.Lmon.mrro.gn.v20190624 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p1f3_HadGEM3-GC31-MM__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.MOHC.HadGEM3-GC31-MM.historical.r1i1p1f3.Lmon.mrro.gn.v20191207 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gn_r1i1p2f1_CanESM5-CanOE__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Lmon.mrro.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr1_r1i1p1f1_GFDL-CM4__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.Lmon.mrro.gr1.v20180701 - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.fx.areacella.gr1.v20180701 - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.fx.sftlf.gr1.v20180701 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr1_r1i1p1f1_GFDL-ESM4__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Lmon.mrro.gr1.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.fx.areacella.gr1.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.fx.sftlf.gr1.v20190726 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr1_r1i1p1f1_INM-CM4-8__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.INM.INM-CM4-8.1pctCO2.r1i1p1f1.fx.areacella.gr1.v20190530 - CMIP6.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.Lmon.mrro.gr1.v20190530 - CMIP6.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.fx.sftlf.gr1.v20190528 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr1_r1i1p1f1_INM-CM5-0__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.Lmon.mrro.gr1.v20190610 - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.fx.areacella.gr1.v20190610 - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.fx.sftlf.gr1.v20190610 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr1_r1i1p1f1_KIOST-ESM__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.Lmon.mrro.gr1.v20210601 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p101f1_GISS-E3-G__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.Lmon.mrro.gr.v20230320 - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.fx.areacella.gr.v20230320 - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.fx.sftlf.gr.v20230320 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_CIESM__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.THU.CIESM.historical.r1i1p1f1.Lmon.mrro.gr.v20200417 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_E3SM-1-0__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.Lmon.mrro.gr.v20190913 - CMIP6.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.fx.areacella.gr.v20190919 - CMIP6.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.fx.sftlf.gr.v20201015 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_E3SM-1-1-ECA__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.Lmon.mrro.gr.v20200128 - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.fx.areacella.gr.v20200116 - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.fx.sftlf.gr.v20201015 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_E3SM-1-1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.Lmon.mrro.gr.v20200324 - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.fx.areacella.gr.v20191212 - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.fx.sftlf.gr.v20201015 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_E3SM-2-0-NARRM__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.Lmon.mrro.gr.v20230430 - CMIP6.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.fx.areacella.gr.v20231010 - CMIP6.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.fx.sftlf.gr.v20231010 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_E3SM-2-0__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.Lmon.mrro.gr.v20221104 - CMIP6.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.fx.areacella.gr.v20220913 - CMIP6.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.fx.sftlf.gr.v20220912 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_E3SM-2-1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.Lmon.mrro.gr.v20240208 - CMIP6.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.fx.areacella.gr.v20240318 - CMIP6.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.fx.sftlf.gr.v20240318 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_EC-Earth3-AerChem__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.Lmon.mrro.gr.v20200624 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.fx.areacella.gr.v20200624 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.fx.sftlf.gr.v20200624 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_EC-Earth3-CC__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Lmon.mrro.gr.v20210113 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.fx.areacella.gr.v20210616 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg-LR__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.Lmon.mrro.gr.v20201116 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.fx.areacella.gr.v20200217 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.fx.sftlf.gr.v20200217 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r11i1p1f1.fx.areacella.gr.v20230203 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.Lmon.mrro.gr.v20211207 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.fx.sftlf.gr.v20211207 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_EC-Earth3__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.Lmon.mrro.gr.v20210324 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.fx.areacella.gr.v20210324 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.fx.sftlf.gr.v20200310 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_IPSL-CM5A2-INCA__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.Lmon.mrro.gr.v20200729 - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.fx.areacella.gr.v20200729 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_IPSL-CM6A-LR-INCA__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.Lmon.mrro.gr.v20210216 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_IPSL-CM6A-LR__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.Lmon.mrro.gr.v20180803 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.fx.areacella.gr.v20180803 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.fx.sftlf.gr.v20180803 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f1_KACE-1-0-G__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.NIMS-KMA.KACE-1-0-G.historical.r1i1p1f1.Lmon.mrro.gr.v20190911 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f2_CNRM-CM6-1-HR__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.Lmon.mrro.gr.v20191021 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.fx.areacella.gr.v20191021 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.fx.sftlf.gr.v20191021 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f2_CNRM-CM6-1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.Lmon.mrro.gr.v20180917 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.fx.areacella.gr.v20180917 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.fx.sftlf.gr.v20180917 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip6_historical_gr_r1i1p1f2_CNRM-ESM2-1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.fx.areacella.gr.v20250328 - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Lmon.mrro.gr.v20181206 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_ACCESS-CM2_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_ACCESS-ESM1-5_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191115 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191115 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20191115 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_AWI-ESM-1-1-LR_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200212 - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200909 - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20200212 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_AWI-ESM-1-REcoM_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.AWI.AWI-ESM-1-REcoM.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20230314 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_BCC-CSM2-MR_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_BCC-ESM1_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.BCC.BCC-ESM1.1pctCO2.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190613 - CMIP7.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_CAS-ESM2-0_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20201224 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_CESM2-FV2_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_CESM2-WACCM-FV2_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_CESM2-WACCM_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190227 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_CESM2_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190308 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_CMCC-CM2-HR4_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200904 - CMIP7.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20200904 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_CMCC-CM2-SR5_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20200616 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_CMCC-ESM2_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20210114 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_CanESM5-1_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_CanESM5-CanOE_r1i1p2f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_CanESM5_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_FGOALS-f3-L_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CAS.FGOALS-f3-L.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190821 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_FGOALS-g3_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20210615 - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200305 - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190820 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_FIO-ESM-2-0_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.FIO-QLNM.FIO-ESM-2-0.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20191127 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_GISS-E2-1-G-CC_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190815 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_GISS-E2-1-G_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20181015 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_GISS-E2-1-H_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190403 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_GISS-E2-2-G_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_GISS-E2-2-H_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_HadGEM3-GC31-LL_r1i1p1f3__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190624 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_HadGEM3-GC31-MM_r1i1p1f3__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.MOHC.HadGEM3-GC31-MM.historical.r1i1p1f3.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20191207 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_ICON-ESM-LR_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20220111 - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20220111 - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20210215 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_MCM-UA-1-0_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190731 - CMIP7.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190731 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_MIROC-ES2L_r1i1p1f2__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.areacella.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.sftlf.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190823 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_MIROC6_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190311 - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190311 - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20181212 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_MPI-ESM-1-2-HAM_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_MPI-ESM1-2-HR_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_MPI-ESM1-2-LR_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_MRI-ESM2-0_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190603 - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190603 - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190603 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_NorESM2-LM_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190815 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190917 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_NorESM2-MM_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_SAM0-UNICON_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190323 - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190323 - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190323 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_TaiESM1_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20200624 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_UKESM1-0-LL_r1i1p1f2__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20190406 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gn_UKESM1-1-LL_r1i1p1f2__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.glb.mon.mrro.tavg-u-hxy-lnd.gn.v20220512 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr1_GFDL-CM4_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20180701 - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr1.v20180701 - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr1.v20180701 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr1_GFDL-ESM4_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr1.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr1.v20190726 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr1_INM-CM4-8_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20190528 - CMIP7.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr1.v20190528 - CMIP7.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr1.v20190530 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr1_INM-CM5-0_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20190610 - CMIP7.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr1.v20190610 - CMIP7.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr1.v20190610 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr1_KIOST-ESM_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr1.v20210601 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_CIESM_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.THU.CIESM.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20200417 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_CNRM-CM6-1-HR_r1i1p1f2__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20191021 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.fx.sftlf.ti-u-hxy-u.gr.v20191021 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20191021 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_CNRM-CM6-1_r1i1p1f2__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20180917 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.fx.sftlf.ti-u-hxy-u.gr.v20180917 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20180917 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_CNRM-ESM2-1_r1i1p1f2__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20250328 - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20181206 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_E3SM-1-0_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20190919 - CMIP7.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20201015 - CMIP7.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20190913 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_E3SM-1-1-ECA_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200116 - CMIP7.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20201015 - CMIP7.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20200128 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_E3SM-1-1_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20191212 - CMIP7.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20201015 - CMIP7.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20200324 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_E3SM-2-0-NARRM_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20231010 - CMIP7.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20231010 - CMIP7.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20230430 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_E3SM-2-0_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20220913 - CMIP7.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20220912 - CMIP7.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20221104 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_E3SM-2-1_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20240318 - CMIP7.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20240318 - CMIP7.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20240208 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_EC-Earth3-AerChem_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200624 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20200624 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20200624 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_EC-Earth3-CC_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20210616 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20210113 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_EC-Earth3-Veg-LR_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200217 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20200217 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20201116 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_EC-Earth3-Veg_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r11i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20230203 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20211207 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20211207 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_EC-Earth3_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20210324 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20200310 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20210324 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_GISS-E3-G_r1i1p101f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.fx.areacella.ti-u-hxy-u.gr.v20230320 - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20230320 - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20230320 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_IPSL-CM5A2-INCA_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200729 - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20200729 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_IPSL-CM6A-LR-INCA_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20210216 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_IPSL-CM6A-LR_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20180803 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20180803 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20180803 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 cmip7_historical_gr_KACE-1-0-G_r1i1p1f1__obs4mips_gn_LORA-1-0_mrro_v20250902: cmip7: - CMIP7.CMIP.NIMS-KMA.KACE-1-0-G.historical.r1i1p1f1.glb.mon.mrro.tavg-u-hxy-lnd.gr.v20190911 obs4mips: - - obs4MIPs.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 + - obs4REF.obs4REF.ARCCSS.LORA-1-0.mon.mrro.50km.gn.v20250902 diff --git a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_nbp_hoffman_.yml b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_nbp_hoffman_.yml index f9c15aed7..3ae3d8c95 100644 --- a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_nbp_hoffman_.yml +++ b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_nbp_hoffman_.yml @@ -4,477 +4,477 @@ cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_gm_Hoffman-1-0_nbp_v2025111 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.sftlf.gn.v20191115 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_CESM2-FV2__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Lmon.nbp.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.areacella.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.sftlf.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM-FV2__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Lmon.nbp.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.fx.areacella.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.fx.sftlf.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Lmon.nbp.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.areacella.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.sftlf.gn.v20190227 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_CESM2__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Lmon.nbp.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.sftlf.gn.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-SR5__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.Lmon.nbp.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.areacella.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.sftlf.gn.v20200616 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_CMCC-ESM2__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.Lmon.nbp.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.areacella.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.sftlf.gn.v20210114 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_CanESM5-1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Lmon.nbp.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_CanESM5__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Lmon.nbp.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G-CC__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.Lmon.nbp.gn.v20190815 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.Lmon.nbp.gn.v20181015 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-H__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.Lmon.nbp.gn.v20190403 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-G__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.Lmon.nbp.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-H__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.Lmon.nbp.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_MPI-ESM-1-2-HAM__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Lmon.nbp.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.fx.areacella.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.fx.sftlf.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-LR__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Lmon.nbp.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.fx.areacella.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.fx.sftlf.gn.v20190710 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_NorCPM1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.Lmon.nbp.gn.v20200724 - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.fx.areacella.gn.v20200724 - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.fx.sftlf.gn.v20200724 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_NorESM2-LM__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.Lmon.nbp.gn.v20190917 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.fx.areacella.gn.v20190815 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_NorESM2-MM__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Lmon.nbp.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.areacella.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_SAM0-UNICON__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.Lmon.nbp.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.fx.areacella.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.fx.sftlf.gn.v20190323 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f1_TaiESM1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.Lmon.nbp.gn.v20200624 - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.fx.areacella.gn.v20200624 - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.fx.sftlf.gn.v20200624 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f2_MIROC-ES2L__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Lmon.nbp.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.fx.areacella.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.fx.sftlf.gn.v20190823 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f2_UKESM1-0-LL__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.Lmon.nbp.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p1f2_UKESM1-1-LL__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.Lmon.nbp.gn.v20220512 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gn_r1i1p2f1_CanESM5-CanOE__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Lmon.nbp.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr1_r1i1p1f1_GFDL-ESM4__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Lmon.nbp.gr1.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.fx.areacella.gr1.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.fx.sftlf.gr1.v20190726 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr1_r1i1p1f1_INM-CM4-8__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.INM.INM-CM4-8.1pctCO2.r1i1p1f1.fx.areacella.gr1.v20190530 - CMIP6.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.Lmon.nbp.gr1.v20190530 - CMIP6.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.fx.sftlf.gr1.v20190528 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr1_r1i1p1f1_INM-CM5-0__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.Lmon.nbp.gr1.v20190610 - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.fx.areacella.gr1.v20190610 - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.fx.sftlf.gr1.v20190610 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr_r1i1p1f1_E3SM-1-1-ECA__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.Lmon.nbp.gr.v20220127 - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.fx.areacella.gr.v20200116 - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.fx.sftlf.gr.v20201015 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr_r1i1p1f1_E3SM-1-1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.Lmon.nbp.gr.v20220127 - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.fx.areacella.gr.v20191212 - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.fx.sftlf.gr.v20201015 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr_r1i1p1f1_EC-Earth3-CC__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Lmon.nbp.gr.v20210113 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.fx.areacella.gr.v20210616 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg-LR__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.Lmon.nbp.gr.v20201116 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.fx.areacella.gr.v20200217 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.fx.sftlf.gr.v20200217 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r11i1p1f1.fx.areacella.gr.v20230203 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.Lmon.nbp.gr.v20211207 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.fx.sftlf.gr.v20211207 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr_r1i1p1f1_IPSL-CM5A2-INCA__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.Lmon.nbp.gr.v20200729 - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.fx.areacella.gr.v20200729 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr_r1i1p1f1_IPSL-CM6A-LR-INCA__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.Lmon.nbp.gr.v20210216 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr_r1i1p1f1_IPSL-CM6A-LR__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.Lmon.nbp.gr.v20180803 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.fx.areacella.gr.v20180803 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.fx.sftlf.gr.v20180803 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip6_historical_gr_r1i1p1f2_CNRM-ESM2-1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.fx.areacella.gr.v20250328 - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Lmon.nbp.gr.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_ACCESS-ESM1-5_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191115 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191115 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20191115 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_CESM2-FV2_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_CESM2-WACCM-FV2_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_CESM2-WACCM_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190227 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_CESM2_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_CMCC-CM2-SR5_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20200616 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_CMCC-ESM2_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20210114 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_CanESM5-1_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_CanESM5-CanOE_r1i1p2f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_CanESM5_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_GISS-E2-1-G-CC_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190815 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_GISS-E2-1-G_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20181015 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_GISS-E2-1-H_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190403 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_GISS-E2-2-G_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_GISS-E2-2-H_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_MIROC-ES2L_r1i1p1f2__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.areacella.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.sftlf.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190823 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_MPI-ESM-1-2-HAM_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_MPI-ESM1-2-LR_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190710 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_NorCPM1_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200724 - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200724 - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20200724 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_NorESM2-LM_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190815 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190917 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_NorESM2-MM_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_SAM0-UNICON_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190323 - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190323 - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190323 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_TaiESM1_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20200624 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_UKESM1-0-LL_r1i1p1f2__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gn_UKESM1-1-LL_r1i1p1f2__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.glb.mon.nbp.tavg-u-hxy-lnd.gn.v20220512 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr1_GFDL-ESM4_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr1.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gr1.v20190726 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr1_INM-CM4-8_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20190528 - CMIP7.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr1.v20190528 - CMIP7.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gr1.v20190530 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr1_INM-CM5-0_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20190610 - CMIP7.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr1.v20190610 - CMIP7.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gr1.v20190610 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr_CNRM-ESM2-1_r1i1p1f2__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20250328 - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.mon.nbp.tavg-u-hxy-lnd.gr.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr_E3SM-1-1-ECA_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200116 - CMIP7.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20201015 - CMIP7.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gr.v20220127 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr_E3SM-1-1_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20191212 - CMIP7.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20201015 - CMIP7.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gr.v20220127 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr_EC-Earth3-CC_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20210616 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gr.v20210113 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr_EC-Earth3-Veg-LR_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200217 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20200217 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gr.v20201116 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr_EC-Earth3-Veg_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r11i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20230203 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20211207 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gr.v20211207 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr_IPSL-CM5A2-INCA_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200729 - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gr.v20200729 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr_IPSL-CM6A-LR-INCA_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gr.v20210216 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 cmip7_historical_gr_IPSL-CM6A-LR_r1i1p1f1__obs4mips_gm_Hoffman-1-0_nbp_v20251117: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20180803 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20180803 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.mon.nbp.tavg-u-hxy-lnd.gr.v20180803 obs4mips: - - obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 + - obs4REF.obs4REF.UCI-ORNL.Hoffman-1-0.yr.nbp.site.gm.v20251117 diff --git a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_snc_esacci_.yml b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_snc_esacci_.yml index 087f78aa5..5ca6e23ad 100644 --- a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_snc_esacci_.yml +++ b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_snc_esacci_.yml @@ -4,659 +4,659 @@ cmip6_historical_gn_r1i1p1f1_AWI-ESM-1-1-LR__obs4mips_gn_CCI-CryoClim-FSC-1_snc_ - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.fx.areacella.gn.v20200212 - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.fx.sftlf.gn.v20200909 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_BCC-CSM2-MR__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.LImon.snc.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_BCC-ESM1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.BCC.BCC-ESM1.1pctCO2.r1i1p1f1.fx.areacella.gn.v20190613 - CMIP6.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.LImon.snc.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_CAS-ESM2-0__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.LImon.snc.gn.v20201224 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_CESM2-FV2__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.LImon.snc.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.areacella.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.fx.sftlf.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM-FV2__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.LImon.snc.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.fx.areacella.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.fx.sftlf.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.LImon.snc.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.areacella.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.fx.sftlf.gn.v20190227 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_CESM2__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.LImon.snc.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.sftlf.gn.v20190308 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-HR4__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.LImon.snc.gn.v20200904 - CMIP6.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.fx.sftlf.gn.v20200904 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-SR5__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.LImon.snc.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.areacella.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.fx.sftlf.gn.v20200616 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_CMCC-ESM2__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.LImon.snc.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.areacella.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.fx.sftlf.gn.v20210114 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_CanESM5-1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.LImon.snc.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_CanESM5__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.LImon.snc.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_FGOALS-f3-L__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CAS.FGOALS-f3-L.historical.r1i1p1f1.LImon.snc.gn.v20190821 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_FGOALS-g3__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.LImon.snc.gn.v20190820 - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.fx.areacella.gn.v20210615 - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.fx.sftlf.gn.v20200305 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G-CC__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.LImon.snc.gn.v20190815 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.LImon.snc.gn.v20181015 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-H__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.LImon.snc.gn.v20190403 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-G__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.LImon.snc.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-H__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.LImon.snc.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_ICON-ESM-LR__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.LImon.snc.gn.v20210215 - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.fx.areacella.gn.v20220111 - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.fx.sftlf.gn.v20220111 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_MIROC6__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.LImon.snc.gn.v20181212 - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.fx.areacella.gn.v20190311 - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.fx.sftlf.gn.v20190311 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_MPI-ESM-1-2-HAM__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.LImon.snc.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.fx.areacella.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.fx.sftlf.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-HR__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.LImon.snc.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.fx.areacella.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.fx.sftlf.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-LR__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.LImon.snc.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.fx.areacella.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.fx.sftlf.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_MRI-ESM2-0__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.LImon.snc.gn.v20190603 - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.fx.areacella.gn.v20190603 - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.fx.sftlf.gn.v20190603 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_NorCPM1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.LImon.snc.gn.v20200724 - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.fx.areacella.gn.v20200724 - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.fx.sftlf.gn.v20200724 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_NorESM2-LM__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.LImon.snc.gn.v20190917 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.fx.areacella.gn.v20190815 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_NorESM2-MM__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.LImon.snc.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.areacella.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.fx.sftlf.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_SAM0-UNICON__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.LImon.snc.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.fx.areacella.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.fx.sftlf.gn.v20190323 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f1_TaiESM1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.LImon.snc.gn.v20200626 - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.fx.areacella.gn.v20200624 - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.fx.sftlf.gn.v20200624 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f2_MIROC-ES2L__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.LImon.snc.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.fx.areacella.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.fx.sftlf.gn.v20190823 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f2_UKESM1-0-LL__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.LImon.snc.gn.v20190406 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f2_UKESM1-1-LL__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.LImon.snc.gn.v20220512 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f3_HadGEM3-GC31-LL__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.LImon.snc.gn.v20190624 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p1f3_HadGEM3-GC31-MM__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.MOHC.HadGEM3-GC31-MM.historical.r1i1p1f3.LImon.snc.gn.v20191207 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gn_r1i1p2f1_CanESM5-CanOE__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.LImon.snc.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.fx.areacella.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.fx.sftlf.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr1_r1i1p1f1_GFDL-CM4__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.LImon.snc.gr1.v20180701 - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.fx.areacella.gr1.v20180701 - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.fx.sftlf.gr1.v20180701 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr1_r1i1p1f1_GFDL-ESM4__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.LImon.snc.gr1.v20180701 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.fx.areacella.gr1.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.fx.sftlf.gr1.v20190726 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p101f1_GISS-E3-G__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.LImon.snc.gr.v20230320 - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.fx.areacella.gr.v20230320 - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.fx.sftlf.gr.v20230320 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f1_CIESM__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.THU.CIESM.historical.r1i1p1f1.LImon.snc.gr.v20200417 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f1_EC-Earth3-AerChem__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.LImon.snc.gr.v20200624 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.fx.areacella.gr.v20200624 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.fx.sftlf.gr.v20200624 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f1_EC-Earth3-CC__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.LImon.snc.gr.v20210113 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.fx.areacella.gr.v20210616 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg-LR__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.LImon.snc.gr.v20200217 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.fx.areacella.gr.v20200217 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.fx.sftlf.gr.v20200217 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f1_EC-Earth3-Veg__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r11i1p1f1.fx.areacella.gr.v20230203 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.LImon.snc.gr.v20211207 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.fx.sftlf.gr.v20211207 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f1_EC-Earth3__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.LImon.snc.gr.v20200310 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.fx.areacella.gr.v20210324 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.fx.sftlf.gr.v20200310 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f1_IPSL-CM5A2-INCA__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.LImon.snc.gr.v20200729 - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.fx.areacella.gr.v20200729 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f1_IPSL-CM6A-LR-INCA__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.LImon.snc.gr.v20210216 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f1_IPSL-CM6A-LR__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.LImon.snc.gr.v20180803 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.fx.areacella.gr.v20180803 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.fx.sftlf.gr.v20180803 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f2_CNRM-CM6-1-HR__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.LImon.snc.gr.v20191021 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.fx.areacella.gr.v20191021 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.fx.sftlf.gr.v20191021 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f2_CNRM-CM6-1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.LImon.snc.gr.v20180917 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.fx.areacella.gr.v20180917 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.fx.sftlf.gr.v20180917 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip6_historical_gr_r1i1p1f2_CNRM-ESM2-1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.fx.areacella.gr.v20250328 - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.LImon.snc.gr.v20181206 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_AWI-ESM-1-1-LR_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200212 - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200909 - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20200212 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_BCC-CSM2-MR_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_BCC-ESM1_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.BCC.BCC-ESM1.1pctCO2.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190613 - CMIP7.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20181114 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_CAS-ESM2-0_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20201224 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_CESM2-FV2_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_CESM2-WACCM-FV2_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_CESM2-WACCM_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190227 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_CESM2_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190308 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_CMCC-CM2-HR4_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200904 - CMIP7.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20200904 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_CMCC-CM2-SR5_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20200616 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_CMCC-ESM2_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20210114 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_CanESM5-1_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_CanESM5-CanOE_r1i1p2f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_CanESM5_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190429 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_FGOALS-f3-L_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CAS.FGOALS-f3-L.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190821 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_FGOALS-g3_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20210615 - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200305 - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190820 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_GISS-E2-1-G-CC_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190815 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_GISS-E2-1-G_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20181015 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_GISS-E2-1-H_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190403 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_GISS-E2-2-G_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_GISS-E2-2-H_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20191120 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_HadGEM3-GC31-LL_r1i1p1f3__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190624 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_HadGEM3-GC31-MM_r1i1p1f3__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.MOHC.HadGEM3-GC31-MM.historical.r1i1p1f3.glb.mon.snc.tavg-u-hxy-lnd.gn.v20191207 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_ICON-ESM-LR_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20220111 - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20220111 - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20210215 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_MIROC-ES2L_r1i1p1f2__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.areacella.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.sftlf.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190823 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_MIROC6_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190311 - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190311 - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20181212 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_MPI-ESM-1-2-HAM_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190627 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_MPI-ESM1-2-HR_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_MPI-ESM1-2-LR_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190710 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_MRI-ESM2-0_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190603 - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190603 - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190603 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_NorCPM1_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200724 - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200724 - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20200724 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_NorESM2-LM_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190815 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190917 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_NorESM2-MM_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20191108 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_SAM0-UNICON_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20190323 - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20190323 - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190323 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_TaiESM1_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gn.v20200626 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_UKESM1-0-LL_r1i1p1f2__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.glb.mon.snc.tavg-u-hxy-lnd.gn.v20190406 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gn_UKESM1-1-LL_r1i1p1f2__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.glb.mon.snc.tavg-u-hxy-lnd.gn.v20220512 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr1_GFDL-CM4_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20180701 - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr1.v20180701 - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gr1.v20180701 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr1_GFDL-ESM4_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr1.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr1.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gr1.v20180701 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_CIESM_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.THU.CIESM.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gr.v20200417 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_CNRM-CM6-1-HR_r1i1p1f2__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20191021 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.fx.sftlf.ti-u-hxy-u.gr.v20191021 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.mon.snc.tavg-u-hxy-lnd.gr.v20191021 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_CNRM-CM6-1_r1i1p1f2__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20180917 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.fx.sftlf.ti-u-hxy-u.gr.v20180917 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.mon.snc.tavg-u-hxy-lnd.gr.v20180917 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_CNRM-ESM2-1_r1i1p1f2__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r13i1p1f2.glb.fx.areacella.ti-u-hxy-u.gr.v20250328 - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.mon.snc.tavg-u-hxy-lnd.gr.v20181206 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_EC-Earth3-AerChem_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200624 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20200624 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gr.v20200624 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_EC-Earth3-CC_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20210616 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gr.v20210113 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_EC-Earth3-Veg-LR_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200217 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20200217 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gr.v20200217 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_EC-Earth3-Veg_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r11i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20230203 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20211207 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gr.v20211207 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_EC-Earth3_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20210324 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20200310 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gr.v20200310 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_GISS-E3-G_r1i1p101f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.fx.areacella.ti-u-hxy-u.gr.v20230320 - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20230320 - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.mon.snc.tavg-u-hxy-lnd.gr.v20230320 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_IPSL-CM5A2-INCA_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20200729 - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gr.v20200729 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_IPSL-CM6A-LR-INCA_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gr.v20210216 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 cmip7_historical_gr_IPSL-CM6A-LR_r1i1p1f1__obs4mips_gn_CCI-CryoClim-FSC-1_snc_v20250519: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.fx.areacella.ti-u-hxy-u.gr.v20180803 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.fx.sftlf.ti-u-hxy-u.gr.v20180803 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.mon.snc.tavg-u-hxy-lnd.gr.v20180803 obs4mips: - - obs4MIPs.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 + - obs4REF.obs4REF.ESACCI.CCI-CryoClim-FSC-1.mon.snc.0.5x0.5degree.gn.v20250519 diff --git a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_so_woa2023_surface_.yml b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_so_woa2023_surface_.yml index 726dc1489..bfe0b92e0 100644 --- a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_so_woa2023_surface_.yml +++ b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_so_woa2023_surface_.yml @@ -4,923 +4,923 @@ cmip6_historical_gn_r1i1p101f1_GISS-E3-G__obs4mips_gn_WOA-23_so_v20251024: - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.Ofx.sftof.gn.v20230320 - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.Omon.sos.gn.v20230320 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_ACCESS-CM2__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.Ofx.areacello.gn.v20191108 - CMIP6.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.Ofx.sftof.gn.v20191108 - CMIP6.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.Omon.sos.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Ofx.areacello.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Ofx.sftof.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Omon.sos.gn.v20191115 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_AWI-CM-1-1-MR__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.AWI.AWI-CM-1-1-MR.historical.r1i1p1f1.Ofx.areacello.gn.v20181218 - CMIP6.CMIP.AWI.AWI-CM-1-1-MR.historical.r1i1p1f1.Omon.sos.gn.v20181218 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_AWI-ESM-1-1-LR__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.Omon.sos.gn.v20200212 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_BCC-CSM2-MR__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.Omon.sos.gn.v20181126 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_BCC-ESM1__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.Omon.sos.gn.v20181129 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CAS-ESM2-0__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Ofx.areacello.gn.v20201228 - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Ofx.volcello.gn.v20201228 - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Omon.sos.gn.v20201228 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CESM2-FV2__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Ofx.areacello.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Ofx.sftof.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Omon.sos.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM-FV2__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Ofx.areacello.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Ofx.sftof.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Omon.sos.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Ofx.sftof.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Omon.sos.gn.v20190808 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CESM2__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Ofx.areacello.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Ofx.sftof.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Omon.sos.gn.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-HR4__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.Ofx.areacello.gn.v20200904 - CMIP6.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.Ofx.volcello.gn.v20200904 - CMIP6.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.Omon.sos.gn.v20200904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-SR5__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.Ofx.areacello.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.Ofx.sftof.gn.v20220720 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.Omon.sos.gn.v20200616 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CMCC-ESM2__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.Ofx.areacello.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.Ofx.sftof.gn.v20220720 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.Omon.sos.gn.v20210114 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CanESM5-1__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Ofx.areacello.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Ofx.volcello.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Omon.sos.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CanESM5__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Ofx.areacello.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Ofx.sftof.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Omon.sos.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_EC-Earth3-AerChem__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.Ofx.areacello.gn.v20200624 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.Ofx.sftof.gn.v20200624 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.Omon.sos.gn.v20200624 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_EC-Earth3-CC__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Ofx.areacello.gn.v20210113 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Ofx.sftof.gn.v20220407 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Omon.sos.gn.v20210113 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_EC-Earth3-Veg-LR__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.Ofx.areacello.gn.v20200919 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.Ofx.sftof.gn.v20200919 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.Omon.sos.gn.v20200919 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_EC-Earth3-Veg__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.Ofx.areacello.gn.v20211207 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.Ofx.sftof.gn.v20211207 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.Omon.sos.gn.v20211207 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_EC-Earth3__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.Ofx.areacello.gn.v20200918 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.Ofx.sftof.gn.v20200918 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.Omon.sos.gn.v20200918 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_FGOALS-g3__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.Ofx.areacello.gn.v20200917 - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.Ofx.volcello.gn.v20200917 - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.Omon.sos.gn.v20191107 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GFDL-CM4__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.Ofx.areacello.gn.v20180701 - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.Omon.sos.gn.v20180701 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GFDL-ESM4__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Ofx.areacello.gn.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Omon.sos.gn.v20190726 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G-CC__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.Omon.sos.gn.v20190815 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.Omon.sos.gn.v20180827 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-H__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.Omon.sos.gn.v20190403 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-G__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.Omon.sos.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-H__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.Omon.sos.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_ICON-ESM-LR__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.Ofx.areacello.gn.v20210215 - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.Omon.sos.gn.v20210215 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_IITM-ESM__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CCCR-IITM.IITM-ESM.historical.r1i1p1f1.Omon.sos.gn.v20200915 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_IPSL-CM5A2-INCA__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.Ofx.areacello.gn.v20200729 - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.Omon.sos.gn.v20200729 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_IPSL-CM6A-LR-INCA__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.Ofx.areacello.gn.v20210216 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.Omon.sos.gn.v20210216 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_IPSL-CM6A-LR__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.Ofx.areacello.gn.v20180803 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.Omon.sos.gn.v20180803 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MCM-UA-1-0__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.Ofx.areacello.gn.v20190731 - CMIP6.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.Omon.sos.gn.v20190731 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MIROC6__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.Ofx.areacello.gn.v20190311 - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.Ofx.sftof.gn.v20190311 - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.Omon.sos.gn.v20181212 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MPI-ESM-1-2-HAM__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Ofx.areacello.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Ofx.sftof.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Omon.sos.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-HR__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.Ofx.areacello.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.Ofx.sftof.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.Omon.sos.gn.v20190710 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-LR__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Ofx.areacello.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Ofx.sftof.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Omon.sos.gn.v20190710 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MRI-ESM2-0__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.Ofx.areacello.gn.v20191210 - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.Ofx.sftof.gn.v20191210 - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.Omon.sos.gn.v20210311 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_NESM3__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NUIST.NESM3.historical.r1i1p1f1.Omon.sos.gn.v20190703 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_NorCPM1__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.Ofx.areacello.gn.v20200724 - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.Ofx.sftof.gn.v20200724 - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.Omon.sos.gn.v20200724 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_NorESM2-MM__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Ofx.areacello.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Ofx.sftof.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Omon.sos.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_SAM0-UNICON__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.Ofx.areacello.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.Omon.sos.gn.v20190323 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_CNRM-CM6-1-HR__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.Ofx.areacello.gn.v20191021 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.Omon.sos.gn.v20191021 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_CNRM-CM6-1__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.Ofx.areacello.gn.v20180917 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.Omon.sos.gn.v20180917 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_CNRM-ESM2-1__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Ofx.areacello.gn.v20181206 - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Omon.sos.gn.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_MIROC-ES2L__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Ofx.areacello.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Ofx.sftof.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Omon.sos.gn.v20190823 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_UKESM1-0-LL__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.Omon.sos.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_UKESM1-1-LL__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.Omon.sos.gn.v20220512 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f3_HadGEM3-GC31-LL__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.Omon.sos.gn.v20190624 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f3_HadGEM3-GC31-MM__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MOHC.HadGEM3-GC31-MM.historical.r1i1p1f3.Omon.sos.gn.v20191207 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p2f1_CanESM5-CanOE__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Ofx.areacello.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Ofx.sftof.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Omon.sos.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p4f2_MIROC-ES2H__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.Ofx.areacello.gn.v20220322 - CMIP6.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.Ofx.sftof.gn.v20220322 - CMIP6.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.Omon.sos.gn.v20220322 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f1_INM-CM4-8__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.Omon.sos.gr1.v20190530 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f1_INM-CM5-0__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.Omon.sos.gr1.v20190610 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f1_KIOST-ESM__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.Ofx.areacello.gr1.v20210601 - CMIP6.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.Omon.sos.gr1.v20220204 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f2_CNRM-CM6-1__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.Omon.sos.gr1.v20180917 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f2_CNRM-ESM2-1__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Omon.sos.gr1.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f2_MIROC-ES2L__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Omon.sos.gr1.v20200731 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p4f2_MIROC-ES2H__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.Omon.sos.gr1.v20230904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r12i1p1f1_EC-Earth3-Veg__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r12i1p1f1.Omon.sos.gr.v20200925 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_CESM2-FV2__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Ofx.areacello.gr.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Omon.sos.gr.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_CESM2-WACCM-FV2__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Ofx.areacello.gr.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Omon.sos.gr.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_CESM2-WACCM__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Omon.sos.gr.v20190808 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_CESM2__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Ofx.areacello.gr.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Omon.sos.gr.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-1-0__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.Ofx.areacello.gr.v20210127 - CMIP6.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.Omon.sos.gr.v20190826 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-1-1-ECA__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.Omon.sos.gr.v20200127 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-1-1__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.Omon.sos.gr.v20191204 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-2-0-NARRM__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.Ofx.areacello.gr.v20231010 - CMIP6.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.Omon.sos.gr.v20230509 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-2-0__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.Ofx.areacello.gr.v20231010 - CMIP6.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.Omon.sos.gr.v20221112 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-2-1__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.Ofx.areacello.gr.v20240209 - CMIP6.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.Omon.sos.gr.v20240210 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_GFDL-CM4__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.Omon.sos.gr.v20180701 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_GFDL-ESM4__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Ofx.areacello.gr.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Omon.sos.gr.v20190726 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_KACE-1-0-G__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.NIMS-KMA.KACE-1-0-G.historical.r1i1p1f1.Omon.sos.gr.v20200130 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_MRI-ESM2-0__obs4mips_gn_WOA-23_so_v20251024: cmip6: - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.Omon.sos.gr.v20191205 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_ACCESS-CM2_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_ACCESS-ESM1-5_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191115 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191115 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20191115 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_AWI-CM-1-1-MR_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.AWI.AWI-CM-1-1-MR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20181218 - CMIP7.CMIP.AWI.AWI-CM-1-1-MR.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20181218 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_AWI-ESM-1-1-LR_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20200212 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_BCC-CSM2-MR_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20181126 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_BCC-ESM1_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20181129 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CAS-ESM2-0_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20201228 - CMIP7.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.glb.fx.volcello.ti-ol-hxy-sea.gn.v20201228 - CMIP7.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20201228 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CESM2-FV2_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CESM2-WACCM-FV2_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CESM2-WACCM_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190808 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CESM2_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CMCC-CM2-HR4_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200904 - CMIP7.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.glb.fx.volcello.ti-ol-hxy-sea.gn.v20200904 - CMIP7.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20200904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CMCC-CM2-SR5_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20220720 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20200616 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CMCC-ESM2_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20220720 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20210114 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CNRM-CM6-1-HR_r1i1p1f2__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.fx.areacello.ti-u-hxy-u.gn.v20191021 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.mon.sos.tavg-u-hxy-sea.gn.v20191021 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CNRM-CM6-1_r1i1p1f2__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.fx.areacello.ti-u-hxy-u.gn.v20180917 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.mon.sos.tavg-u-hxy-sea.gn.v20180917 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CNRM-ESM2-1_r1i1p1f2__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.fx.areacello.ti-u-hxy-u.gn.v20181206 - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.mon.sos.tavg-u-hxy-sea.gn.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CanESM5-1_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.volcello.ti-ol-hxy-sea.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CanESM5-CanOE_r1i1p2f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_CanESM5_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_EC-Earth3-AerChem_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20200624 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_EC-Earth3-CC_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20210113 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20220407 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20210113 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_EC-Earth3-Veg-LR_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200919 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20200919 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20200919 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_EC-Earth3-Veg_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20211207 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20211207 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20211207 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_EC-Earth3_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200918 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20200918 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20200918 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_FGOALS-g3_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200917 - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.fx.volcello.ti-ol-hxy-sea.gn.v20200917 - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20191107 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_GFDL-CM4_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20180701 - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20180701 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_GFDL-ESM4_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190726 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E2-1-G-CC_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190815 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E2-1-G_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20180827 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E2-1-H_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190403 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E2-2-G_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E2-2-H_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E3-G_r1i1p101f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.fx.areacello.ti-u-hxy-u.gn.v20230320 - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.fx.sftof.ti-u-hxy-u.gn.v20230320 - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20230320 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_HadGEM3-GC31-LL_r1i1p1f3__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.glb.mon.sos.tavg-u-hxy-sea.gn.v20190624 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_HadGEM3-GC31-MM_r1i1p1f3__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MOHC.HadGEM3-GC31-MM.historical.r1i1p1f3.glb.mon.sos.tavg-u-hxy-sea.gn.v20191207 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_ICON-ESM-LR_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20210215 - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20210215 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_IITM-ESM_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CCCR-IITM.IITM-ESM.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20200915 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_IPSL-CM5A2-INCA_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200729 - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20200729 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_IPSL-CM6A-LR-INCA_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20210216 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20210216 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_IPSL-CM6A-LR_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20180803 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20180803 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_MCM-UA-1-0_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190731 - CMIP7.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190731 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_MIROC-ES2H_r1i1p4f2__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.glb.fx.areacello.ti-u-hxy-u.gn.v20220322 - CMIP7.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.glb.fx.sftof.ti-u-hxy-u.gn.v20220322 - CMIP7.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.glb.mon.sos.tavg-u-hxy-sea.gn.v20220322 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_MIROC-ES2L_r1i1p1f2__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.areacello.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.sftof.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.mon.sos.tavg-u-hxy-sea.gn.v20190823 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_MIROC6_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190311 - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190311 - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20181212 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_MPI-ESM-1-2-HAM_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_MPI-ESM1-2-HR_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190710 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_MPI-ESM1-2-LR_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190710 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_MRI-ESM2-0_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191210 - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191210 - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20210311 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_NESM3_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NUIST.NESM3.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190703 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_NorCPM1_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200724 - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20200724 - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20200724 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_NorESM2-MM_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_SAM0-UNICON_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190323 - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gn.v20190323 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_UKESM1-0-LL_r1i1p1f2__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.glb.mon.sos.tavg-u-hxy-sea.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gn_UKESM1-1-LL_r1i1p1f2__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.glb.mon.sos.tavg-u-hxy-sea.gn.v20220512 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr1_CNRM-CM6-1_r1i1p1f2__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.mon.sos.tavg-u-hxy-sea.gr1.v20180917 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr1_CNRM-ESM2-1_r1i1p1f2__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.mon.sos.tavg-u-hxy-sea.gr1.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr1_INM-CM4-8_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr1.v20190530 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr1_INM-CM5-0_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr1.v20190610 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr1_KIOST-ESM_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr1.v20210601 - CMIP7.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr1.v20220204 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr1_MIROC-ES2H_r1i1p4f2__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.glb.mon.sos.tavg-u-hxy-sea.gr1.v20230904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr1_MIROC-ES2L_r1i1p1f2__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.mon.sos.tavg-u-hxy-sea.gr1.v20200731 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_CESM2-FV2_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_CESM2-WACCM-FV2_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_CESM2-WACCM_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20190808 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_CESM2_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-1-0_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20210127 - CMIP7.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20190826 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-1-1-ECA_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20200127 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-1-1_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20191204 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-2-0-NARRM_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20231010 - CMIP7.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20230509 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-2-0_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20231010 - CMIP7.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20221112 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-2-1_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20240209 - CMIP7.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20240210 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_EC-Earth3-Veg_r12i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r12i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20200925 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_GFDL-CM4_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20180701 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_GFDL-ESM4_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20190726 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_KACE-1-0-G_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.NIMS-KMA.KACE-1-0-G.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20200130 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 cmip7_historical_gr_MRI-ESM2-0_r1i1p1f1__obs4mips_gn_WOA-23_so_v20251024: cmip7: - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.mon.sos.tavg-u-hxy-sea.gr.v20191205 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.so.1x1degree.gn.v20251024 diff --git a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_thetao_woa2023_surface_.yml b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_thetao_woa2023_surface_.yml index c821d0a6a..1d68ad23b 100644 --- a/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_thetao_woa2023_surface_.yml +++ b/packages/climate-ref-ilamb/tests/unit/test_solve_regression/test_solve_regression_thetao_woa2023_surface_.yml @@ -4,52 +4,52 @@ cmip6_historical_gn_r1i1p101f1_GISS-E3-G__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.Ofx.sftof.gn.v20230320 - CMIP6.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.Omon.tos.gn.v20230320 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_ACCESS-CM2__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.Ofx.areacello.gn.v20191108 - CMIP6.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.Ofx.sftof.gn.v20191108 - CMIP6.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.Omon.tos.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Ofx.areacello.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Ofx.sftof.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Omon.tos.gn.v20191115 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_AWI-CM-1-1-MR__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.AWI.AWI-CM-1-1-MR.historical.r1i1p1f1.Ofx.areacello.gn.v20181218 - CMIP6.CMIP.AWI.AWI-CM-1-1-MR.historical.r1i1p1f1.Omon.thetao.gn.v20181218 - CMIP6.CMIP.AWI.AWI-CM-1-1-MR.historical.r1i1p1f1.Omon.tos.gn.v20181218 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_AWI-ESM-1-1-LR__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.Omon.thetao.gn.v20200212 - CMIP6.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.Omon.tos.gn.v20200212 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_BCC-CSM2-MR__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.Omon.thetao.gn.v20181126 - CMIP6.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.Omon.tos.gn.v20181126 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_BCC-ESM1__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.Omon.thetao.gn.v20181129 - CMIP6.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.Omon.tos.gn.v20181129 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CAMS-CSM1-0__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CAMS.CAMS-CSM1-0.historical.r1i1p1f1.Omon.thetao.gn.v20190708 - CMIP6.CMIP.CAMS.CAMS-CSM1-0.historical.r1i1p1f1.Omon.tos.gn.v20190708 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CAS-ESM2-0__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Ofx.areacello.gn.v20201228 @@ -57,62 +57,62 @@ cmip6_historical_gn_r1i1p1f1_CAS-ESM2-0__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Omon.thetao.gn.v20201228 - CMIP6.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.Omon.tos.gn.v20201228 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CESM2-FV2__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Ofx.areacello.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Ofx.sftof.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Omon.tos.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM-FV2__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Ofx.areacello.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Ofx.sftof.gn.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Omon.tos.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CESM2-WACCM__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Ofx.sftof.gn.v20190227 - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Omon.tos.gn.v20190808 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CESM2__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Ofx.areacello.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Ofx.sftof.gn.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Omon.tos.gn.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CIESM__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.THU.CIESM.historical.r1i1p1f1.Ofx.areacello.gn.v20200220 - CMIP6.CMIP.THU.CIESM.historical.r1i1p1f1.Ofx.sftof.gn.v20200220 - CMIP6.CMIP.THU.CIESM.historical.r1i1p1f1.Omon.tos.gn.v20200220 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-HR4__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.Ofx.areacello.gn.v20200904 - CMIP6.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.Ofx.volcello.gn.v20200904 - CMIP6.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.Omon.tos.gn.v20200904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CMCC-CM2-SR5__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.Ofx.areacello.gn.v20200616 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.Ofx.sftof.gn.v20220720 - CMIP6.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.Omon.tos.gn.v20200616 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CMCC-ESM2__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.Ofx.areacello.gn.v20210114 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.Ofx.sftof.gn.v20220720 - CMIP6.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.Omon.tos.gn.v20210114 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CanESM5-1__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Ofx.areacello.gn.v20190429 @@ -120,7 +120,7 @@ cmip6_historical_gn_r1i1p1f1_CanESM5-1__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Omon.thetao.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.Omon.tos.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_CanESM5__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Ofx.areacello.gn.v20190429 @@ -128,42 +128,42 @@ cmip6_historical_gn_r1i1p1f1_CanESM5__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Omon.thetao.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Omon.tos.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_EC-Earth3-AerChem__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.Ofx.areacello.gn.v20200624 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.Ofx.sftof.gn.v20200624 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.Omon.tos.gn.v20200624 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_EC-Earth3-CC__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Ofx.areacello.gn.v20210113 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Ofx.sftof.gn.v20220407 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Omon.tos.gn.v20210113 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_EC-Earth3-Veg-LR__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.Ofx.areacello.gn.v20200919 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.Ofx.sftof.gn.v20200919 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.Omon.tos.gn.v20200919 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_EC-Earth3-Veg__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.Ofx.areacello.gn.v20211207 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.Ofx.sftof.gn.v20211207 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.Omon.tos.gn.v20211207 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_EC-Earth3__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.Ofx.areacello.gn.v20200918 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.Ofx.sftof.gn.v20200918 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.Omon.tos.gn.v20200918 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_FGOALS-f3-L__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CAS.FGOALS-f3-L.historical.r1i1p1f1.Ofx.areacello.gn.v20190918 @@ -171,7 +171,7 @@ cmip6_historical_gn_r1i1p1f1_FGOALS-f3-L__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP6.CMIP.CAS.FGOALS-f3-L.historical.r1i1p1f1.Omon.thetao.gn.v20191007 - CMIP6.CMIP.CAS.FGOALS-f3-L.historical.r1i1p1f1.Omon.tos.gn.v20191007 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_FGOALS-g3__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.Ofx.areacello.gn.v20200917 @@ -179,152 +179,152 @@ cmip6_historical_gn_r1i1p1f1_FGOALS-g3__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.Omon.thetao.gn.v20191012 - CMIP6.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.Omon.tos.gn.v20191107 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_FIO-ESM-2-0__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.FIO-QLNM.FIO-ESM-2-0.historical.r1i1p1f1.Ofx.areacello.gn.v20200928 - CMIP6.CMIP.FIO-QLNM.FIO-ESM-2-0.historical.r1i1p1f1.Omon.tos.gn.v20191122 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GFDL-CM4__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.Ofx.areacello.gn.v20180701 - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.Omon.tos.gn.v20180701 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GFDL-ESM4__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Ofx.areacello.gn.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Omon.tos.gn.v20190726 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G-CC__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.Omon.tos.gn.v20190815 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-G__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.Omon.tos.gn.v20180827 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GISS-E2-1-H__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.Omon.tos.gn.v20190403 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-G__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.Omon.tos.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_GISS-E2-2-H__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.Omon.tos.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_ICON-ESM-LR__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.Ofx.areacello.gn.v20210215 - CMIP6.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.Omon.tos.gn.v20210215 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_IITM-ESM__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CCCR-IITM.IITM-ESM.historical.r1i1p1f1.Omon.tos.gn.v20200915 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_IPSL-CM5A2-INCA__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.Ofx.areacello.gn.v20200729 - CMIP6.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.Omon.tos.gn.v20200729 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_IPSL-CM6A-LR-INCA__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.Ofx.areacello.gn.v20210216 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.Omon.tos.gn.v20210216 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_IPSL-CM6A-LR__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.Ofx.areacello.gn.v20180803 - CMIP6.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.Omon.tos.gn.v20180803 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MCM-UA-1-0__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.Ofx.areacello.gn.v20190731 - CMIP6.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.Omon.tos.gn.v20190731 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MIROC6__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.Ofx.areacello.gn.v20190311 - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.Ofx.sftof.gn.v20190311 - CMIP6.CMIP.MIROC.MIROC6.historical.r1i1p1f1.Omon.tos.gn.v20181212 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MPI-ESM-1-2-HAM__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Ofx.areacello.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Ofx.sftof.gn.v20190627 - CMIP6.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.Omon.tos.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-HR__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.Ofx.areacello.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.Ofx.sftof.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.Omon.tos.gn.v20190710 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MPI-ESM1-2-LR__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Ofx.areacello.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Ofx.sftof.gn.v20190710 - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.Omon.tos.gn.v20190710 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_MRI-ESM2-0__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.Ofx.areacello.gn.v20191210 - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.Ofx.sftof.gn.v20191210 - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.Omon.tos.gn.v20190904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_NESM3__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NUIST.NESM3.historical.r1i1p1f1.Omon.tos.gn.v20190703 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_NorCPM1__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.Ofx.areacello.gn.v20200724 - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.Ofx.sftof.gn.v20200724 - CMIP6.CMIP.NCC.NorCPM1.historical.r1i1p1f1.Omon.tos.gn.v20200724 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_NorESM2-LM__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.Ofx.areacello.gn.v20190815 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.Ofx.sftof.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.Omon.tos.gn.v20190815 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_NorESM2-MM__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Ofx.areacello.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Ofx.sftof.gn.v20191108 - CMIP6.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.Omon.tos.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_SAM0-UNICON__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.Ofx.areacello.gn.v20190323 - CMIP6.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.Omon.tos.gn.v20190323 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f1_TaiESM1__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.Ofx.areacello.gn.v20210212 @@ -332,52 +332,52 @@ cmip6_historical_gn_r1i1p1f1_TaiESM1__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.Omon.thetao.gn.v20200630 - CMIP6.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.Omon.tos.gn.v20200630 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_CNRM-CM6-1-HR__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.Ofx.areacello.gn.v20191021 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.Omon.tos.gn.v20191021 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_CNRM-CM6-1__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.Ofx.areacello.gn.v20180917 - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.Omon.tos.gn.v20180917 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_CNRM-ESM2-1__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Ofx.areacello.gn.v20181206 - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Omon.tos.gn.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_MIROC-ES2L__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Ofx.areacello.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Ofx.sftof.gn.v20190823 - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Omon.tos.gn.v20190823 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_UKESM1-0-LL__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.Omon.tos.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f2_UKESM1-1-LL__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.Omon.tos.gn.v20220512 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f3_HadGEM3-GC31-LL__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.Omon.tos.gn.v20190624 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p1f3_HadGEM3-GC31-MM__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MOHC.HadGEM3-GC31-MM.historical.r1i1p1f3.Omon.tos.gn.v20191207 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p2f1_CanESM5-CanOE__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Ofx.areacello.gn.v20190429 @@ -385,189 +385,189 @@ cmip6_historical_gn_r1i1p2f1_CanESM5-CanOE__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Omon.thetao.gn.v20190429 - CMIP6.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.Omon.tos.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gn_r1i1p4f2_MIROC-ES2H__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.Ofx.areacello.gn.v20220322 - CMIP6.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.Ofx.sftof.gn.v20220322 - CMIP6.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.Omon.tos.gn.v20220322 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f1_INM-CM4-8__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.Omon.tos.gr1.v20190530 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f1_INM-CM5-0__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.Omon.tos.gr1.v20190610 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f1_KIOST-ESM__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.Ofx.areacello.gr1.v20210601 - CMIP6.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.Omon.tos.gr1.v20220204 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f2_CNRM-CM6-1__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.Omon.tos.gr1.v20180917 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f2_CNRM-ESM2-1__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Omon.tos.gr1.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p1f2_MIROC-ES2L__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.Omon.tos.gr1.v20200731 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr1_r1i1p4f2_MIROC-ES2H__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.Omon.tos.gr1.v20230904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r12i1p1f1_EC-Earth3-Veg__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r12i1p1f1.Omon.tos.gr.v20200925 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_CESM2-FV2__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Ofx.areacello.gr.v20191120 - CMIP6.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.Omon.tos.gr.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_CESM2-WACCM-FV2__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Ofx.areacello.gr.v20191120 - CMIP6.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.Omon.tos.gr.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_CESM2-WACCM__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.Omon.tos.gr.v20190808 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_CESM2__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Ofx.areacello.gr.v20190308 - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Omon.tos.gr.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-1-0__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.Ofx.areacello.gr.v20210127 - CMIP6.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.Omon.tos.gr.v20190826 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-1-1-ECA__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.Omon.tos.gr.v20200127 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-1-1__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.Omon.tos.gr.v20191204 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-2-0-NARRM__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.Ofx.areacello.gr.v20231010 - CMIP6.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.Omon.tos.gr.v20230510 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-2-0__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.Ofx.areacello.gr.v20231010 - CMIP6.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.Omon.tos.gr.v20221112 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_E3SM-2-1__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.Ofx.areacello.gr.v20240209 - CMIP6.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.Omon.tos.gr.v20240211 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_EC-Earth3-CC__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.Amon.tos.gr.v20210113 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_GFDL-CM4__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.Omon.tos.gr.v20180701 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_GFDL-ESM4__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Ofx.areacello.gr.v20190726 - CMIP6.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.Omon.tos.gr.v20190726 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_KACE-1-0-G__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.NIMS-KMA.KACE-1-0-G.historical.r1i1p1f1.Omon.tos.gr.v20200130 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r1i1p1f1_MRI-ESM2-0__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.Omon.tos.gr.v20190904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip6_historical_gr_r2i1p1f1_EC-Earth3__obs4mips_gn_WOA-23_thetao_v20251024: cmip6: - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r2i1p1f1.Amon.tos.gr.v20201215 - CMIP6.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r2i1p1f1.Omon.tos.gr.v20201215 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_ACCESS-CM2_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.CSIRO-ARCCSS.ACCESS-CM2.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_ACCESS-ESM1-5_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191115 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191115 - CMIP7.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20191115 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_AWI-CM-1-1-MR_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.AWI.AWI-CM-1-1-MR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20181218 - CMIP7.CMIP.AWI.AWI-CM-1-1-MR.historical.r1i1p1f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20181218 - CMIP7.CMIP.AWI.AWI-CM-1-1-MR.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20181218 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_AWI-ESM-1-1-LR_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20200212 - CMIP7.CMIP.AWI.AWI-ESM-1-1-LR.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20200212 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_BCC-CSM2-MR_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20181126 - CMIP7.CMIP.BCC.BCC-CSM2-MR.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20181126 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_BCC-ESM1_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20181129 - CMIP7.CMIP.BCC.BCC-ESM1.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20181129 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CAMS-CSM1-0_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CAMS.CAMS-CSM1-0.historical.r1i1p1f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20190708 - CMIP7.CMIP.CAMS.CAMS-CSM1-0.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190708 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CAS-ESM2-0_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20201228 @@ -575,80 +575,80 @@ cmip7_historical_gn_CAS-ESM2-0_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP7.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20201228 - CMIP7.CMIP.CAS.CAS-ESM2-0.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20201228 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CESM2-FV2_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CESM2-WACCM-FV2_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CESM2-WACCM_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190227 - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190808 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CESM2_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CIESM_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.THU.CIESM.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200220 - CMIP7.CMIP.THU.CIESM.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20200220 - CMIP7.CMIP.THU.CIESM.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20200220 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CMCC-CM2-HR4_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200904 - CMIP7.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.glb.fx.volcello.ti-ol-hxy-sea.gn.v20200904 - CMIP7.CMIP.CMCC.CMCC-CM2-HR4.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20200904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CMCC-CM2-SR5_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200616 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20220720 - CMIP7.CMIP.CMCC.CMCC-CM2-SR5.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20200616 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CMCC-ESM2_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20210114 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20220720 - CMIP7.CMIP.CMCC.CMCC-ESM2.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20210114 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CNRM-CM6-1-HR_r1i1p1f2__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.fx.areacello.ti-u-hxy-u.gn.v20191021 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1-HR.historical.r1i1p1f2.glb.mon.tos.tavg-u-hxy-sea.gn.v20191021 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CNRM-CM6-1_r1i1p1f2__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.fx.areacello.ti-u-hxy-u.gn.v20180917 - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.mon.tos.tavg-u-hxy-sea.gn.v20180917 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CNRM-ESM2-1_r1i1p1f2__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.fx.areacello.ti-u-hxy-u.gn.v20181206 - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.mon.tos.tavg-u-hxy-sea.gn.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CanESM5-1_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190429 @@ -656,7 +656,7 @@ cmip7_historical_gn_CanESM5-1_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-1.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CanESM5-CanOE_r1i1p2f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190429 @@ -664,7 +664,7 @@ cmip7_historical_gn_CanESM5-CanOE_r1i1p2f1__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5-CanOE.historical.r1i1p2f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_CanESM5_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190429 @@ -672,42 +672,42 @@ cmip7_historical_gn_CanESM5_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20190429 - CMIP7.CMIP.CCCma.CanESM5.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190429 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_EC-Earth3-AerChem_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20200624 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-AerChem.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20200624 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_EC-Earth3-CC_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20210113 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20220407 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-CC.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20210113 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_EC-Earth3-Veg-LR_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200919 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20200919 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg-LR.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20200919 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_EC-Earth3-Veg_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20211207 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20211207 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20211207 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_EC-Earth3_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200918 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20200918 - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20200918 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_FGOALS-f3-L_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CAS.FGOALS-f3-L.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190918 @@ -715,7 +715,7 @@ cmip7_historical_gn_FGOALS-f3-L_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP7.CMIP.CAS.FGOALS-f3-L.historical.r1i1p1f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20191007 - CMIP7.CMIP.CAS.FGOALS-f3-L.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20191007 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_FGOALS-g3_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200917 @@ -723,183 +723,183 @@ cmip7_historical_gn_FGOALS-g3_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20191012 - CMIP7.CMIP.CAS.FGOALS-g3.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20191107 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_FIO-ESM-2-0_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.FIO-QLNM.FIO-ESM-2-0.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200928 - CMIP7.CMIP.FIO-QLNM.FIO-ESM-2-0.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20191122 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_GFDL-CM4_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20180701 - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20180701 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_GFDL-ESM4_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190726 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E2-1-G-CC_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G-CC.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190815 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E2-1-G_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-G.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20180827 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E2-1-H_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-1-H.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190403 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E2-2-G_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-G.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E2-2-H_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E2-2-H.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_GISS-E3-G_r1i1p101f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.fx.areacello.ti-u-hxy-u.gn.v20230320 - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.fx.sftof.ti-u-hxy-u.gn.v20230320 - CMIP7.CMIP.NASA-GISS.GISS-E3-G.historical.r1i1p101f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20230320 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_HadGEM3-GC31-LL_r1i1p1f3__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.glb.mon.tos.tavg-u-hxy-sea.gn.v20190624 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_HadGEM3-GC31-MM_r1i1p1f3__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MOHC.HadGEM3-GC31-MM.historical.r1i1p1f3.glb.mon.tos.tavg-u-hxy-sea.gn.v20191207 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_ICON-ESM-LR_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20210215 - CMIP7.CMIP.MPI-M.ICON-ESM-LR.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20210215 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_IITM-ESM_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CCCR-IITM.IITM-ESM.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20200915 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_IPSL-CM5A2-INCA_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200729 - CMIP7.CMIP.IPSL.IPSL-CM5A2-INCA.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20200729 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_IPSL-CM6A-LR-INCA_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20210216 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR-INCA.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20210216 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_IPSL-CM6A-LR_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20180803 - CMIP7.CMIP.IPSL.IPSL-CM6A-LR.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20180803 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_MCM-UA-1-0_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190731 - CMIP7.CMIP.UA.MCM-UA-1-0.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190731 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_MIROC-ES2H_r1i1p4f2__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.glb.fx.areacello.ti-u-hxy-u.gn.v20220322 - CMIP7.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.glb.fx.sftof.ti-u-hxy-u.gn.v20220322 - CMIP7.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.glb.mon.tos.tavg-u-hxy-sea.gn.v20220322 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_MIROC-ES2L_r1i1p1f2__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.areacello.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.fx.sftof.ti-u-hxy-u.gn.v20190823 - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.mon.tos.tavg-u-hxy-sea.gn.v20190823 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_MIROC6_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190311 - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190311 - CMIP7.CMIP.MIROC.MIROC6.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20181212 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_MPI-ESM-1-2-HAM_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190627 - CMIP7.CMIP.HAMMOZ-Consortium.MPI-ESM-1-2-HAM.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_MPI-ESM1-2-HR_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-HR.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190710 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_MPI-ESM1-2-LR_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20190710 - CMIP7.CMIP.MPI-M.MPI-ESM1-2-LR.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190710 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_MRI-ESM2-0_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191210 - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191210 - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_NESM3_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NUIST.NESM3.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190703 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_NorCPM1_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20200724 - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20200724 - CMIP7.CMIP.NCC.NorCPM1.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20200724 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_NorESM2-LM_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190815 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-LM.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190815 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_NorESM2-MM_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.fx.sftof.ti-u-hxy-u.gn.v20191108 - CMIP7.CMIP.NCC.NorESM2-MM.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20191108 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_SAM0-UNICON_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20190323 - CMIP7.CMIP.SNU.SAM0-UNICON.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20190323 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_TaiESM1_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gn.v20210212 @@ -907,138 +907,138 @@ cmip7_historical_gn_TaiESM1_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.mon.thetao.tavg-ol-hxy-sea.gn.v20200630 - CMIP7.CMIP.AS-RCEC.TaiESM1.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gn.v20200630 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_UKESM1-0-LL_r1i1p1f2__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MOHC.UKESM1-0-LL.historical.r1i1p1f2.glb.mon.tos.tavg-u-hxy-sea.gn.v20190627 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gn_UKESM1-1-LL_r1i1p1f2__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MOHC.UKESM1-1-LL.historical.r1i1p1f2.glb.mon.tos.tavg-u-hxy-sea.gn.v20220512 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr1_CNRM-CM6-1_r1i1p1f2__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-CM6-1.historical.r1i1p1f2.glb.mon.tos.tavg-u-hxy-sea.gr1.v20180917 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr1_CNRM-ESM2-1_r1i1p1f2__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.glb.mon.tos.tavg-u-hxy-sea.gr1.v20181206 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr1_INM-CM4-8_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.INM.INM-CM4-8.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr1.v20190530 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr1_INM-CM5-0_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.INM.INM-CM5-0.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr1.v20190610 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr1_KIOST-ESM_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr1.v20210601 - CMIP7.CMIP.KIOST.KIOST-ESM.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr1.v20220204 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr1_MIROC-ES2H_r1i1p4f2__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2H.historical.r1i1p4f2.glb.mon.tos.tavg-u-hxy-sea.gr1.v20230904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr1_MIROC-ES2L_r1i1p1f2__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MIROC.MIROC-ES2L.historical.r1i1p1f2.glb.mon.tos.tavg-u-hxy-sea.gr1.v20200731 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_CESM2-FV2_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20191120 - CMIP7.CMIP.NCAR.CESM2-FV2.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_CESM2-WACCM-FV2_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20191120 - CMIP7.CMIP.NCAR.CESM2-WACCM-FV2.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20191120 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_CESM2-WACCM_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2-WACCM.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20190808 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_CESM2_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20190308 - CMIP7.CMIP.NCAR.CESM2.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20190308 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-1-0_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20210127 - CMIP7.CMIP.E3SM-Project.E3SM-1-0.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20190826 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-1-1-ECA_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-1-ECA.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20200127 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-1-1_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-1-1.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20191204 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-2-0-NARRM_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20231010 - CMIP7.CMIP.E3SM-Project.E3SM-2-0-NARRM.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20230510 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-2-0_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20231010 - CMIP7.CMIP.E3SM-Project.E3SM-2-0.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20221112 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_E3SM-2-1_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20240209 - CMIP7.CMIP.E3SM-Project.E3SM-2-1.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20240211 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_EC-Earth3-Veg_r12i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3-Veg.historical.r12i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20200925 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_EC-Earth3_r2i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.EC-Earth-Consortium.EC-Earth3.historical.r2i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20201215 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_GFDL-CM4_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-CM4.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20180701 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_GFDL-ESM4_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.fx.areacello.ti-u-hxy-u.gr.v20190726 - CMIP7.CMIP.NOAA-GFDL.GFDL-ESM4.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20190726 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_KACE-1-0-G_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.NIMS-KMA.KACE-1-0-G.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20200130 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 cmip7_historical_gr_MRI-ESM2-0_r1i1p1f1__obs4mips_gn_WOA-23_thetao_v20251024: cmip7: - CMIP7.CMIP.MRI.MRI-ESM2-0.historical.r1i1p1f1.glb.mon.tos.tavg-u-hxy-sea.gr.v20190904 obs4mips: - - obs4MIPs.obs4MIPs.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 + - obs4REF.obs4REF.NOAA-NCEI-OCL.WOA-23.monC.thetao.1x1degree.gn.v20251024 diff --git a/packages/climate-ref-pmp/conftest.py b/packages/climate-ref-pmp/conftest.py index 754bb030b..0bd5b8e09 100644 --- a/packages/climate-ref-pmp/conftest.py +++ b/packages/climate-ref-pmp/conftest.py @@ -18,9 +18,10 @@ def pmp_data_catalog(sample_data_dir) -> pd.DataFrame: @pytest.fixture(scope="session") -def data_catalog(cmip6_data_catalog, obs4mips_data_catalog, pmp_data_catalog): +def data_catalog(cmip6_data_catalog, obs4mips_data_catalog, obs4ref_data_catalog, pmp_data_catalog): return { SourceDatasetType.CMIP6: cmip6_data_catalog, SourceDatasetType.obs4MIPs: obs4mips_data_catalog, + SourceDatasetType.obs4REF: obs4ref_data_catalog, SourceDatasetType.PMPClimatology: pmp_data_catalog, } diff --git a/packages/climate-ref-pmp/src/climate_ref_pmp/diagnostics/enso.py b/packages/climate-ref-pmp/src/climate_ref_pmp/diagnostics/enso.py index 3c2a11ca7..9980e8b80 100644 --- a/packages/climate-ref-pmp/src/climate_ref_pmp/diagnostics/enso.py +++ b/packages/climate-ref-pmp/src/climate_ref_pmp/diagnostics/enso.py @@ -95,7 +95,7 @@ def __init__(self, metrics_collection: str, experiments: Collection[str] = ("his RegistryRequest( slug=f"enso-{self.slug}-obs", registry_name="obs4ref", - source_type="obs4MIPs", + source_type="obs4REF", facets={ "source_id": self.obs_sources, "variable_id": self.model_variables, @@ -141,7 +141,7 @@ def __init__(self, metrics_collection: str, experiments: Collection[str] = ("his RegistryRequest( slug=f"enso-{self.slug}-obs-cmip7", registry_name="obs4ref", - source_type="obs4MIPs", + source_type="obs4REF", facets={ "source_id": self.obs_sources, "variable_id": self.model_variables, diff --git a/packages/climate-ref-pmp/src/climate_ref_pmp/diagnostics/variability_modes.py b/packages/climate-ref-pmp/src/climate_ref_pmp/diagnostics/variability_modes.py index 2ffdc817c..37dbb6d66 100644 --- a/packages/climate-ref-pmp/src/climate_ref_pmp/diagnostics/variability_modes.py +++ b/packages/climate-ref-pmp/src/climate_ref_pmp/diagnostics/variability_modes.py @@ -121,7 +121,7 @@ def _get_data_requirements( RegistryRequest( slug=f"mov-{self.mode_id.lower()}-obs", registry_name="obs4ref", - source_type="obs4MIPs", + source_type="obs4REF", facets={"source_id": "HadISST-1-1", "variable_id": "ts"}, ), CMIP6Request( @@ -144,7 +144,7 @@ def _get_data_requirements( RegistryRequest( slug=f"mov-{self.mode_id.lower()}-obs-cmip7", registry_name="obs4ref", - source_type="obs4MIPs", + source_type="obs4REF", facets={"source_id": "HadISST-1-1", "variable_id": "ts"}, ), CMIP7Request( diff --git a/packages/climate-ref-pmp/tests/test-data/enso_proc/cmip6/catalog.yaml b/packages/climate-ref-pmp/tests/test-data/enso_proc/cmip6/catalog.yaml index 87cca19ce..0f05a929a 100644 --- a/packages/climate-ref-pmp/tests/test-data/enso_proc/cmip6/catalog.yaml +++ b/packages/climate-ref-pmp/tests/test-data/enso_proc/cmip6/catalog.yaml @@ -3,16 +3,16 @@ _metadata: obs4mips: slug_column: instance_id selector: - activity_id: obs4MIPs + activity_id: obs4REF datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2017-07-16 12:00:00' filename: hfls_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.hfls.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.hfls.250km.gn.v20250415 institution_id: ESSO long_name: Surface Upward Latent Heat Flux nominal_resolution: 250 km @@ -28,14 +28,14 @@ obs4mips: variant_label: PCMDI version: v20250415 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2017-07-16 12:00:00' filename: hfss_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.hfss.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.hfss.250km.gn.v20250415 institution_id: ESSO long_name: Surface Upward Sensible Heat Flux nominal_resolution: 250 km @@ -51,14 +51,14 @@ obs4mips: variant_label: PCMDI version: v20250415 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2017-07-16 12:00:00' filename: tauu_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.tauu.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.tauu.250km.gn.v20250415 institution_id: ESSO long_name: Surface Downward Eastward Wind Stress nominal_resolution: 250 km @@ -74,14 +74,14 @@ obs4mips: variant_label: PCMDI version: v20250415 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2017-07-15 12:00:00' filename: ts_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc finalised: true frequency: mon grid: 1.0x1.0 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727 + instance_id: obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727 institution_id: ESSO long_name: Surface Temperature nominal_resolution: 250 km @@ -97,14 +97,14 @@ obs4mips: variant_label: PCMDI version: v20210727 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2025-01-16 12:00:00' filename: ts_mon_HadISST-1-1_PCMDI_gn_187001-202501.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 institution_id: MOHC long_name: Surface Temperature nominal_resolution: 250 km @@ -120,14 +120,14 @@ obs4mips: variant_label: PCMDI version: v20250415 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2023-09-16 00:00:00' filename: rlds_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rlds.100km.gn.v20230209 + instance_id: obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rlds.100km.gn.v20230209 institution_id: NASA-LaRC long_name: Surface Downwelling Longwave Radiation nominal_resolution: 100 km @@ -143,14 +143,14 @@ obs4mips: variant_label: RSS version: v20230209 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2023-09-16 00:00:00' filename: rlus_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rlus.100km.gn.v20230209 + instance_id: obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rlus.100km.gn.v20230209 institution_id: NASA-LaRC long_name: Surface Upwelling Longwave Radiation nominal_resolution: 100 km @@ -166,14 +166,14 @@ obs4mips: variant_label: RSS version: v20230209 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2023-09-16 00:00:00' filename: rsds_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rsds.100km.gn.v20230209 + instance_id: obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rsds.100km.gn.v20230209 institution_id: NASA-LaRC long_name: Surface Downwelling Shortwave Radiation nominal_resolution: 100 km @@ -189,14 +189,14 @@ obs4mips: variant_label: RSS version: v20230209 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2023-09-16 00:00:00' filename: rsus_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rsus.100km.gn.v20230209 + instance_id: obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rsus.100km.gn.v20230209 institution_id: NASA-LaRC long_name: Surface Upwelling Shortwave Radiation nominal_resolution: 100 km diff --git a/packages/climate-ref-pmp/tests/test-data/enso_proc/cmip7/catalog.yaml b/packages/climate-ref-pmp/tests/test-data/enso_proc/cmip7/catalog.yaml index 01122a088..77a72d169 100644 --- a/packages/climate-ref-pmp/tests/test-data/enso_proc/cmip7/catalog.yaml +++ b/packages/climate-ref-pmp/tests/test-data/enso_proc/cmip7/catalog.yaml @@ -3,16 +3,16 @@ _metadata: obs4mips: slug_column: instance_id selector: - activity_id: obs4MIPs + activity_id: obs4REF datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2017-07-16 12:00:00' filename: hfls_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.hfls.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.hfls.250km.gn.v20250415 institution_id: ESSO long_name: Surface Upward Latent Heat Flux nominal_resolution: 250 km @@ -28,14 +28,14 @@ obs4mips: variant_label: PCMDI version: v20250415 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2017-07-16 12:00:00' filename: hfss_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.hfss.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.hfss.250km.gn.v20250415 institution_id: ESSO long_name: Surface Upward Sensible Heat Flux nominal_resolution: 250 km @@ -51,14 +51,14 @@ obs4mips: variant_label: PCMDI version: v20250415 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2017-07-16 12:00:00' filename: tauu_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.tauu.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.tauu.250km.gn.v20250415 institution_id: ESSO long_name: Surface Downward Eastward Wind Stress nominal_resolution: 250 km @@ -74,14 +74,14 @@ obs4mips: variant_label: PCMDI version: v20250415 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2017-07-15 12:00:00' filename: ts_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc finalised: true frequency: mon grid: 1.0x1.0 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727 + instance_id: obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727 institution_id: ESSO long_name: Surface Temperature nominal_resolution: 250 km @@ -97,14 +97,14 @@ obs4mips: variant_label: PCMDI version: v20210727 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2025-01-16 12:00:00' filename: ts_mon_HadISST-1-1_PCMDI_gn_187001-202501.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 institution_id: MOHC long_name: Surface Temperature nominal_resolution: 250 km @@ -120,14 +120,14 @@ obs4mips: variant_label: PCMDI version: v20250415 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2023-09-16 00:00:00' filename: rlds_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rlds.100km.gn.v20230209 + instance_id: obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rlds.100km.gn.v20230209 institution_id: NASA-LaRC long_name: Surface Downwelling Longwave Radiation nominal_resolution: 100 km @@ -143,14 +143,14 @@ obs4mips: variant_label: RSS version: v20230209 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2023-09-16 00:00:00' filename: rlus_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rlus.100km.gn.v20230209 + instance_id: obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rlus.100km.gn.v20230209 institution_id: NASA-LaRC long_name: Surface Upwelling Longwave Radiation nominal_resolution: 100 km @@ -166,14 +166,14 @@ obs4mips: variant_label: RSS version: v20230209 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2023-09-16 00:00:00' filename: rsds_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rsds.100km.gn.v20230209 + instance_id: obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rsds.100km.gn.v20230209 institution_id: NASA-LaRC long_name: Surface Downwelling Shortwave Radiation nominal_resolution: 100 km @@ -189,14 +189,14 @@ obs4mips: variant_label: RSS version: v20230209 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2023-09-16 00:00:00' filename: rsus_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rsus.100km.gn.v20230209 + instance_id: obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rsus.100km.gn.v20230209 institution_id: NASA-LaRC long_name: Surface Upwelling Shortwave Radiation nominal_resolution: 100 km diff --git a/packages/climate-ref-pmp/tests/test-data/enso_tel/cmip6/catalog.yaml b/packages/climate-ref-pmp/tests/test-data/enso_tel/cmip6/catalog.yaml index 612587abc..4f885a08a 100644 --- a/packages/climate-ref-pmp/tests/test-data/enso_tel/cmip6/catalog.yaml +++ b/packages/climate-ref-pmp/tests/test-data/enso_tel/cmip6/catalog.yaml @@ -3,16 +3,16 @@ _metadata: obs4mips: slug_column: instance_id selector: - activity_id: obs4MIPs + activity_id: obs4REF datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2017-07-15 12:00:00' filename: ts_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc finalised: true frequency: mon grid: 1.0x1.0 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727 + instance_id: obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727 institution_id: ESSO long_name: Surface Temperature nominal_resolution: 250 km @@ -28,14 +28,14 @@ obs4mips: variant_label: PCMDI version: v20210727 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2025-01-16 12:00:00' filename: ts_mon_HadISST-1-1_PCMDI_gn_187001-202501.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 institution_id: MOHC long_name: Surface Temperature nominal_resolution: 250 km @@ -51,14 +51,14 @@ obs4mips: variant_label: PCMDI version: v20250415 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2023-03-16 12:00:00' filename: pr_mon_GPCP-Monthly-3-2_RSS_gn_198301-202303.nc finalised: true frequency: mon grid: 0.5x0.5 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-GSFC.GPCP-Monthly-3-2.mon.pr.50km.gn.v20231205 + instance_id: obs4REF.obs4REF.NASA-GSFC.GPCP-Monthly-3-2.mon.pr.50km.gn.v20231205 institution_id: NASA-GSFC long_name: Precipitation nominal_resolution: 50 km diff --git a/packages/climate-ref-pmp/tests/test-data/enso_tel/cmip7/catalog.yaml b/packages/climate-ref-pmp/tests/test-data/enso_tel/cmip7/catalog.yaml index 9c7453999..8c66352c1 100644 --- a/packages/climate-ref-pmp/tests/test-data/enso_tel/cmip7/catalog.yaml +++ b/packages/climate-ref-pmp/tests/test-data/enso_tel/cmip7/catalog.yaml @@ -3,16 +3,16 @@ _metadata: obs4mips: slug_column: instance_id selector: - activity_id: obs4MIPs + activity_id: obs4REF datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2017-07-15 12:00:00' filename: ts_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc finalised: true frequency: mon grid: 1.0x1.0 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727 + instance_id: obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727 institution_id: ESSO long_name: Surface Temperature nominal_resolution: 250 km @@ -28,14 +28,14 @@ obs4mips: variant_label: PCMDI version: v20210727 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2025-01-16 12:00:00' filename: ts_mon_HadISST-1-1_PCMDI_gn_187001-202501.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 institution_id: MOHC long_name: Surface Temperature nominal_resolution: 250 km @@ -51,14 +51,14 @@ obs4mips: variant_label: PCMDI version: v20250415 vertical_levels: 1 - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2023-03-16 12:00:00' filename: pr_mon_GPCP-Monthly-3-2_RSS_gn_198301-202303.nc finalised: true frequency: mon grid: 0.5x0.5 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-GSFC.GPCP-Monthly-3-2.mon.pr.50km.gn.v20231205 + instance_id: obs4REF.obs4REF.NASA-GSFC.GPCP-Monthly-3-2.mon.pr.50km.gn.v20231205 institution_id: NASA-GSFC long_name: Precipitation nominal_resolution: 50 km diff --git a/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-npgo/cmip6/catalog.yaml b/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-npgo/cmip6/catalog.yaml index 59a287e01..0d2ef2e30 100644 --- a/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-npgo/cmip6/catalog.yaml +++ b/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-npgo/cmip6/catalog.yaml @@ -6,14 +6,14 @@ obs4mips: source_id: HadISST-1-1 variable_id: ts datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2025-01-16 12:00:00' filename: ts_mon_HadISST-1-1_PCMDI_gn_187001-202501.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 institution_id: MOHC long_name: Surface Temperature nominal_resolution: 250 km diff --git a/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-npgo/cmip7/catalog.yaml b/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-npgo/cmip7/catalog.yaml index e3daf6796..1f0d6831a 100644 --- a/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-npgo/cmip7/catalog.yaml +++ b/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-npgo/cmip7/catalog.yaml @@ -6,14 +6,14 @@ obs4mips: source_id: HadISST-1-1 variable_id: ts datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2025-01-16 12:00:00' filename: ts_mon_HadISST-1-1_PCMDI_gn_187001-202501.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 institution_id: MOHC long_name: Surface Temperature nominal_resolution: 250 km diff --git a/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-pdo/cmip6/catalog.yaml b/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-pdo/cmip6/catalog.yaml index 59a287e01..0d2ef2e30 100644 --- a/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-pdo/cmip6/catalog.yaml +++ b/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-pdo/cmip6/catalog.yaml @@ -6,14 +6,14 @@ obs4mips: source_id: HadISST-1-1 variable_id: ts datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2025-01-16 12:00:00' filename: ts_mon_HadISST-1-1_PCMDI_gn_187001-202501.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 institution_id: MOHC long_name: Surface Temperature nominal_resolution: 250 km diff --git a/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-pdo/cmip7/catalog.yaml b/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-pdo/cmip7/catalog.yaml index e3daf6796..1f0d6831a 100644 --- a/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-pdo/cmip7/catalog.yaml +++ b/packages/climate-ref-pmp/tests/test-data/extratropical-modes-of-variability-pdo/cmip7/catalog.yaml @@ -6,14 +6,14 @@ obs4mips: source_id: HadISST-1-1 variable_id: ts datasets: - - activity_id: obs4MIPs + - activity_id: obs4REF end_time: '2025-01-16 12:00:00' filename: ts_mon_HadISST-1-1_PCMDI_gn_187001-202501.nc finalised: true frequency: mon grid: 1x1 degree latitude x longitude grid_label: gn - instance_id: obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 + instance_id: obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 institution_id: MOHC long_name: Surface Temperature nominal_resolution: 250 km diff --git a/packages/climate-ref/conftest.py b/packages/climate-ref/conftest.py index 029add526..4031c04c1 100644 --- a/packages/climate-ref/conftest.py +++ b/packages/climate-ref/conftest.py @@ -18,7 +18,7 @@ from climate_ref.datasets.cmip6_parsers import parse_cmip6_complete, parse_cmip6_drs from climate_ref.datasets.cmip7 import CMIP7DatasetAdapter from climate_ref.datasets.cmip7_parsers import parse_cmip7_complete, parse_cmip7_drs -from climate_ref.datasets.obs4mips import Obs4MIPsDatasetAdapter +from climate_ref.datasets.obs4mips import Obs4MIPsDatasetAdapter, Obs4REFDatasetAdapter from climate_ref.models.metric_value import MetricValue from climate_ref.provider_registry import _register_provider from climate_ref.solve_helpers import solve_to_results @@ -120,11 +120,12 @@ def prepare_db(cmip7_aft_cv): @pytest.fixture(scope="session") -def db_seeded_template( +def db_seeded_template( # noqa: PLR0913 tmp_path_session: Path, migrated_db_template: Path, cmip6_data_catalog, obs4mips_data_catalog, + obs4ref_data_catalog, prepare_db, ) -> Path: template_db_path = tmp_path_session / "climate_ref_template_seeded.db" @@ -140,12 +141,15 @@ def db_seeded_template( for instance_id, data_catalog_dataset in cmip6_validated.groupby(adapter.slug_column): adapter.register_dataset(database, data_catalog_dataset) - # Seed the obs4MIPs sample datasets - adapter_obs = Obs4MIPsDatasetAdapter() - obs4mips_validated = adapter_obs.validate_data_catalog(obs4mips_data_catalog) - with database.session.begin(): - for instance_id, data_catalog_dataset in obs4mips_validated.groupby(adapter_obs.slug_column): - adapter_obs.register_dataset(database, data_catalog_dataset) + # Seed the obs4MIPs and obs4REF sample datasets + for adapter_obs, catalog in ( + (Obs4MIPsDatasetAdapter(), obs4mips_data_catalog), + (Obs4REFDatasetAdapter(), obs4ref_data_catalog), + ): + validated = adapter_obs.validate_data_catalog(catalog) + with database.session.begin(): + for instance_id, data_catalog_dataset in validated.groupby(adapter_obs.slug_column): + adapter_obs.register_dataset(database, data_catalog_dataset) with database.session.begin(): _register_provider(database, example_provider) diff --git a/packages/climate-ref/src/climate_ref/conftest_plugin.py b/packages/climate-ref/src/climate_ref/conftest_plugin.py index 186e95181..f9ea6cd6e 100644 --- a/packages/climate-ref/src/climate_ref/conftest_plugin.py +++ b/packages/climate-ref/src/climate_ref/conftest_plugin.py @@ -49,7 +49,7 @@ DiagnosticProviderConfig, ) from climate_ref.datasets.cmip6 import CMIP6DatasetAdapter -from climate_ref.datasets.obs4mips import Obs4MIPsDatasetAdapter +from climate_ref.datasets.obs4mips import Obs4MIPsDatasetAdapter, Obs4REFDatasetAdapter from climate_ref.models import Execution from climate_ref.solve_helpers import load_solve_catalog from climate_ref.solver import solve_executions @@ -220,10 +220,13 @@ def cmip6_data_catalog(sample_data: None, sample_data_dir: Path) -> pd.DataFrame @pytest.fixture(scope="session") def obs4mips_data_catalog(sample_data: None, sample_data_dir: Path) -> pd.DataFrame: """obs4MIPs sample data catalog.""" - adapter = Obs4MIPsDatasetAdapter() - obs4ref = adapter.find_local_datasets(sample_data_dir / "obs4REF") - obs4mips = adapter.find_local_datasets(sample_data_dir / "obs4MIPs") - return pd.concat([obs4ref, obs4mips], ignore_index=True) + return Obs4MIPsDatasetAdapter().find_local_datasets(sample_data_dir / "obs4MIPs") + + +@pytest.fixture(scope="session") +def obs4ref_data_catalog(sample_data: None, sample_data_dir: Path) -> pd.DataFrame: + """obs4REF sample data catalog.""" + return Obs4REFDatasetAdapter().find_local_datasets(sample_data_dir / "obs4REF") @pytest.fixture(scope="session") diff --git a/packages/climate-ref/src/climate_ref/datasets/obs4mips.py b/packages/climate-ref/src/climate_ref/datasets/obs4mips.py index dbc6cd6e3..2b7dbb0f5 100644 --- a/packages/climate-ref/src/climate_ref/datasets/obs4mips.py +++ b/packages/climate-ref/src/climate_ref/datasets/obs4mips.py @@ -1,6 +1,5 @@ from __future__ import annotations -import functools import traceback from pathlib import Path from typing import Any @@ -21,25 +20,17 @@ from climate_ref.models.dataset import Dataset, Obs4MIPsDataset, Obs4REFDataset -def parse_obs4mips( - file: str, accepted_activity_ids: tuple[str, ...] = ("obs4MIPs", "obs4REF"), **kwargs: Any -) -> dict[str, Any]: +def parse_obs4mips(file: str, **kwargs: Any) -> dict[str, Any]: """ - Parser for obs4mips + Parser for obs4MIPs and obs4REF files + + obs4REF files follow the obs4MIPs metadata conventions, so the same parser reads both. + The adapter that called the parser decides which collection the file belongs to. Parameters ---------- file File to parse - accepted_activity_ids - Activity ids that the calling adapter expects. - - Any file whose ``activity_id`` is outside this set but is still a - known obs4MIPs/obs4REF activity id is ingested anyway with a warning - (a REF-curated obs4REF file parsed by the obs4MIPs adapter, or vice versa) -- - the hard-reject cutover for this cross-contamination case is a tracked follow-up, - not this change. A file whose ``activity_id`` is neither ``obs4MIPs`` nor - ``obs4REF`` at all is never a valid asset for this parser and is always rejected. kwargs Additional keyword arguments (not used, but required for protocol compatibility) """ @@ -65,19 +56,11 @@ def parse_obs4mips( try: with netCDF4.Dataset(file, "r") as ds: - # obs4REF is the REF-specific observational product. - # it shares the obs4MIPs metadata conventions and is ingested through this adapter. activity_id = getattr(ds, "activity_id", "") if activity_id not in ("obs4MIPs", "obs4REF"): traceback_message = f"{file} is not an obs4MIPs or obs4REF dataset" raise TypeError(traceback_message) - if activity_id not in accepted_activity_ids: - logger.warning( - f"{file} has activity_id={activity_id!r}, which is outside the expected " - f"{accepted_activity_ids} for this adapter; ingesting anyway" - ) - global_attrs = read_global_attrs(ds, keys) missing_fields = [key for key in keys if global_attrs.get(key) is None] @@ -130,16 +113,12 @@ class Obs4MIPsDatasetAdapter(DatasetAdapter): dataset_cls: type[Dataset] = Obs4MIPsDataset slug_column = "instance_id" - instance_id_prefix = "obs4MIPs" - """Prefix used to build ``instance_id`` for datasets ingested through this adapter.""" - - accepted_activity_ids: tuple[str, ...] = ("obs4MIPs",) + activity_id = "obs4MIPs" """ - Activity ids this adapter expects to ingest. + The collection this adapter ingests into, whatever the file itself claims. - A file whose ``activity_id`` is outside this set (but is still a recognised - obs4MIPs/obs4REF activity id) is ingested anyway with a warning -- see - :func:`parse_obs4mips`. + The obs4REF collection republishes obs4MIPs files unchanged, + so the file attribute cannot tell the two collections apart. """ dataset_specific_metadata = ( @@ -199,7 +178,7 @@ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame: """ datasets = build_catalog( paths=[str(file_or_directory)], - parsing_func=functools.partial(parse_obs4mips, accepted_activity_ids=self.accepted_activity_ids), + parsing_func=parse_obs4mips, include_patterns=["*.nc"], n_jobs=self.n_jobs, ) @@ -207,6 +186,9 @@ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame: logger.error("No datasets found") raise ValueError("No obs4MIPs-compliant datasets found") + self._warn_if_misfiled(datasets) + datasets["activity_id"] = self.activity_id + # Convert the start_time and end_time columns to cftime objects datasets["start_time"] = parse_cftime_dates(datasets["start_time"]) datasets["end_time"] = parse_cftime_dates(datasets["end_time"]) @@ -219,22 +201,37 @@ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame: def _transform(item: str, value: Any) -> str: return str(value).replace(" ", "") if item == "nominal_resolution" else str(value) - datasets = build_instance_id( - datasets, drs_items, prefix=self.instance_id_prefix, transform=_transform - ) + datasets = build_instance_id(datasets, drs_items, prefix=self.activity_id, transform=_transform) datasets["finalised"] = True return datasets + def _warn_if_misfiled(self, datasets: pd.DataFrame) -> None: + """ + Warn when the files look like they belong to the other collection. + + The registry republishes obs4MIPs files unchanged, so an ``obs4REF`` directory + or activity id is only a hint. The files are ingested either way. + """ + other = "obs4REF" if self.activity_id == "obs4MIPs" else "obs4MIPs" + misfiled = datasets["path"].astype(str).str.contains(f"/{other}/", regex=False) + if other == "obs4REF": + misfiled |= datasets["activity_id"] == other + count = int(misfiled.sum()) + if count: + logger.warning( + f"{count} of {len(datasets)} files look like {other} data but are being ingested as " + f"{self.activity_id}. Use `--source-type {other.lower()}` if that is not intended." + ) + class Obs4REFDatasetAdapter(Obs4MIPsDatasetAdapter): """ Adapter for obs4REF datasets - obs4REF is REF-curated observational data that shares the obs4MIPs metadata - conventions and is parsed by the same :func:`parse_obs4mips` function, but is - ingested as a distinct dataset type so it is never mistaken for published obs4MIPs data. + obs4REF is the REF-curated collection of observational data. + It shares the obs4MIPs metadata conventions and parser, + but is ingested as its own dataset type so it is never mistaken for published obs4MIPs data. """ dataset_cls: type[Dataset] = Obs4REFDataset - instance_id_prefix = "obs4REF" - accepted_activity_ids: tuple[str, ...] = ("obs4REF",) + activity_id = "obs4REF" diff --git a/packages/climate-ref/src/climate_ref/doctor/checks/data.py b/packages/climate-ref/src/climate_ref/doctor/checks/data.py index 79e83db56..3fedf533d 100644 --- a/packages/climate-ref/src/climate_ref/doctor/checks/data.py +++ b/packages/climate-ref/src/climate_ref/doctor/checks/data.py @@ -4,15 +4,22 @@ These look for the conditions that make a solve quietly do the wrong thing rather than fail: reference data that no diagnostic can reach, reference data that is missing so its diagnostics never run, -and datasets whose files cover the same period twice. +obs4REF data ingested under the obs4MIPs source type, +obs4REF data that obs4MIPs has since published, +datasets whose files cover the same period twice, +and diagnostics the ingested data cannot solve at all. """ from collections import defaultdict +from collections.abc import Mapping +from climate_ref.data_catalog import DataCatalog from climate_ref.doctor.context import DoctorContext from climate_ref.doctor.findings import Finding, Severity from climate_ref.doctor.registry import check from climate_ref.text import pluralise +from climate_ref_core.diagnostics import Diagnostic +from climate_ref_core.exceptions import InvalidDiagnosticException from climate_ref_core.reference_data import ( ESGF_OBS4MIPS, ReferenceDataset, @@ -20,7 +27,7 @@ source_ids_by_registry, ) from climate_ref_core.source_types import SourceDatasetType -from climate_ref_core.summary import summarize_provider +from climate_ref_core.summary import _normalize_requirement_sets, summarize_provider @check( @@ -129,6 +136,9 @@ def check_missing_reference_data(context: DoctorContext) -> list[Finding]: if len(catalog) and "source_id" in catalog: ingested[source_type.value].update(catalog["source_id"].unique()) + # obs4REF fills in whatever obs4MIPs lacks, so either satisfies an obs4MIPs requirement. + ingested[SourceDatasetType.obs4MIPs.value] |= ingested[SourceDatasetType.obs4REF.value] + findings = [] for dataset in sorted(required, key=lambda d: (d.supplier, d.source_id)): if dataset.source_id in ingested[dataset.source_type]: @@ -179,11 +189,9 @@ def check_unreachable_source_types(context: DoctorContext) -> list[Finding]: """ Find data ingested under a source type that no enabled diagnostic asks for. - The clearest case is obs4REF. - The ``obs4ref`` source type exists and can be ingested, - but no diagnostic declares an obs4REF data requirement, - and the solver only matches a requirement against its own source type. - Data ingested that way is never selected. + The solver only matches a requirement against its own source type, + so data ingested under a type nothing asks for is never selected. + The one exception is obs4REF data, which fills in for obs4MIPs requirements. Parameters ---------- @@ -201,6 +209,8 @@ def check_unreachable_source_types(context: DoctorContext) -> list[Finding]: for requirement_set in diagnostic.requirement_sets: for requirement in requirement_set.requirements: requested.add(requirement.source_type) + if SourceDatasetType.obs4MIPs.value in requested: + requested.add(SourceDatasetType.obs4REF.value) findings = [] for source_type in SourceDatasetType: @@ -222,14 +232,198 @@ def check_unreachable_source_types(context: DoctorContext) -> list[Finding]: "so nothing will select these datasets." ), remedy=( - "If this is obs4REF data, re-ingest it with " - f"`--source-type {SourceDatasetType.obs4MIPs.value}`." + "Re-ingest the data under the source type the diagnostics ask for, " + "or enable a provider that uses it." ), ) ) return findings +@check( + "misfiled-obs4ref", + "obs4REF data ingested under the obs4MIPs source type", +) +def check_misfiled_obs4ref(context: DoctorContext) -> list[Finding]: + """ + Find obs4REF data that was ingested as obs4MIPs. + + Earlier releases ingested the obs4REF collection this way, and it still solves. + The cost is that the catalog no longer shows which datasets came from the registry + and which from the archive, and a later obs4MIPs publication cannot take over from it. + + A dataset counts as obs4REF when an obs4REF registry carries its ``source_id`` + or its files sit under an ``obs4REF`` directory. + + Parameters + ---------- + context + The deployment to check. + + Returns + ------- + : + One finding when any such data is present. + """ + catalog = context.catalog(SourceDatasetType.obs4MIPs) + if not len(catalog) or not {"instance_id", "source_id", "path"}.issubset(catalog.columns): + return [] + + registry_ids = { + source_id + for (source_type, source_id) in source_ids_by_registry() + if source_type == SourceDatasetType.obs4REF.value + } + misfiled = catalog["source_id"].isin(registry_ids) | catalog["path"].astype(str).str.contains( + "/obs4REF/", regex=False + ) + if not misfiled.any(): + return [] + + instance_ids = sorted(catalog.loc[misfiled, "instance_id"].unique()) + return [ + Finding( + severity=Severity.WARNING, + summary=f"{pluralise(len(instance_ids), 'obs4REF dataset')} ingested as obs4mips", + detail="Affected: " + ", ".join(instance_ids) + ".", + remedy=( + "Re-ingest the obs4REF collection under its own source type, " + "then retract each of the obs4mips rows above with `ref datasets retract `." + ), + command="ref datasets ingest --source-type obs4ref ", + ) + ] + + +@check( + "superseded-obs4ref", + "obs4REF datasets that the obs4MIPs archive has since published", +) +def check_superseded_obs4ref(context: DoctorContext) -> list[Finding]: + """ + Find obs4REF datasets for which an obs4MIPs copy is also ingested. + + The solver takes the obs4MIPs copy, so the obs4REF one is no longer used. + This is the signal that a dataset can be dropped from the obs4REF registry. + + Parameters + ---------- + context + The deployment to check. + + Returns + ------- + : + One finding per superseded obs4REF dataset. + """ + from climate_ref.solver import obs_dataset_key # noqa: PLC0415 + + obs4mips = context.catalog(SourceDatasetType.obs4MIPs) + obs4ref = context.catalog(SourceDatasetType.obs4REF) + if not len(obs4mips) or not len(obs4ref) or "instance_id" not in obs4mips or "instance_id" not in obs4ref: + return [] + + published = set(obs_dataset_key(obs4mips["instance_id"])) + superseded = obs4ref[obs_dataset_key(obs4ref["instance_id"]).isin(published)] + return [ + Finding( + severity=Severity.INFO, + summary=f"{instance_id} is superseded by the obs4MIPs copy", + remedy=( + "The obs4MIPs copy is used instead. " + "These can be retracted, and dropped from the obs4REF registry." + ), + ) + for instance_id in sorted(superseded["instance_id"].unique()) + ] + + +@check( + "unsolvable-diagnostics", + "Enabled diagnostics for which the ingested data produces no executions", +) +def check_unsolvable_diagnostics(context: DoctorContext) -> list[Finding]: + """ + Find enabled diagnostics that the ingested data cannot solve at all. + + This runs the solver against the ingested catalogs, diagnostic by diagnostic, + so it catches everything the narrower checks do not: + a filter no dataset matches, a constraint no group satisfies, a source type nothing was ingested under. + + Parameters + ---------- + context + The deployment to check. + + Returns + ------- + : + One finding per diagnostic with no executions. + """ + from climate_ref.solver import solve_executions # noqa: PLC0415 + + catalogs: dict[SourceDatasetType, DataCatalog] = { + source_type: DataCatalog.from_frame(context.catalog(source_type)) for source_type in SourceDatasetType + } + + findings = [] + for provider in context.providers: + for diagnostic in provider.diagnostics(): + try: + solvable = any(True for _ in solve_executions(catalogs, diagnostic, provider)) + except InvalidDiagnosticException: + solvable = False + if solvable: + continue + findings.append( + Finding( + severity=Severity.WARNING, + summary=f"{provider.slug}/{diagnostic.slug} has no executions", + detail=_why_unsolvable(diagnostic, catalogs), + remedy=( + "Ingest the data each diagnostic asks for. " + "The findings above list the missing reference data." + ), + ) + ) + return findings + + +def _why_unsolvable( + diagnostic: Diagnostic, + catalogs: Mapping[SourceDatasetType, DataCatalog], +) -> str: + """ + Explain which requirement the ingested data fails to meet. + + Each requirement is checked on its own, so the first one with no matching group names + the data to fetch. When every requirement matches something, the failure lies in how + they combine, which is reported as such. + """ + from climate_ref.solver import apply_obs4ref_fallback, extract_covered_datasets # noqa: PLC0415 + + available = apply_obs4ref_fallback(catalogs) + + reasons = [] + for requirements in _normalize_requirement_sets(diagnostic.data_requirements): + for requirement in requirements: + catalog = available[requirement.source_type] + frame = catalog.to_frame() if isinstance(catalog, DataCatalog) else catalog + if not len(frame): + reasons.append(f"nothing is ingested as {requirement.source_type.value}") + break + if not extract_covered_datasets(catalog, requirement): + facets = "; ".join( + ", ".join(f"{k}={'|'.join(v)}" for k, v in sorted(f.facets.items())) + for f in requirement.filters + ) + reasons.append(f"no {requirement.source_type.value} datasets match {facets}") + break + else: + reasons.append("every requirement matches data, but no complete group satisfies the constraints") + return "Unmet: " + ". ".join(dict.fromkeys(reasons)) + "." + + @check( "overlapping-registries", "Reference datasets that more than one registry carries", diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py index 68e5144da..aa32edf17 100644 --- a/packages/climate-ref/src/climate_ref/solver.py +++ b/packages/climate-ref/src/climate_ref/solver.py @@ -45,6 +45,8 @@ from climate_ref_core.exceptions import InvalidDiagnosticException from climate_ref_core.providers import DiagnosticProvider +_EMPTY_CATALOG = pd.DataFrame() + @frozen class DiagnosticExecution: @@ -248,6 +250,92 @@ def _process_group_constraints( return group +def with_obs4ref_fallback( + obs4mips: pd.DataFrame | DataCatalog, + obs4ref: pd.DataFrame | DataCatalog, +) -> pd.DataFrame | DataCatalog: + """ + Fill an obs4MIPs catalog with the obs4REF datasets it lacks. + + The obs4MIPs archive on ESGF is the official home of the reference data, + and the obs4REF registry carries the datasets that are not published there yet. + A dataset present in both is taken from obs4MIPs, whichever version each holds, + so publishing a dataset takes over from the registry copy without any re-ingest. + + Parameters + ---------- + obs4mips + The obs4MIPs catalog. + obs4ref + The obs4REF catalog. + + Returns + ------- + : + The obs4MIPs catalog, extended with the obs4REF datasets it does not hold. + The original catalog is returned untouched when there is nothing to add. + """ + obs4ref_df = obs4ref.to_frame() if isinstance(obs4ref, DataCatalog) else obs4ref + if obs4ref_df.empty or "instance_id" not in obs4ref_df.columns: + return obs4mips + obs4mips_df = obs4mips.to_frame() if isinstance(obs4mips, DataCatalog) else obs4mips + + held = set(obs_dataset_key(obs4mips_df["instance_id"])) if len(obs4mips_df) else set() + extra = obs4ref_df[~obs_dataset_key(obs4ref_df["instance_id"]).isin(held)] + if extra.empty: + return obs4mips + if obs4mips_df.empty: + return obs4ref + return DataCatalog.from_frame(pd.concat([obs4mips_df, extra])) + + +def obs_dataset_key(instance_id: pd.Series) -> pd.Series: + """ + Reduce an obs4MIPs or obs4REF ``instance_id`` to what identifies the dataset across the two. + + The two collections build the same id apart from the leading collection components + and the trailing version. + + Parameters + ---------- + instance_id + Instance ids from either collection. + + Returns + ------- + : + The id with the collection prefix and version removed. + """ + return instance_id.astype(str).str.split(".", n=2).str[2].str.rsplit(".", n=1).str[0] + + +def apply_obs4ref_fallback( + data_catalog: Mapping[SourceDatasetType, pd.DataFrame | DataCatalog], +) -> Mapping[SourceDatasetType, pd.DataFrame | DataCatalog]: + """ + Fold the obs4REF catalog into the obs4MIPs one so obs4MIPs requirements can reach it. + + Parameters + ---------- + data_catalog + Data catalogs for each source dataset type + + Returns + ------- + : + The catalogs with obs4MIPs extended by the obs4REF datasets it lacks + (see `with_obs4ref_fallback`). + The mapping is returned unchanged when nothing is ingested as obs4REF. + """ + if SourceDatasetType.obs4REF not in data_catalog: + return data_catalog + obs4mips = data_catalog.get(SourceDatasetType.obs4MIPs, _EMPTY_CATALOG) + return { + **data_catalog, + SourceDatasetType.obs4MIPs: with_obs4ref_fallback(obs4mips, data_catalog[SourceDatasetType.obs4REF]), + } + + def solve_executions( data_catalog: Mapping[SourceDatasetType, pd.DataFrame | DataCatalog], diagnostic: Diagnostic, @@ -275,11 +363,12 @@ def solve_executions( raise ValueError(f"Diagnostic {diagnostic.slug!r} has no data requirements") first_item = next(iter(diagnostic.data_requirements)) + catalogs = apply_obs4ref_fallback(data_catalog) if isinstance(first_item, DataRequirement): # We have a single collection of data requirements yield from _solve_from_data_requirements( - data_catalog, + catalogs, diagnostic, typing.cast(Sequence[DataRequirement], diagnostic.data_requirements), provider, @@ -294,7 +383,7 @@ def solve_executions( # Buffer executions to check if any were actually produced # _solve_from_data_requirements returns empty if source types are missing executions = list( - _solve_from_data_requirements(data_catalog, diagnostic, requirement_collection, provider) + _solve_from_data_requirements(catalogs, diagnostic, requirement_collection, provider) ) if executions: any_matched = True diff --git a/packages/climate-ref/tests/unit/datasets/test_obs4mips/obs4mips_catalog_db.yml b/packages/climate-ref/tests/unit/datasets/test_obs4mips/obs4mips_catalog_db.yml index 5fe46d4d5..6ffa0ac23 100644 --- a/packages/climate-ref/tests/unit/datasets/test_obs4mips/obs4mips_catalog_db.yml +++ b/packages/climate-ref/tests/unit/datasets/test_obs4mips/obs4mips_catalog_db.yml @@ -1,114 +1,3 @@ -- activity_id: obs4MIPs - end_time: '2012-12-15 00:00:00' - finalised: true - frequency: mon - grid: 0.5x0.5 degree - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ARCCSS.LORA-1-1.mon.mrro.50km.gn.v20250516 - institution_id: ARCCSS - long_name: Total Runoff - nominal_resolution: 50 km - path: '{esgf_data_dir}/obs4REF/obs4REF/ARCCSS/LORA-1-1/mon/mrro/gn/20250516/mrro_mon_LORA-1-1_REF_gn_198001-201212.nc' - product: observations - realm: land - source_id: LORA-1-1 - source_type: gridded_insitu - source_version_number: '1.1' - start_time: '1980-01-15 00:00:00' - units: kg m-2 s-1 - variable_id: mrro - variant_label: REF - version: v20250516 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2019-12-16 12:00:00' - finalised: true - frequency: mon - grid: 1x1 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.CNES.AVISO-1-0.mon.zos.250km.gn.v20210727 - institution_id: CNES - long_name: Sea Surface Height Above Geoid - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/CNES/AVISO-1-0/mon/zos/gn/v20210727/zos_mon_AVISO-1-0_PCMDI_gn_199301-201912.nc' - product: observations - realm: ocean - source_id: AVISO-1-0 - source_type: satellite_retrieval - source_version_number: '1.0' - start_time: '1993-01-16 12:00:00' - units: m - variable_id: zos - variant_label: PCMDI - version: v20210727 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2015-12-15 00:00:00' - finalised: true - frequency: mon - grid: 1x1 degree - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ColumbiaU.WECANN-1-0.mon.gpp.100km.gn.v20250516 - institution_id: ColumbiaU - long_name: Carbon Mass Flux out of Atmosphere Due to Gross Primary Production on - Land [kgC m-2 s-1] - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/ColumbiaU/WECANN-1-0/mon/gpp/gn/20250516/gpp_mon_WECANN-1-0_REF_gn_200701-201512.nc' - product: derived - realm: land - source_id: WECANN-1-0 - source_type: satellite_retrieval - source_version_number: '1' - start_time: '2007-01-15 00:00:00' - units: kg m-2 s-1 - variable_id: gpp - variant_label: REF - version: v20250516 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2015-12-15 00:00:00' - finalised: true - frequency: mon - grid: 1x1 degree - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ColumbiaU.WECANN-1-0.mon.hfls.100km.gn.v20250516 - institution_id: ColumbiaU - long_name: Surface Upward Latent Heat Flux - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/ColumbiaU/WECANN-1-0/mon/hfls/gn/20250516/hfls_mon_WECANN-1-0_REF_gn_200701-201512.nc' - product: derived - realm: atmos - source_id: WECANN-1-0 - source_type: satellite_retrieval - source_version_number: '1' - start_time: '2007-01-15 00:00:00' - units: W m-2 - variable_id: hfls - variant_label: REF - version: v20250516 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2015-12-15 00:00:00' - finalised: true - frequency: mon - grid: 1x1 degree - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ColumbiaU.WECANN-1-0.mon.hfss.100km.gn.v20250516 - institution_id: ColumbiaU - long_name: Surface Upward Sensible Heat Flux - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/ColumbiaU/WECANN-1-0/mon/hfss/gn/20250516/hfss_mon_WECANN-1-0_REF_gn_200701-201512.nc' - product: derived - realm: atmos - source_id: WECANN-1-0 - source_type: satellite_retrieval - source_version_number: '1' - start_time: '2007-01-15 00:00:00' - units: W m-2 - variable_id: hfss - variant_label: REF - version: v20250516 - vertical_levels: 1 - activity_id: obs4MIPs end_time: '2021-12-16 12:00:00' finalised: true @@ -2639,579 +2528,3 @@ variant_label: PCMDI version: v20250220 vertical_levels: 37 -- activity_id: obs4MIPs - end_time: '2017-07-16 12:00:00' - finalised: true - frequency: mon - grid: 1x1 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.hfls.250km.gn.v20250415 - institution_id: ESSO - long_name: Surface Upward Latent Heat Flux - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/ESSO/TropFlux-1-0/mon/hfls/gn/v20250415/hfls_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc' - product: observations - realm: atmos - source_id: TropFlux-1-0 - source_type: satellite_blended - source_version_number: '1.0' - start_time: '1979-01-16 12:00:00' - units: W m-2 - variable_id: hfls - variant_label: PCMDI - version: v20250415 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2017-07-15 12:00:00' - finalised: true - frequency: mon - grid: 1.0x1.0 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.hfns.250km.gn.v20210727 - institution_id: ESSO - long_name: Net Surface Energy - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/ESSO/TropFlux-1-0/mon/hfns/gn/v20210727/hfns_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc' - product: observations - realm: atmos - source_id: TropFlux-1-0 - source_type: satellite_blended - source_version_number: '1.0' - start_time: '1979-01-15 00:00:00' - units: W m-2 - variable_id: hfns - variant_label: PCMDI - version: v20210727 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2017-07-16 12:00:00' - finalised: true - frequency: mon - grid: 1x1 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.hfss.250km.gn.v20250415 - institution_id: ESSO - long_name: Surface Upward Sensible Heat Flux - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/ESSO/TropFlux-1-0/mon/hfss/gn/v20250415/hfss_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc' - product: observations - realm: atmos - source_id: TropFlux-1-0 - source_type: satellite_blended - source_version_number: '1.0' - start_time: '1979-01-16 12:00:00' - units: W m-2 - variable_id: hfss - variant_label: PCMDI - version: v20250415 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2017-07-16 12:00:00' - finalised: true - frequency: mon - grid: 1x1 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.tas.250km.gn.v20250415 - institution_id: ESSO - long_name: Near-Surface Air Temperature - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/ESSO/TropFlux-1-0/mon/tas/gn/v20250415/tas_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc' - product: observations - realm: atmos - source_id: TropFlux-1-0 - source_type: satellite_blended - source_version_number: '1.0' - start_time: '1979-01-16 12:00:00' - units: K - variable_id: tas - variant_label: PCMDI - version: v20250415 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2017-07-16 12:00:00' - finalised: true - frequency: mon - grid: 1x1 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.tauu.250km.gn.v20250415 - institution_id: ESSO - long_name: Surface Downward Eastward Wind Stress - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/ESSO/TropFlux-1-0/mon/tauu/gn/v20250415/tauu_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc' - product: observations - realm: atmos - source_id: TropFlux-1-0 - source_type: satellite_blended - source_version_number: '1.0' - start_time: '1979-01-16 12:00:00' - units: Pa - variable_id: tauu - variant_label: PCMDI - version: v20250415 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2017-07-15 12:00:00' - finalised: true - frequency: mon - grid: 1.0x1.0 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.tauv.250km.gn.v20210727 - institution_id: ESSO - long_name: Surface Downward Northward Wind Stress - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/ESSO/TropFlux-1-0/mon/tauv/gn/v20210727/tauv_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc' - product: observations - realm: atmos - source_id: TropFlux-1-0 - source_type: satellite_blended - source_version_number: '1.0' - start_time: '1979-01-15 00:00:00' - units: Pa - variable_id: tauv - variant_label: PCMDI - version: v20210727 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2017-07-15 12:00:00' - finalised: true - frequency: mon - grid: 1.0x1.0 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727 - institution_id: ESSO - long_name: Surface Temperature - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/ESSO/TropFlux-1-0/mon/ts/gn/v20210727/ts_mon_TropFlux-1-0_PCMDI_gn_197901-201707.nc' - product: observations - realm: atmos - source_id: TropFlux-1-0 - source_type: satellite_blended - source_version_number: '1.0' - start_time: '1979-01-15 00:00:00' - units: K - variable_id: ts - variant_label: PCMDI - version: v20210727 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2025-01-16 12:00:00' - finalised: true - frequency: mon - grid: 1x1 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 - institution_id: MOHC - long_name: Surface Temperature - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/MOHC/HadISST-1-1/mon/ts/gn/v20250415/ts_mon_HadISST-1-1_PCMDI_gn_187001-202501.nc' - product: observations - realm: atmos - source_id: HadISST-1-1 - source_type: satellite_blended - source_version_number: 1-1 - start_time: '1870-01-16 12:00:00' - units: K - variable_id: ts - variant_label: PCMDI - version: v20250415 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2023-03-16 12:00:00' - finalised: true - frequency: mon - grid: 0.5x0.5 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-GSFC.GPCP-Monthly-3-2.mon.pr.50km.gn.v20231205 - institution_id: NASA-GSFC - long_name: Precipitation - nominal_resolution: 50 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NOAA-NCEI/GPCP-2-3/mon/pr/gn/v20231205/pr_mon_GPCP-Monthly-3-2_RSS_gn_198301-202303.nc' - product: observations - realm: atmos - source_id: GPCP-Monthly-3-2 - source_type: satellite_blended - source_version_number: '3.2' - start_time: '1983-01-16 12:00:00' - units: kg m-2 s-1 - variable_id: pr - variant_label: RSS - version: v20231205 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2017-12-16 12:00:00' - finalised: true - frequency: mon - grid: 0.25x0.25 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-GSFC.TRMM-3B43v-7.mon.pr.25km.gn.v20210727 - institution_id: NASA-GSFC - long_name: Precipitation - nominal_resolution: 25 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NASA-GSFC/TRMM-3B43v-7/mon/pr/gn/v20210727/pr_mon_TRMM-3B43v-7_PCMDI_gn_199801-201712.nc' - product: observations - realm: atmos - source_id: TRMM-3B43v-7 - source_type: satellite_blended - source_version_number: 3B43v.7 - start_time: '1998-01-16 12:00:00' - units: kg m-2 s-1 - variable_id: pr - variant_label: PCMDI - version: v20210727 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2023-09-16 00:00:00' - finalised: true - frequency: mon - grid: 1x1 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rlds.100km.gn.v20230209 - institution_id: NASA-LaRC - long_name: Surface Downwelling Longwave Radiation - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NASA-LaRC/CERES-EBAF-4-2/mon/rlds/gn/v20230209/rlds_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc' - product: observations - realm: atmos - source_id: CERES-EBAF-4-2 - source_type: satellite_blended - source_version_number: '4.2' - start_time: '2000-03-16 12:00:00' - units: W m-2 - variable_id: rlds - variant_label: RSS - version: v20230209 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2023-09-16 00:00:00' - finalised: true - frequency: mon - grid: 1x1 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rlus.100km.gn.v20230209 - institution_id: NASA-LaRC - long_name: Surface Upwelling Longwave Radiation - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NASA-LaRC/CERES-EBAF-4-2/mon/rlus/gn/v20230209/rlus_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc' - product: observations - realm: atmos - source_id: CERES-EBAF-4-2 - source_type: satellite_blended - source_version_number: '4.2' - start_time: '2000-03-16 12:00:00' - units: W m-2 - variable_id: rlus - variant_label: RSS - version: v20230209 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2023-09-16 00:00:00' - finalised: true - frequency: mon - grid: 1x1 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rsds.100km.gn.v20230209 - institution_id: NASA-LaRC - long_name: Surface Downwelling Shortwave Radiation - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NASA-LaRC/CERES-EBAF-4-2/mon/rsds/gn/v20230209/rsds_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc' - product: observations - realm: atmos - source_id: CERES-EBAF-4-2 - source_type: satellite_blended - source_version_number: '4.2' - start_time: '2000-03-16 12:00:00' - units: W m-2 - variable_id: rsds - variant_label: RSS - version: v20230209 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2023-09-16 00:00:00' - finalised: true - frequency: mon - grid: 1x1 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NASA-LaRC.CERES-EBAF-4-2.mon.rsus.100km.gn.v20230209 - institution_id: NASA-LaRC - long_name: Surface Upwelling Shortwave Radiation - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NASA-LaRC/CERES-EBAF-4-2/mon/rsus/gn/v20230209/rsus_mon_CERES-EBAF-4-2_RSS_gn_200003-202309.nc' - product: observations - realm: atmos - source_id: CERES-EBAF-4-2 - source_type: satellite_blended - source_version_number: '4.2' - start_time: '2000-03-16 12:00:00' - units: W m-2 - variable_id: rsus - variant_label: RSS - version: v20230209 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2012-12-16 12:00:00' - finalised: true - frequency: mon - grid: 2.0x2.0 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-ESRL-PSD.20CR.mon.psl.250km.gn.v20210727 - institution_id: NOAA-ESRL-PSD - long_name: Sea Level Pressure - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NOAA-ESRL-PSD/20CR/mon/psl/gn/v20210727/psl_mon_20CR_PCMDI_gn_187101-201212.nc' - product: observations - realm: atmos - source_id: 20CR - source_type: reanalysis - source_version_number: N/A - start_time: '1871-01-16 12:00:00' - units: Pa - variable_id: psl - variant_label: PCMDI - version: v20210727 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2012-12-16 12:00:00' - finalised: true - frequency: mon - grid: 2.0x2.0 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-ESRL-PSD.20CR.mon.ts.250km.gn.v20210727 - institution_id: NOAA-ESRL-PSD - long_name: Surface Temperature - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NOAA-ESRL-PSD/20CR/mon/ts/gn/v20210727/ts_mon_20CR_PCMDI_gn_187101-201212.nc' - product: observations - realm: atmos - source_id: 20CR - source_type: reanalysis - source_version_number: N/A - start_time: '1871-01-16 12:00:00' - units: K - variable_id: ts - variant_label: PCMDI - version: v20210727 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2019-01-16 12:00:00' - finalised: true - frequency: mon - grid: 0.25x0.25 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-NCEI.CMAP-V1902.mon.pr.100km.gn.v20210727 - institution_id: NOAA-NCEI - long_name: Precipitation - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NOAA-NCEI/CMAP-V1902/mon/pr/gn/v20210727/pr_mon_CMAP-V1902_PCMDI_gn_197901-201901.nc' - product: observations - realm: atmos - source_id: CMAP-V1902 - source_type: satellite_blended - source_version_number: V1902 - start_time: '1979-01-16 12:00:00' - units: kg m-2 s-1 - variable_id: pr - variant_label: PCMDI - version: v20210727 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2019-07-16 12:00:00' - finalised: true - frequency: mon - grid: 2.5x2.5 degree latitude x longitude - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-NCEI.GPCP-2-3.mon.pr.250km.gn.v20210727 - institution_id: NOAA-NCEI - long_name: Precipitation - nominal_resolution: 250 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NOAA-NCEI/GPCP-2-3/mon/pr/gn/v20210727/pr_mon_GPCP-2-3_PCMDI_gn_197901-201907.nc' - product: observations - realm: atmos - source_id: GPCP-2-3 - source_type: satellite_blended - source_version_number: '2.3' - start_time: '1979-01-16 12:00:00' - units: kg m-2 s-1 - variable_id: pr - variant_label: PCMDI - version: v20210727 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2015-12-15 00:00:00' - finalised: true - frequency: mon - grid: 100 km - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-NCEI.WOA2023.mon.no3.100km.gn.v20250516 - institution_id: NOAA-NCEI - long_name: Objectively analyzed mean fields for moles_of_nitrate_per_unit_mass_in_sea_water - at standard depth levels. - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NOAA-NCEI/WOA2023/mon/no3/gn/20250516/no3_mon_WOA2023_REF_gn_201501-202212.nc' - product: observations - realm: ocean - source_id: WOA2023 - source_type: gridded_insitu - source_version_number: '2023' - start_time: '2015-01-15 00:00:00' - units: micromoles_per_kilogram - variable_id: no3 - variant_label: REF - version: v20250516 - vertical_levels: 43 -- activity_id: obs4MIPs - end_time: '2015-12-15 00:00:00' - finalised: true - frequency: mon - grid: 100 km - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-NCEI.WOA2023.mon.o2.100km.gn.v20250516 - institution_id: NOAA-NCEI - long_name: Objectively analyzed mean fields for moles_of_oxygen_per_unit_mass_in_sea_water - at standard depth levels. - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NOAA-NCEI/WOA2023/mon/o2/gn/20250516/o2_mon_WOA2023_REF_gn_201501-202212.nc' - product: observations - realm: ocean - source_id: WOA2023 - source_type: gridded_insitu - source_version_number: '2023' - start_time: '2015-01-15 00:00:00' - units: micromoles_per_kilogram - variable_id: o2 - variant_label: REF - version: v20250516 - vertical_levels: 57 -- activity_id: obs4MIPs - end_time: '2015-12-15 00:00:00' - finalised: true - frequency: mon - grid: 100 km - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-NCEI.WOA2023.mon.po4.100km.gn.v20250516 - institution_id: NOAA-NCEI - long_name: Objectively analyzed mean fields for moles_of_phosphate_per_unit_mass_in_sea_water - at standard depth levels. - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NOAA-NCEI/WOA2023/mon/po4/gn/20250516/po4_mon_WOA2023_REF_gn_201501-202212.nc' - product: observations - realm: ocean - source_id: WOA2023 - source_type: gridded_insitu - source_version_number: '2023' - start_time: '2015-01-15 00:00:00' - units: micromoles_per_kilogram - variable_id: po4 - variant_label: REF - version: v20250516 - vertical_levels: 43 -- activity_id: obs4MIPs - end_time: '2015-12-15 00:00:00' - finalised: true - frequency: mon - grid: 100 km - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-NCEI.WOA2023.mon.so.100km.gn.v20250516 - institution_id: NOAA-NCEI - long_name: Objectively analyzed mean fields for sea_water_practical_salinity at - standard depth levels. - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NOAA-NCEI/WOA2023/mon/so/gn/20250516/so_mon_WOA2023_REF_gn_201501-202212.nc' - product: observations - realm: ocean - source_id: WOA2023 - source_type: gridded_insitu - source_version_number: '2023' - start_time: '2015-01-15 00:00:00' - units: '1' - variable_id: so - variant_label: REF - version: v20250516 - vertical_levels: 57 -- activity_id: obs4MIPs - end_time: '2015-12-15 00:00:00' - finalised: true - frequency: mon - grid: 100 km - grid_label: gn - instance_id: obs4MIPs.obs4MIPs.NOAA-NCEI.WOA2023.mon.thetao.100km.gn.v20250516 - institution_id: NOAA-NCEI - long_name: Sea Water Potential Temperature - nominal_resolution: 100 km - path: '{esgf_data_dir}/obs4REF/obs4REF/NOAA-NCEI/WOA2023/mon/thetao/gn/20250516/thetao_mon_WOA2023_REF_gn_201501-202212.nc' - product: observations - realm: ocean - source_id: WOA2023 - source_type: gridded_insitu - source_version_number: '2023' - start_time: '2015-01-15 00:00:00' - units: degC - variable_id: thetao - variant_label: REF - version: v20250516 - vertical_levels: 57 -- activity_id: obs4MIPs - end_time: '2023-02-15 00:00:00' - finalised: true - frequency: mon - grid: N/A - grid_label: NA - instance_id: obs4MIPs.obs4MIPs.NOC.RAPID.mon.msftmz.N/A.NA.v20250516 - institution_id: NOC - long_name: Ocean Meridional Overturning Mass Streamfunction - nominal_resolution: N/A - path: '{esgf_data_dir}/obs4REF/obs4REF/NOC/RAPID/mon/msftmz/NA/20250516/msftmz_mon_RAPID_REF_NA_200404-202302.nc' - product: derived - realm: ocean - source_id: RAPID - source_type: insitu - source_version_number: '2023.1' - start_time: '2004-04-15 00:00:00' - units: Sv - variable_id: msftmz - variant_label: REF - version: v20250516 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2010-07-02 12:00:00' - finalised: true - frequency: yr - grid: global mean data - grid_label: gm - instance_id: obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman.yr.fgco2.N/A.gm.v20250516 - institution_id: UCI-ORNL - long_name: global net downward ocean carbon flux - nominal_resolution: N/A - path: '{esgf_data_dir}/obs4REF/obs4REF/UCI-ORNL/Hoffman/yr/fgco2/gm/20250516/fgco2_yr_Hoffman_REF_gm_185007-201007.nc' - product: derived - realm: ocean - source_id: Hoffman - source_type: stastical-estimate - source_version_number: '1' - start_time: '1850-07-02 12:00:00' - units: Pg yr-1 - variable_id: fgco2 - variant_label: REF - version: v20250516 - vertical_levels: 1 -- activity_id: obs4MIPs - end_time: '2010-07-02 12:00:00' - finalised: true - frequency: yr - grid: global mean data - grid_label: gm - instance_id: obs4MIPs.obs4MIPs.UCI-ORNL.Hoffman.yr.nbp.N/A.gm.v20250516 - institution_id: UCI-ORNL - long_name: global net downward land carbon flux - nominal_resolution: N/A - path: '{esgf_data_dir}/obs4REF/obs4REF/UCI-ORNL/Hoffman/yr/nbp/gm/20250516/nbp_yr_Hoffman_REF_gm_185007-201007.nc' - product: derived - realm: land - source_id: Hoffman - source_type: stastical-estimate - source_version_number: '1' - start_time: '1850-07-02 12:00:00' - units: Pg yr-1 - variable_id: nbp - variant_label: REF - version: v20250516 - vertical_levels: 1 diff --git a/packages/climate-ref/tests/unit/datasets/test_obs4ref.py b/packages/climate-ref/tests/unit/datasets/test_obs4ref.py index 0b770faab..aff40c8ec 100644 --- a/packages/climate-ref/tests/unit/datasets/test_obs4ref.py +++ b/packages/climate-ref/tests/unit/datasets/test_obs4ref.py @@ -1,10 +1,4 @@ -"""Tests for the obs4REF adapter (A3): warn-only cross-adapter enforcement. - -``Obs4REFDatasetAdapter`` and ``Obs4MIPsDatasetAdapter`` share the same -:func:`~climate_ref.datasets.obs4mips.parse_obs4mips` parser, but each restricts its own -``accepted_activity_ids``. A file whose ``activity_id`` doesn't match the adapter that -parses it is still ingested (warn-only, not rejected) -- see ``obs4mips.py`` docstrings. -""" +"""Tests for the obs4REF adapter.""" import netCDF4 import numpy as np @@ -45,43 +39,45 @@ def _write_obs4_style_file(path, *, activity_id: str) -> None: @pytest.fixture def obs4ref_style_dir(tmp_path): - """A single obs4REF-DRS-shaped file with ``activity_id="obs4REF"``.""" + """An obs4REF-registry-shaped file that still claims ``activity_id="obs4MIPs"`` inside.""" fixture_dir = tmp_path / "obs4ref_style" _write_obs4_style_file( fixture_dir / "obs4REF" / "TESTORG" / "TEST-SRC" / "mon" / "ts" / "gn" / "v1" / "ts_mon.nc", - activity_id="obs4REF", + activity_id="obs4MIPs", ) return fixture_dir class TestObs4REFDatasetAdapter: - def test_instance_id_prefix(self): - assert Obs4REFDatasetAdapter.instance_id_prefix == "obs4REF" - assert Obs4REFDatasetAdapter.accepted_activity_ids == ("obs4REF",) + def test_activity_id(self): + assert Obs4REFDatasetAdapter.activity_id == "obs4REF" + assert Obs4MIPsDatasetAdapter.activity_id == "obs4MIPs" - def test_load_local_datasets_prefixes_instance_id(self, obs4ref_style_dir): - adapter = Obs4REFDatasetAdapter() - data_catalog = adapter.find_local_datasets(obs4ref_style_dir) + def test_stamps_collection_regardless_of_file(self, obs4ref_style_dir): + """The registry republishes obs4MIPs files unchanged, so the adapter decides the collection.""" + data_catalog = Obs4REFDatasetAdapter().find_local_datasets(obs4ref_style_dir) assert len(data_catalog) == 1 - assert data_catalog["instance_id"].iloc[0].startswith("obs4REF.") - - def test_cross_parsed_by_obs4mips_adapter_warns_and_ingests(self, obs4ref_style_dir, caplog): - """An obs4REF file parsed by the obs4MIPs adapter is still ingested, with a warning.""" - obs4mips_adapter = Obs4MIPsDatasetAdapter() - ref_adapter = Obs4REFDatasetAdapter() + assert data_catalog["activity_id"].iloc[0] == "obs4REF" + assert data_catalog["instance_id"].iloc[0] == "obs4REF.obs4REF.TESTORG.TEST-SRC.mon.ts.100km.gn.v1" - obs4mips_catalog = obs4mips_adapter.find_local_datasets(obs4ref_style_dir) - ref_catalog = ref_adapter.find_local_datasets(obs4ref_style_dir) + def test_same_file_gets_distinct_ids(self, obs4ref_style_dir): + obs4mips_catalog = Obs4MIPsDatasetAdapter().find_local_datasets(obs4ref_style_dir) + ref_catalog = Obs4REFDatasetAdapter().find_local_datasets(obs4ref_style_dir) - assert len(obs4mips_catalog) == 1 obs4mips_instance_id = obs4mips_catalog["instance_id"].iloc[0] ref_instance_id = ref_catalog["instance_id"].iloc[0] + assert obs4mips_instance_id.startswith("obs4MIPs.obs4MIPs.") + assert ref_instance_id.startswith("obs4REF.obs4REF.") + assert obs4mips_instance_id.split(".", 2)[2] == ref_instance_id.split(".", 2)[2] + + def test_obs4mips_adapter_warns_on_obs4ref_layout(self, obs4ref_style_dir, caplog): + Obs4MIPsDatasetAdapter().find_local_datasets(obs4ref_style_dir) + + warnings = [r.message for r in caplog.records if r.levelname == "WARNING"] + assert any("look like obs4REF data" in msg and "--source-type obs4ref" in msg for msg in warnings) - assert obs4mips_instance_id.startswith("obs4MIPs.") - assert ref_instance_id.startswith("obs4REF.") - # Non-colliding slugs -- the cross-parsed file never masquerades as the same dataset. - assert obs4mips_instance_id != ref_instance_id + def test_obs4ref_adapter_does_not_warn(self, obs4ref_style_dir, caplog): + Obs4REFDatasetAdapter().find_local_datasets(obs4ref_style_dir) - warning_messages = [r.message for r in caplog.records if r.levelname == "WARNING"] - assert any("outside the expected" in msg for msg in warning_messages) + assert not [r for r in caplog.records if r.levelname == "WARNING"] diff --git a/packages/climate-ref/tests/unit/test_doctor.py b/packages/climate-ref/tests/unit/test_doctor.py index 56199066d..38d0c326b 100644 --- a/packages/climate-ref/tests/unit/test_doctor.py +++ b/packages/climate-ref/tests/unit/test_doctor.py @@ -18,8 +18,11 @@ ) from climate_ref.doctor.checks.data import ( check_duplicate_coverage, + check_misfiled_obs4ref, check_missing_reference_data, + check_superseded_obs4ref, check_unreachable_source_types, + check_unsolvable_diagnostics, ) from climate_ref.doctor.registry import RegisteredCheck, run_checks from climate_ref_core.datasets import FacetFilter @@ -146,42 +149,194 @@ def test_ingested_dataset_is_not_reported(self): assert check_missing_reference_data(context) == [] def test_data_under_another_source_type_does_not_satisfy_a_requirement(self): - # The obs4ref trap: the data is present, but in a table no requirement reads. - provider = _provider_requiring(SourceDatasetType.obs4MIPs, "WECANN-1-0", "gpp") - catalog = _catalog( - [("obs4REF.WECANN-1-0.gpp", "WECANN-1-0", "gpp", "2007-01-01", "2015-12-01", "/d/gpp.nc")] - ) + # The data is present, but in a table the requirement does not read. + provider = _provider_requiring(SourceDatasetType.obs4MIPs, "ERA-5", "ta") + catalog = _catalog([("pmp.ERA-5.ta", "ERA-5", "ta", "2000-01-01", "2000-12-01", "/d/ta.nc")]) context = _context( - {SourceDatasetType.obs4MIPs: _catalog([]), SourceDatasetType.obs4REF: catalog}, + {SourceDatasetType.obs4MIPs: _catalog([]), SourceDatasetType.PMPClimatology: catalog}, [provider], ) findings = check_missing_reference_data(context) assert len(findings) == 1 - assert "WECANN-1-0" in findings[0].summary + assert "ERA-5" in findings[0].summary + + def test_obs4mips_requirement_is_met_by_obs4ref_data(self): + # obs4REF fills in what obs4MIPs has not published, so this is not missing. + provider = _provider_requiring(SourceDatasetType.obs4MIPs, "WECANN-1-0", "gpp") + catalog = _catalog( + [("obs4REF.WECANN-1-0.gpp", "WECANN-1-0", "gpp", "2007-01-01", "2015-12-01", "/d/gpp.nc")] + ) + context = _context({SourceDatasetType.obs4REF: catalog}, [provider]) + + assert check_missing_reference_data(context) == [] class TestUnreachableSourceTypes: def test_data_no_requirement_asks_for_is_reported(self): provider = _provider_requiring(SourceDatasetType.obs4MIPs, "ERA-5", "ta") + catalog = _catalog([("pmp.ERA-5.ta", "ERA-5", "ta", "2000-01-01", "2000-12-01", "/d/ta.nc")]) + context = _context({SourceDatasetType.PMPClimatology: catalog}, [provider]) + + findings = check_unreachable_source_types(context) + + assert len(findings) == 1 + assert "pmp-climatology" in findings[0].summary + assert "source type the diagnostics ask for" in findings[0].remedy + + def test_requested_source_type_is_not_reported(self): + provider = _provider_requiring(SourceDatasetType.obs4MIPs, "ERA-5", "ta") + catalog = _catalog([("obs4MIPs.ERA-5.ta", "ERA-5", "ta", "2000-01-01", "2000-12-01", "/d/ta.nc")]) + context = _context({SourceDatasetType.obs4MIPs: catalog}, [provider]) + + assert check_unreachable_source_types(context) == [] + + def test_obs4ref_data_is_reachable_through_obs4mips_requirements(self): + provider = _provider_requiring(SourceDatasetType.obs4MIPs, "WECANN-1-0", "gpp") catalog = _catalog( [("obs4REF.WECANN-1-0.gpp", "WECANN-1-0", "gpp", "2007-01-01", "2015-12-01", "/d/gpp.nc")] ) context = _context({SourceDatasetType.obs4REF: catalog}, [provider]) - findings = check_unreachable_source_types(context) + assert check_unreachable_source_types(context) == [] + + +class TestSupersededObs4ref: + def test_dataset_in_both_is_reported(self): + obs4mips = _catalog( + [ + ( + "obs4MIPs.obs4MIPs.C.WECANN-1-0.mon.gpp.gn.v2", + "WECANN-1-0", + "gpp", + "2007-01-01", + "2015-12-01", + "/a", + ) + ] + ) + obs4ref = _catalog( + [ + ( + "obs4REF.obs4REF.C.WECANN-1-0.mon.gpp.gn.v1", + "WECANN-1-0", + "gpp", + "2007-01-01", + "2015-12-01", + "/b", + ), + ( + "obs4REF.obs4REF.C.WECANN-1-0.mon.hfls.gn.v1", + "WECANN-1-0", + "hfls", + "2007-01-01", + "2015-12-01", + "/c", + ), + ] + ) + context = _context({SourceDatasetType.obs4MIPs: obs4mips, SourceDatasetType.obs4REF: obs4ref}) + + findings = check_superseded_obs4ref(context) assert len(findings) == 1 - assert "obs4ref" in findings[0].summary - assert "--source-type obs4mips" in findings[0].remedy + assert findings[0].severity == Severity.INFO + assert findings[0].summary.startswith("obs4REF.obs4REF.C.WECANN-1-0.mon.gpp.gn.v1") - def test_requested_source_type_is_not_reported(self): + def test_nothing_ingested(self): + assert check_superseded_obs4ref(_context()) == [] + + +class TestMisfiledObs4ref: + def test_obs4ref_layout_under_obs4mips_is_reported(self): + catalog = _catalog( + [ + ( + "obs4MIPs.obs4MIPs.X.gpp", + "X-1-0", + "gpp", + "2007-01-01", + "2015-12-01", + "/d/obs4REF/X/gpp.nc", + ), + ( + "obs4MIPs.obs4MIPs.ERA-5.ta", + "ERA-5", + "ta", + "2000-01-01", + "2000-12-01", + "/d/obs4MIPs/ta.nc", + ), + ] + ) + context = _context({SourceDatasetType.obs4MIPs: catalog}) + + findings = check_misfiled_obs4ref(context) + + assert len(findings) == 1 + assert findings[0].severity == Severity.WARNING + assert "1 obs4REF dataset ingested as obs4mips" in findings[0].summary + assert "obs4MIPs.obs4MIPs.X.gpp" in findings[0].detail + assert "ERA-5" not in findings[0].detail + assert findings[0].command == "ref datasets ingest --source-type obs4ref " + + def test_registry_source_id_under_obs4mips_is_reported(self): + # WECANN-1-0 is carried by the obs4REF registry, whatever directory it was ingested from. + catalog = _catalog( + [("obs4MIPs.obs4MIPs.WECANN.gpp", "WECANN-1-0", "gpp", "2007-01-01", "2015-12-01", "/d/gpp.nc")] + ) + context = _context({SourceDatasetType.obs4MIPs: catalog}) + + assert len(check_misfiled_obs4ref(context)) == 1 + + def test_esgf_data_is_not_reported(self): + catalog = _catalog([("obs4MIPs.ERA-5.ta", "ERA-5", "ta", "2000-01-01", "2000-12-01", "/d/ta.nc")]) + context = _context({SourceDatasetType.obs4MIPs: catalog}) + + assert check_misfiled_obs4ref(context) == [] + + def test_nothing_ingested(self): + assert check_misfiled_obs4ref(_context()) == [] + + +class TestUnsolvableDiagnostics: + def test_diagnostic_with_nothing_ingested_is_reported(self): + provider = _provider_requiring(SourceDatasetType.obs4MIPs, "ERA-5", "ta") + context = _context({}, [provider]) + + findings = check_unsolvable_diagnostics(context) + + assert len(findings) == 1 + assert findings[0].severity == Severity.WARNING + assert "test_provider/needs-reference has no executions" == findings[0].summary + assert "nothing is ingested as obs4mips" in findings[0].detail + + def test_unmatched_filter_names_the_facets(self): + provider = _provider_requiring(SourceDatasetType.obs4MIPs, "ERA-5", "ta") + catalog = _catalog([("obs4MIPs.ERA-5.psl", "ERA-5", "psl", "2000-01-01", "2000-12-01", "/d/psl.nc")]) + context = _context({SourceDatasetType.obs4MIPs: catalog}, [provider]) + + findings = check_unsolvable_diagnostics(context) + + assert len(findings) == 1 + assert "no obs4mips datasets match source_id=ERA-5, variable_id=ta" in findings[0].detail + + def test_solvable_diagnostic_is_not_reported(self): provider = _provider_requiring(SourceDatasetType.obs4MIPs, "ERA-5", "ta") catalog = _catalog([("obs4MIPs.ERA-5.ta", "ERA-5", "ta", "2000-01-01", "2000-12-01", "/d/ta.nc")]) context = _context({SourceDatasetType.obs4MIPs: catalog}, [provider]) - assert check_unreachable_source_types(context) == [] + assert check_unsolvable_diagnostics(context) == [] + + def test_obs4mips_requirement_solves_from_obs4ref_data(self): + provider = _provider_requiring(SourceDatasetType.obs4MIPs, "WECANN-1-0", "gpp") + catalog = _catalog( + [("obs4REF.WECANN-1-0.gpp", "WECANN-1-0", "gpp", "2007-01-01", "2015-12-01", "/d/gpp.nc")] + ) + context = _context({SourceDatasetType.obs4REF: catalog}, [provider]) + + assert check_unsolvable_diagnostics(context) == [] class TestDiagnose: diff --git a/packages/climate-ref/tests/unit/test_doctor_registry.py b/packages/climate-ref/tests/unit/test_doctor_registry.py index ffd8e2415..8f37c831c 100644 --- a/packages/climate-ref/tests/unit/test_doctor_registry.py +++ b/packages/climate-ref/tests/unit/test_doctor_registry.py @@ -15,6 +15,9 @@ "missing-reference-data", "unreachable-source-type", "overlapping-registries", + "misfiled-obs4ref", + "superseded-obs4ref", + "unsolvable-diagnostics", } diff --git a/packages/climate-ref/tests/unit/test_solver.py b/packages/climate-ref/tests/unit/test_solver.py index 732765d15..706030663 100644 --- a/packages/climate-ref/tests/unit/test_solver.py +++ b/packages/climate-ref/tests/unit/test_solver.py @@ -21,10 +21,12 @@ ExecutionSolver, SolveFilterOptions, apply_dataset_filters, + apply_obs4ref_fallback, extract_covered_datasets, matches_filter, solve_executions, solve_required_executions, + with_obs4ref_fallback, ) from climate_ref_core.constraints import AddParentDataset, AddSupplementaryDataset, RequireFacets from climate_ref_core.datasets import SourceDatasetType @@ -1284,7 +1286,7 @@ def test_solve_with_new_datasets(obs4mips_data_catalog, mock_diagnostic, provide assert result_2.datasets.hash != result_1.datasets.hash -def test_solve_with_new_areacella(obs4mips_data_catalog, mock_diagnostic, provider): +def test_solve_with_new_areacella(obs4ref_data_catalog, mock_diagnostic, provider): expected_dataset_key = "cmip6_ssp126_ACCESS-ESM1-5_tas__obs4mips_HadISST-1-1_ts" mock_diagnostic.data_requirements = ( DataRequirement( @@ -1315,7 +1317,7 @@ def test_solve_with_new_areacella(obs4mips_data_catalog, mock_diagnostic, provid result_1 = next( solve_executions( { - SourceDatasetType.obs4MIPs: obs4mips_data_catalog, + SourceDatasetType.obs4REF: obs4ref_data_catalog, SourceDatasetType.CMIP6: cmip_data_catalog, }, mock_diagnostic, @@ -1340,7 +1342,7 @@ def test_solve_with_new_areacella(obs4mips_data_catalog, mock_diagnostic, provid result_2 = next( solve_executions( { - SourceDatasetType.obs4MIPs: obs4mips_data_catalog, + SourceDatasetType.obs4REF: obs4ref_data_catalog, SourceDatasetType.CMIP6: cmip_data_catalog, }, mock_diagnostic, @@ -1953,3 +1955,61 @@ def test_drs_and_complete_parsers_produce_same_executions(config, sample_data_di finally: db_complete.close() db_drs.close() + + +class TestObs4REFFallback: + """obs4MIPs requirements are filled from obs4REF, and the obs4MIPs copy wins.""" + + @staticmethod + def _frame(prefix, source_ids, version="v1", start=0): + return pd.DataFrame( + { + "instance_id": [f"{prefix}.{prefix}.INST.{s}.mon.ts.gn.{version}" for s in source_ids], + "source_id": list(source_ids), + "variable_id": "ts", + }, + index=range(start, start + len(source_ids)), + ) + + def test_missing_datasets_are_added(self): + obs4mips = self._frame("obs4MIPs", ["A"]) + obs4ref = self._frame("obs4REF", ["A", "B"], start=10) + + merged = with_obs4ref_fallback(obs4mips, obs4ref).to_frame() + + # A is taken from obs4MIPs only, B comes from obs4REF and keeps its dataset id. + assert merged["source_id"].tolist() == ["A", "B"] + assert merged.index.tolist() == [0, 11] + assert merged["instance_id"].iloc[1].startswith("obs4REF.") + + def test_obs4mips_wins_whatever_the_versions(self): + obs4mips = self._frame("obs4MIPs", ["A"], version="v1") + obs4ref = self._frame("obs4REF", ["A"], version="v2", start=10) + + assert with_obs4ref_fallback(obs4mips, obs4ref) is obs4mips + + def test_untouched_when_nothing_to_add(self): + obs4mips = self._frame("obs4MIPs", ["A"]) + + assert with_obs4ref_fallback(obs4mips, pd.DataFrame()) is obs4mips + + def test_obs4ref_alone_is_used_as_is(self): + obs4ref = self._frame("obs4REF", ["A"]) + + assert with_obs4ref_fallback(pd.DataFrame(), obs4ref) is obs4ref + + def test_only_the_obs4mips_catalog_is_extended(self): + catalogs = { + SourceDatasetType.obs4MIPs: self._frame("obs4MIPs", []), + SourceDatasetType.obs4REF: self._frame("obs4REF", ["A"]), + } + + applied = apply_obs4ref_fallback(catalogs) + + assert len(applied[SourceDatasetType.obs4MIPs]) == 1 + assert applied[SourceDatasetType.obs4REF] is catalogs[SourceDatasetType.obs4REF] + + def test_untouched_without_an_obs4ref_catalog(self): + catalogs = {SourceDatasetType.obs4MIPs: self._frame("obs4MIPs", ["A"])} + + assert apply_obs4ref_fallback(catalogs) is catalogs diff --git a/packages/climate-ref/tests/unit/test_solver/test_solve_metrics.yml b/packages/climate-ref/tests/unit/test_solver/test_solve_metrics.yml index b867d98a8..a56192887 100644 --- a/packages/climate-ref/tests/unit/test_solver/test_solve_metrics.yml +++ b/packages/climate-ref/tests/unit/test_solver/test_solve_metrics.yml @@ -35,7 +35,7 @@ cmip6_historical_ACCESS-ESM1-5_tos_r1i1p1f1__obs4mips_HadISST-1-1_ts: - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Omon.tos.gn.v20191115 - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Ofx.areacello.gn.v20191115 SourceDatasetType.obs4MIPs: - - obs4MIPs.obs4MIPs.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 + - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415 cmip6_historical_CESM2_rsut_r1i1p1f1: SourceDatasetType.CMIP6: - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.rsut.gn.v20190308 diff --git a/scripts/generate_esgf_catalog.py b/scripts/generate_esgf_catalog.py index c4e561a74..71f46b4c2 100644 --- a/scripts/generate_esgf_catalog.py +++ b/scripts/generate_esgf_catalog.py @@ -35,6 +35,13 @@ def main() -> None: default=[], help="Directory containing obs4MIPs data (can be specified multiple times)", ) + parser.add_argument( + "--obs4ref-dir", + type=Path, + action="append", + default=[], + help="Directory containing obs4REF data (can be specified multiple times)", + ) parser.add_argument( "--pmp-climatology-dir", type=Path, @@ -80,6 +87,13 @@ def main() -> None: write_catalog_parquet(catalog, out_path) print(f" Wrote {len(catalog)} rows to {out_path}") + if args.obs4ref_dir: + print(f"Scanning obs4REF directories: {args.obs4ref_dir}") + catalog = generate_catalog("obs4ref", args.obs4ref_dir, strip_path_prefix=args.strip_prefix) + out_path = output_dir / "obs4ref_catalog.parquet" + write_catalog_parquet(catalog, out_path) + print(f" Wrote {len(catalog)} rows to {out_path}") + if args.pmp_climatology_dir: print(f"Scanning PMP climatology directories: {args.pmp_climatology_dir}") catalog = generate_catalog( diff --git a/tests/test-data/esgf-catalog/obs4mips_catalog.parquet b/tests/test-data/esgf-catalog/obs4mips_catalog.parquet index fbd84ae5c4179368e1fa9c452828b791d130ace9..88cecc5b0789b5f46dcf5e6443c9df65e2b6f621 100644 GIT binary patch delta 6198 zcmc&(4Rlk-m7dYluaRX*)_am{*~r3vF&27$@7a=nLMZ+XHbS_zQCwg#0t?5)f3ao9 zNt2bG(8NuE0+Tk0H-{5AO*VM5Krmr*cC%S%2!(`}WP94~Nwa(SbvLBjWwTA&aB|qa z^DN1Nkmj^KXBWhqxo_^=d*^<4?wh&n{5$-smvFqLrd49qtb=t0Srv<{2pN~?5kdo- zhRatqwG6X}<=C7h?4+7CvszZ`3ZP?{tlM#Z`_c=v~Q-Gx^M97yvuL!Mz}ArFE@LH3Wiu{N)^ zwgkD=@MgFt7A?}q4$(g*3ZlPXLZG>X6<{UgF}yU+wj^w2n90JvqYT6J{^D4<2CR0m z?jSVO6{d0(i0bZ73Iq}PtmkHDsHrB0s(a4%tjeSdPBMtW<;!{%A~LSY1=&3)M9=rR+~b!VpT1I`s9y zR$pyXYmMT=FJIm$JCyKc9NoEI3(91_=)SKn1A5Y!3}#eF@wA8Klh9d~Q-?-keFMYP=-xyDVWDiGT z(cWGN(9WS~Y^WzX8tHXt=lE}Rl5bZA!iv9PY)sC9#2rsFtDL zk(f49uiiYYv67uSb6g{#h$eICNQCO{9vq}iZ=?rIsAiYl46|w^;$VP9F(FUkgfyjQ z%yK(wM~AVtL)W2a4opu^muJB8o7qs(-9@|Tid*Vd)i+T;)Hb9xEqPFHj?aZ@n#(l` zt3y@W@CP0Ij+))h1X-K1*cVmpkZe#}G~y0MN29&&mZ9kI@W@aUbN|vl5R3G>>jrv9 z`ug3g9~$V7E(F&0)iv(bBe8B$Xvkhfx6EQxy$BW7^+qE@zP9@wbT|4CaxZH^=C*qW zhGGHo4TBtCP7_Z5-Ty1RT5Ws|@;$}D<`XVxDWWSbVaOjg$ZpIdOK?vav z)Pd`1zCZ6`_vnJ`3s_m_VG}uJELgJ4j*4Yb6n$cuPx8ApURF?7RJArBb)^`#qwp|v z~Ycr?fYN;x63I!$>AW5!LkDLm4;8ATylI=?$0Xw>t8E0>Yf^1nCmRYUKysJI}B$Gk^{`XIfNT zA^AkfCk5P+JjbB;>>9o{oj^!eH$J}HZ6>R&`Q)tK5ZbuhZOhQUbzyvYp*7>}O(!;% z=a_zY2~X5}tY^Av_qCUB?_PQIM3bZ4esvvUfKU(!5vX3Np3|9Je}k4E3v`=2^v#uF#^|JP)& zwE+(J*1 zCi4Tvtw^rv3v5#%F*Hl=&*XG!S*$tLJWOWI`NV9|5~C%T0y%O>SfEzgFU4+tfTDP4 zo`fC+@K6H4^p;|GhyqF36@mh(;6>Ref`_V$5*-Sl!gm(`>&Gy<48Cs)cGC)WYHOAC zwkYLwpzrVbXj}7v@GC$2ld)2xT`OMPb0${3ujRng&9AL}wc~GpMzP9m)Gsf2F7)!# zTi@Oas69D_wuR4p`smwTWov&l`qq`$og2>mj?sRrZqxf?_562NZuv<9wqpTyrzyzp zpM&kMa)~$l&XC4iIKS0cyUQnwGR0A_Aww8;-LeVDCD<04pqsDJt876ps{OeFtrYgc z7p*yr!YZt;ENk|Xv)M6&HXt$4oX}wvDX`^}zp&{@zb#PmIFcv6z5DdU20iGjUC(H2 ztsv(hretWY0SexR$&YQ`1uy=-R;|Kq%^et#Vm7}dl%WAmsrev=SfUT57h2DJ;VlWV zuJ|{X9bu3R!${f}to5SS$MBV+nop5`WQ9c>OI4iM^hH6hK1CJ4IzFuuyy%Z@!ChC1de*aEjqQNnVk>y;N)aHV zyu~>vz}Dh;SfP?{|AJb(f?sIeNxszro4H7kvGH?_6laU0z+Vi_JM=bZIy(PwB-_&1ea zmPKsQ3}_ai&To83igP^qk>_dtlWuH88#cl|F&yvt{KhzL9H)E5u#s%dIh~=Fd{}p5 zT-`cOnseKXk>fxeM;nf#3$W93S!V5VL}qelX&%g(nQ}E^wTW>EGZ*re?k)X|-3Weocfnpy$ebkhJ>r>|Ck}g#&J&kBzf2J)I8QvslALs+$+K#nc-S*O zPaN{hP~z9SQReQzZ0h|5_C%w#&y*B!U^*vgO(Wvz#F2F3N{WcXBu94Iq?PH!U#1hU zQUXlS>|S;`^?u*9$&-0wS_8iW)22F`b;_5sbhtr8ml!(JW zyc>o%HjS?-op?B%I7Ep#KU_?`hc&QVayk$zQiNCr=L(30GD!x|;nQ_l&Hh@Qb|{QP ziDK-VU6EL%v#V$5ky2W%_|kCliKS3?C{0DwC~WqC2oL(I6NWkyQAkxn`1!<6kkv_} zuT!e3l0^7Rhsq^c2|~vX3^BONjL;F)ZzP;J>lo^0Ru>oIb+ocVx9c(GLN|91UMJFT z8P)3-%O$@hQ(q=Y&P+2{A&ZtA;T(GqiM({KaWg2~E~km{r=90I*EI|YD5pmO~8^$Fem!W>>8 zkDRXx!KLT!q zWloi}$o(R#f7X~_z92eGz&xww-emZbYW__Xc@meAYROuA{#iW7L@&EMastPE4ONw@Kq;!8)+bc`jpX5h`R`TSNfrNrp5LpZ z%(tr;Df6y!iFuhazYyk4f+`h%ErY*km{*dh?$8O+@3U`ju5NH>plf7vEMcpP+Ye%l z{M7|SRNIZ|#T+sya{B9w6M8?Cop#VW=kgz$`0XY|FD=lJ%rck$azY=G%JQ;-@kxf; z!|*Rz_+wd=QK_gHQWX_tP4DCnTi#e{qZLjB~&ClyE-c6^e z9w!riCk@c+n7e`b({BFcLPbgP-mK{OM3>OHeIwwaVgE(}_rU`G z^#H%Clq|4{gsZU5^N1DslxNpc?r=6g)x=+|ChIFidg3CQibaOk_a?RnIalF`Q~iI< zaI;JKcP21D6`~gWALjf8X-S`~t9;pTeFu|RsQ5IYxcaIVO2GBBmAk6vukV4~yhBMFe_jIIXt@4S(o0R%t_;xdEatZG`@cs9sy!B{ z$|J7H2+69^NLU8<;mzc_B}&5;UO7QlDPy@&mTXJZC>!1^3CL+p36Q-C z?3Jhq(S-jsnmHlsX7rMT@5^>R^QDAE11x#2b~F5`O?XvZ1(GjU+LWshE3Y-$Yd4#D z&aSI>-;2ydcq|wXlye#HpViZF-ex0!f&WSh^yD(u(o;J z-PS%&D{*>jx7*s%>2_qmbDPKEbapBUnZx6-cKh5;InJwh_N;6tm}n1;Zw?3IOp(gf z>v3bXxwDgywL5z8a?>r&PHV5*)s2_y9d4&H;ql-S`DTmD+15!6;p%l_eW^1N8Xp}B z`fgFn^%hWse6{vEyXUf}BF)j5K^S^G(qQK_>w&4JEb6thl35R8<_Qdj)k>rGZWkwI z#>7lKU?ds{NQ&jJN%@Ht&$-on`Ulj#?szy-Yv?hMMka!LJ2J64@83-1AdTJ#qc0abWnwf z%+y1nmvt^7Q!GDn7oM}1+dV3p5Tb@#d*SasQ%2Q*?)QC@<8 z)a~f&1Zv)1Z54c3@M2|~)8TSK51znuWv|0(ubvAt8Ocw^Ogry%TfO#w4l?gx<`h6i zMa(EeA@K-ZnHU=i1;YM-pU4Y_A+*N{hDM@6KfD}N=B$9MiDGeIEFN6z3t0njAN9op zb73au^JGR%np2YZYy@>AN|j)LYTj$5O_-XeBu=;oGuuGR9Ld?vbtF4@?F1MN9FO59 z5ck(`c22;IMc{Y>0y{6%;%r4NP-NEFIiBE+n3WTJbD2-(wHvWOlxahA1=>>oa8m}= z(JBSn2^q5ovI{He^CUJBRmNf=oT-RMZo`>XvG7{)$8vK53v}EkSEF&cdh^KG7|E+c z5*0))NWJgO>2n7*TvQ8jav3ijXgegZk0faD4POys|15A?}E; z3&ed9(7%gu-58y+ai38;i+Lg>8Mo|_R1C=}^LEGm*ozL~%zj0`l3M@F#b1A=DVyqR zgTx1R?q>$NnJj5kRR@V?ea@cNPGatsHo^mDzUd6PwA14uB%R%ko)*vurqmlXqTC6X zC}k~ek`j6SDJ4jbP!k6~_DyiGLIz@YtC*>=knrUR_{`mbje(H4FB*u&#-jm< zug(>bI7~`XDh-=^Z;ONhb&#A>=7dk4aAXsF-x!E4grNFkhN&3ybUA7)L1t$t;EP&4 z8@6;=5i&Phk>0Z|5{+-Ni$bP7@mmsnZGh=bzNp{4G8p#v!Hd2?bZsCU_l*P~a?New zcrY3;FNH`jI|HGRdBr+k+}s%}H?#))=AkX-7UEp9H!uQTDpH;>Tfo5}*B6TgV&#Rv z&*_T}MZ)GDcw-L2J>rJ}g7-rSb6+G9TGm*MOk}DtN5SZ!!VLZb*5mqQzzJ>fPgENHRtF@$P_c*zAac9XlbKH}fFA zmn1}U4`f|``R^^gV5K-{91M>r6H{hS9B4;}$HVeL)U|6rWM}1merYrUs`f=8!lPTd zzJ<`gjMB^@XZ#pFYF6#xeZ1?ig&FT4hBO0n0Yu7HOB(J!A+19qJv{?yZazb z490w+U^p0uE^#wtg|NTqgwY=ui?1_>92XR}7`NioAfn@eTk41V$s)tecxU2CW>F=b zpB$g~bfzIu{v-+NQ!p!BYsGVcdvB~!LuC`p0?Izc%)5|83MF{oE-k?Nd?b5}5-wv& zPZLD1G>=;CI3F#;n$l5o%qkGfkKjC&*>4r_e0>>-#0~P?1a3C{9G;@Ox?2;(H2VV)$*yCqEP%;6vlH5MB;;xZ{X%(th4Ls~FVUbJ0C_sgk`yN^HL zG>6%FDEE8|EL7!_o|}p69K6 zwY4!1sf32b4RWq|04R8G9`fFg)UlQ}gsQ!5VwI{qxBTClFM^`p)TQXC>$Etg+><~; z&pDbG?8UT7sITQG`)JDR4#LhgaFIN4$d$gI#>w{eJF;)NTE``MyH4X&bMjqObAB}k77-eHMoVkW zJl`n&zy}{u%rOeQAU>Z}VyOiaEw=J@vtUmWNS6N-zC4w(eP-Ll#2VfxzLvAd^}rH~ zUUkn&JlRpIzyIZ}FRrUG?f4-jyRWtM(O0&$99U7c>oxgZulQ^B{P>=eAHwZqz`p*iLEt^wkI$?r%2=ezcarK(NMlgRGaR`+!o;=#JKopK0*ch-b$D_e#Xusyp)w|pg1No$E_s0uNN~KO$#RMQ{xjqRfXTK05*FO-n+(<`&ayA8&)IA}+AlNud5JTvt1_6wO1VRT-Ft{Wo zpyn*WibQ!Df5s=jYo+yEQ68$bbN>i1Gj^J}(4|(K;uh%ZPU|ZJ#@4;RF zI%;I7t4#bL-m=eK{`mT%L(kl5{VG9?X&G(#n(Nz-t$%s__mc2=?FYBLh=gA}=skRP ztl{jAx37FQ;W+PnJ1 zyr`DR!(Ocfqy>8HHKtMs#F2?CdI_pHJ;IgAeB#aDjjVFL>mbQ0?Uhn)sYER@OyDMX zE0OYT`y@j3UMr^StGgz}<=JtiP&YsY+FXZZ;-PFk{OQCi*>x4qAp7KzomVDT6ZEwV zP;$K+I6X~VH~A}MY8732mCF;?KakI9 zqAz^jd0(1FS^4n=U7mxMnBG8tW-+0GQGR@Zt3?0!p7=b0>uG%9OmKjyljsdVY7_X#Y>JIjo+W*JBt`ZTI57fH)r&U`46Ttv=;m^RcizuvH z-(y2Hjnx{>AJlZURr8?|)izfmp}%lSi6_4d`d}|)h=-2kw@ls_%=m(I-Ji3#>2v8C zV3|%c|4%7AGnK-{Q0{_-1&_N9-10l-fn=<`@EwZz8Pu4dMn%>I@4Y4N%YD8p(tZxD zfwj8L<+edlW7bAhKBj>EiEd0=%hw7GSf?Co;Sm#4)x$vXZ;TCm9T#4EdwUF(d-_G6 zKF^}wqKbybBvI-wG)*0 z1hC+F-wB*~?HuhsC#nq|!}$qloj|Kkpz{;tE6}YzL5bT87jg07a9%sc&4-^eZo{(H z>ch7bB?8fyZM`oV@Y}dRY-HFP4GcFbR;ox{%8;`X zXO&vJ9i3K(x5MqOeT8gCb=y##X8%%f>s>iZSGLz8w$ZB)_&Uuy8pBxDwVeolc&Be! z@n3c6#`%h3E@!&2s@QkEk$9x|vFojq#Xp>G>?!Y1W zy|Yfd{^2FX`776M%7EXQo9h0Yd#xD5uc-LIR6&(K-5VN%(~ShQ9-VGMx+PiHnN znHd~blLm;F?`+;V{eIt+=5@`rDLMQOOldANIZJH-L;#N({=KhV7nfhcWz$ z4)2>PUS&w6c|z+E!;HqsnT=0oHp=s6kPVPV(hLtzzlUMGG_&#X%!b8?M!k3BnxP(P zb2}1HXVRQtRHV*i$j{YEg@)ogb7{UoGe1|Z&)Jh>7m%Si7mdC+Us1!?$d@8sbMjU+ z;{7senyzNrlJ%+LhjKL?3>qmkt;b)cN5%~8Ouj|ALDQ#ysHLF8h}L>Hph8D(RWB)d z+DNG?VXwyTDV(+#`=?yJz}stVv7C={Y3HD_biv4h*9sOG(1sUAkX9;rG;TmjebLf8 zBV>#73C&E%XKZqyE?D=MWxu>ap=hx?^5Je4E%^xcVPAWri?ia z+spzfAs@iv8^w7mrA(_U(AM#g7IkV^2C5O{5}sRxU|FDB1Ro%?=+uysAyY7F6;X>( zgDdPcs9xa71~jQI*amMA}{dlQRjNwbKs z^LjjS$#`PkF1m^Yu8o$rqPFH%RQEROAfdx0tGpaJG~!~Bc35rIlHln)-kA!O)jcFe zj$mfs#t^wVePYE)0V2$$9AV2POO70wD!GwSPQo3)(k?D6>sx$lx*ne_w={S&^DO(e z<#e5rqt^bcekfLaFLUjKO*$(2CKRth5jRsvkh-UAes;Mjh19>_2~~)NhksC(FT;Dp zUoR>$?x78*u;C||^|FeY=WL?UvQTlVC_f{gMSH}XEerC2@)$O}fZ2mG_AN|kQ|Dx& zqnzP>qDqpI!V8N`j4HntWoYw_FjoitjLZPN&t(|))9h6_`xqr2x3now@5L$hit@tw zKyVxzUc&4S1$#V`u%A9~A_5f8DN@9%%Zp2Zc!D&DK^I|%Zbi= zWrinY>@gGj=zPK^S*n*P|0m^dv7l-pvFtsx;eCp|w2(ctK%$(ZC?1kJPRvV*7^^BO zfR68D!-trCu$X>_@yQ zRnWr?;#<}V2p-H8YJlX|C5CeG87{|k-ct1bWl diff --git a/tests/test-data/esgf-catalog/obs4ref_catalog.parquet b/tests/test-data/esgf-catalog/obs4ref_catalog.parquet new file mode 100644 index 0000000000000000000000000000000000000000..e426e96601d5ee61fb242832c7d68d67b4d253a2 GIT binary patch literal 16326 zcmd5@Z)_XqdB3AbTeM|YmUT4MDCtGD7^N1;yOSuJf|L44i4qx!k|~Fhm@LeZJW-U6 zM}|jAqSFLPkp@9GbXf|dS+J!=Fm%Px4O!3+#ZUx62LxM?wsKfRAn|(<4bqDiz^b&K5Q2tYT@hs5c z0-*!=#}?#bO_s-9LzFm+Fmz%pR~xMJ>7ps4u(jr6+3lP}_sBv#3;Z zlERi{No5OCUHTG?@dx~wTIvxBI2&cy4$cO<*L%mgfPmnFf!>Ws(1|E#r*%>KT+e;~ zPe6PS<{~q(1WHAn56$6hk?5#cBcIE5x+#KgisdfdJkw9ey+JcS0^JM}g4vA|KyZFd zj7H;b_>>gaM1OK+Ek&Pk#^dzaD=~2ewowm8A^Fu;Rz8oN9SQ_bje0B{RS2937tr{#Bco$TnZ$9j)vbQW`M9b_ng&wE?^0i zxm!jD(MolhJ~t8K;2EXiIr`M}Dg1E2x2$c$j1#wc&vtr67eoEie+x*4pE?OUcWe&; zY4B~iwg+xgQ8f&Mf?O-@$pyd}X1pEVFkoKlJ=t1FfKFZ1ZB4rr1Q}y|OYnwvK6ecT zQzW&3l2-EIc{Ze7WWwvjbzKC%M+2o_)66g1mahF6=u%*2f3()DM3qw6A6tw1d9?0A zY<|uk@^kokaeZyhFZj9B^uTCkYpX1&e%^h@z|u-eqzArt23DlR)i^BSgegBSp`O?0 zqhd1Y=lvY2e|<&t3sW{4KcUZfhpbF=>HRsw5niB~mp}qNL&R?_o(KAxRF@S6jF+v+ z^;${ZlN5B2Vy#pt$c1c41;aK_R;?~+^-^9^{4%_kVH>ii&jWki31!H--g~x_Dq+)< zY?f)XTY`xhsb+kV1_&NASt!8KCn>>wa+(it=pv}F0591nrVrM7X}((tGb+#l`ahtV zAA%Bwh-cimbrq>=Y92FFu2EC#v6_@eM-&wWy(Pa^DJi%{%~eVMtko(gcv;)3mv7Sd zEf%X)^gE4=*0<%lQ~{<~=3>pES7!i_!j8({P-Gh`eNC%wpl z{lU-&!(MzLlsXTDcl(*|pD<+oOXn@f{1jyf2swC7t?aYW%AQ;3DjeZK=-2HP*$=$ptpoWFx39HZ2!HW_s;Sb^=t!MioiZWbNL& zfM8Ur3?pm_-YW$eFhQT}YNet)Fgr$##+1eZTWQqkrydsT<%(9_mH~9GA+z-g zo0RKpq~vDUQl4F{RSJ!~YyTx6xC9Z3Y2!wv4nm|FDnu3s&np<&>@p;eLdo@;i4~Bm z4m_6BqWe4STD>Mq<*kP5TA~s!ejAdE``g??tpdEQ*1(@@&BO~7^$`8_)!dE@9XF*ec0NL1VWnHCB_Q>^_7*En6M`?n@99Sxr((YN-xe>MXdvT99f5 zNSD?6Hmk@$OAFk1D@#q(<#JW7Nx+u8wSmW7x1$W0h4MaNR-vIQv`#N&=^AY73mutl zX{@)WJx?>wwlmve0mBQJL|?%88%KBFEwW|5K!0&`wa7MWe*P50Jj!Z*9{qN0552Rh zH84J)9>Dsd`xNhkX)itA9h7gqryo(E1tVDxUPVy+3F~jDrH*63+i%Qx`;E08YQkoe zz#X0oPhlFr{VjF2Fo}!O8J6 z0gAm6;{4oYKPSuvDCZT94$RI|UV-DL{oFKt!But6axTp&aC8JW&1lrMD_0IF196VC z(aw3G07QWSM1(c)>pjo6!PR7xB*FGnc>wm%wf#X(u0!3{$R4 zhvo~U4<%-XU6=a9V9fNLW%`Gdka+sgq?cSj1C!F128l@b|D5RNNhEyd&p{`NG&O*d zxt_ky?;4)&l+VV{_t+R3B?+~y(lY)%Hpa(=EhBxRL;S@Tm=F7n%>UlWTk#4g@o_A- zvxQRaiD0Xn2qsfOYyA*}z99%_Vz8(N_u!xnaUnj816i2J#yINXY-B!LRI?a2;I(K9 zZYH4SpGa%e;#_5ZZOsqGY0xNyf^>DU``il_Ypy2-XE4`5a_E>JM{M?(+lLt%K~j33utDnk5y2 zn$M*lrM_mA%E3jW2=jBnW-SPZdazgy?%kal;CcQseQqGztYz=K$1M$)vuJ|5;}o~G zMNQ}xb`a|}mo$p{DMKL;_whUb$&*+OU4Qk=oFwY6oL2Z1_ z8O^#c-dUtV<55buH%jfkQKSY*buzn4xrgb0ezIbBF6C^T=aaOGu7hetHKSfl%Gd3z z>zMd8SlMfbVNyJn#KbjG^@qM0<^*C4;zDR3T&L3~9?Iz+DiBX1Imfo#`ZJoT8%%VnX?=1t?m<@nW1|UQOtrkr0%L7F<&=w<}Sf%FLEos1rPFMWV z?%;&hdbb=X)&lhMFyTCwSf;RpO|C-x1&sI$Y#jth{5|p=Y5??dUm`(0XE;T`WbHt+ z762azV3QaqmR)~1O#EUtAySjNfdj;F^wqoX@{+&`ar8g(9v~{*pSYj82B8$_Xq4R7 z^jMmCih`r|tBHsyVgjv*`NlAWN`U9?{|1zzi753oBR)t+9nj+g^%f0il>oH2vmerf zXvBrr7g{0l$OCqbgGv3WA0N*91Y-s__V3-h;(aU`)xPTbO zhJzyaMP8bQU#N-5JK5Afs<#;b12xdd$KDX-J)z~d<+8*CC}v>X;XRFQ&mL5;*-~Mg zS{en%BjfI^n*3NpR`X5ss1sW`^S~(_$SP7!R?Nd^p+UskAS^_?2oI^1atZ2dXgoEz z^}rS8ohPfL;;);>Tu{O*W%Gzzt2Cfe?IL};HCk_0W%FvLH4cq|h7752*CN-Kyo#p8PE*MPSEccup7btIji>rx^_=NuTZOJp`q^B z-Y=^K%it+om90r?v16^XD%H0QB(&tomU!l|(`W}t?a&kjYu#IDf|4c|D9_Ny=*Y(x zeAFm2FcP9p432d81mo}wd0wPFuh72xef_}Xw=k5M z@}XA|x<8;juhG7bY2Wv0qGS<+(D&h4BzpS`H!DUDuFbiLz`W`3 zyzTJ4a^Cmv=Md)8U7T6rUU1mTWS{bMVA}I0?R$aoy*7-%hxcYy;BbeB0r9 z&*A&Y1>Y@(XzVtZURI1pZ816%FF|?I^DOPV<@Np4OV~Eue_4szXN$v}O$dH;%JVM7 z73=$l5v2IJUcp#de74_KaQy@!{C(5mc?Q(_W#5ZqMB?^cij`QQgO;0~2tf8+H0RiE#_?(y9kM(K&f4n2sELR=UUPW<$l-hatG@5ukLu{v zpjnB(YJU)!iz@=~%K^_v{l52Oz8_vgfdA8e!79b;?hX}EBXGkzVyXm*2x(8o_e%V# zrIQ#Lg~|>KysPb~_x$Q1&ycU*b1P1bOnOgL@$y^q{;}urv65Pl53+guo2IIb&9V>u z!6ZATKf+&@U{3BDC6AAfVKoiQ$4o8B80;h$@TAnXnbcvkSn}g+h1}xfE-3P=&Gxuf zui;=B{$_!>+Bq;jvMxf zp_kP9%rtK7JmA{Hw>#nvZ=I~jopW8FpW#?FuC%O(=9#0zJG?EkB6iLl9VTAIS<#Hq zqr+*X87q`&;^@#!cZXKQuE{P`2%S89?E|-Uy6K@K-Of+jx06=fj;Sux9DKdR3TcdX z17cAs)#1xexkjB7q>n>6IF^SqvtFq|f%y2CQp(lf3nExoR+|$1^=Tie8@h&30lsq- z;Ky)Zixt!46wFPHJwbT~#8hf!eOAAB_rCL$Ao&qjMNzyC|M2GlQJfYdMKLCdMKM|= zW06S5{5}rOx98`tFX_+lN~n*TE63`Y zwfGFoZEsGcs)e$`$?0%46OH5gvb3JoGO=)DbDdX^PP`o3DJ&?0w2=yJ2+D4}1ej`M z8?d(X@TX*XBhp$IE=lF|PC=*BD6I4QK=*K7O>+4X!Ki08Qrnq@7`KUhEQI*n!a;Z` zigJdwbL$U+Y+@;+CY1a}TFI4@A%a=XR5R-byBor7)*J_4Q;FN?s?e0yua}mhVky56 z1DnNeZcL>@`2}63oRD}dv!3L1c|h)sNXR^&pGr0LIbJC&#CA79PiTXjn$~iQV9&)4 z`>FY|0{S@E-YloJo;pH)V_|J`J)z`u+w5)}S*Z)|KRfp3C; zafOXkH7A6&j!}P&oWMiO#I%hm@HHW<={!smIWuKN$t|R|O!0Et^~YfE{d_sRTWI@_ zH6HSdiM`BXI|e_0J41|at{>EU`Ch)9-p(y>#}yAZb5rT&rjTy?kc|y3YmUKgZOm4@ z+2>g`xmVao>|~6bc}#g0{GQL3lgh?aM#-y55`!53kW*T@$i~k;Y0oj_;X*kExi(hH zE!@`L!sbSz3b{`My&8G@7~&$c4Y_$cr$YW%h?O$y`@M2Xi@y-pk;jigf4PNlBg41O zdGs}#+-zBM415*VLvU^^_tF=igL)<3Bz44b*b!nPRo#SdRf2t#0-UogY-^pQV*J>y zwd1pzQ?CojUbPa`dxg2pR-N#vY5_PyW6eS=4CgJ>Z9VJ>XYw}ixCQ4e)_|CY?07rk zS|Htk`s22IWuP8`I;e`Z-!aurgdaHfp^h!|)ECiHI^2YMNj)}wL5vj^ioN`QA8Nu1 zvF~xjBAfwvLD|dcb$bt=$1_8(nU3Ksg}6yV{Tae@(#Yplz94?k*P(Y%+O?;hzSML> zT2Jm}pgu>xv#EPKW8qWPz8O+eN*hU~cTZ(mV;6^|9Mq(GEL(ep&2o6JRnOb@Q+jV0 z;|hy#{;6p$vq5^s%UG*J&)CeY$GF^79BX*!yAEJY0&>GH>Aj%#htG+9i=hu(Ou z2<@+^iOZt6xIYK|?mRv(;Pc9UR1~9Eur1!f_BbZOGd#ibWo)aL_MuIVUPn?l@Hv9d za~jg$!B%2oj*~E&JS+XNiDW@C}wc|%$$Mu zppFNEcinhOVm;pO0#ZVyqnM+JA9?16VKBAmTV;CTmZ z0yR}yfV+U3=nCL4;23%07`P6d1b=qA__634d0&M1-$(%+QIv80vj${%Gl&1f_}J6(nkNuK`H7|B~3X-!?uo=6f5UmxmY&&UEn)myhss z$UpJ72)P%+2{EyQP!E0ro}TYa_iecK0Pzk;G=Wk!F+V{(}EOGW(fpC#dc G@c%C(Y#VF< literal 0 HcmV?d00001 From 9f5c790421864c17ff13f4c182151988a03270df Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 17:12:16 +1000 Subject: [PATCH 09/64] chore: add changelog fragment --- changelog/898.feature.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog/898.feature.md diff --git a/changelog/898.feature.md b/changelog/898.feature.md new file mode 100644 index 000000000..3a79f6399 --- /dev/null +++ b/changelog/898.feature.md @@ -0,0 +1,8 @@ +Ingests the obs4REF collection under its own `obs4ref` source type, +so its `instance_id` starts with `obs4REF` and it is never mistaken for data fetched from the obs4MIPs archive. +The diagnostics keep asking for obs4MIPs data. +Where a dataset is ingested from both, the obs4MIPs copy is used and obs4REF fills in the rest, +so publishing a dataset to obs4MIPs takes over from the registry copy without any change to the REF. +`ref doctor` gains three checks: `misfiled-obs4ref`, `superseded-obs4ref` and `unsolvable-diagnostics`. +Data ingested with `--source-type obs4mips` in earlier releases still solves, +but re-ingesting it with `--source-type obs4ref` is recommended. From ad7e5029ab2a8979fe9ad706e5c0fae7556ecce8 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 17:21:12 +1000 Subject: [PATCH 10/64] fix: narrow the misfiled obs4REF check and keep the merged catalog finaliseable The misfiled check flagged any dataset an obs4REF registry carries, so a CERES-EBAF, GPCP, HadISST or TropFlux copy correctly fetched from ESGF was reported as needing a re-ingest. It now looks only at the directory layout the registry actually produces. The merged obs4MIPs catalog dropped its adapter and database, so an unfinalised dataset could no longer be finalised. It now carries them through, and ref doctor solves against the same catalogs the solver would use. --- docs/getting-started/02-download-datasets.md | 2 +- docs/getting-started/quickstart.md | 2 +- docs/how-to-guides/diagnose-a-deployment.md | 16 +++++------ .../src/climate_ref_ilamb/standard.py | 4 +-- .../src/climate_ref/datasets/obs4mips.py | 21 +++++++++++++- .../src/climate_ref/doctor/checks/data.py | 25 ++++++----------- .../src/climate_ref/doctor/context.py | 28 +++++++++++++++++++ .../climate-ref/src/climate_ref/solver.py | 11 +++++--- .../climate-ref/tests/unit/test_doctor.py | 8 +++--- .../climate-ref/tests/unit/test_solver.py | 14 ++++++++-- 10 files changed, 91 insertions(+), 40 deletions(-) diff --git a/docs/getting-started/02-download-datasets.md b/docs/getting-started/02-download-datasets.md index ec67b64e5..521bc1761 100644 --- a/docs/getting-started/02-download-datasets.md +++ b/docs/getting-started/02-download-datasets.md @@ -112,7 +112,7 @@ The script also fetches `CERES-EBAF-4-2`, `GPCP-Monthly-3-2`, `HadISST-1-1` and These are the ESGF-published copies of datasets that were curated for the REF before publication, so the obs4REF registry ships them as well. -If you fetch these from ESGF as well as from the obs4REF registry, the ESGF copy is the one used. +If you fetch the same dataset from ESGF as well as from the obs4REF registry, the ESGF copy is the one used. obs4MIPs is the official home of the reference data, and the registry only fills in what is not published yet. `ref doctor` lists the registry copies that have been superseded this way. /// diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 2a870c622..8a510588a 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -105,7 +105,7 @@ ref datasets fetch-data --registry quickstart --output-directory $REF_CONFIGURAT ## 4. Ingest (~20 s) Extract metadata from the downloaded files into the local catalog. -The model data is CMIP6; the observation uses the `obs4mips` source type: +The model data is CMIP6. The observation comes from the obs4REF registry, so it is ingested under the `obs4ref` source type. ```bash ref datasets ingest --source-type cmip6 $REF_CONFIGURATION/datasets/sample-data/CMIP6 diff --git a/docs/how-to-guides/diagnose-a-deployment.md b/docs/how-to-guides/diagnose-a-deployment.md index d1abcc5c8..15ce6610e 100644 --- a/docs/how-to-guides/diagnose-a-deployment.md +++ b/docs/how-to-guides/diagnose-a-deployment.md @@ -1,13 +1,13 @@ # Diagnose a deployment -`ref doctor` looks for the problems that a solve hides rather than reports: - -- reference data that is missing, so its diagnostics quietly plan no executions. -- data ingested under a source type no diagnostic requires, so nothing selects it. -- obs4REF data ingested as `obs4mips`, so the catalog cannot say where it came from. -- obs4REF datasets that obs4MIPs has since published, so the registry copy is no longer used. -- datasets whose files cover the same period twice, so a diagnostic reads that period more than once. -- diagnostics the ingested data cannot solve at all, with the requirement that goes unmet. +`ref doctor` looks for the problems that a solve hides rather than reports. + +- Reference data is missing, so its diagnostics quietly plan no executions. +- Data is ingested under a source type no diagnostic requires, so nothing selects it. +- obs4REF data is ingested as `obs4mips`, so the catalog cannot say where it came from. +- An obs4REF dataset has since been published to obs4MIPs, so the registry copy is no longer used. +- A dataset's files cover the same period twice, so a diagnostic reads that period more than once. +- A diagnostic cannot be solved at all by the ingested data, and the finding names the requirement that goes unmet. ```bash ref doctor diff --git a/packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py b/packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py index 3c3e9431c..d6c16b8e4 100644 --- a/packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py +++ b/packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py @@ -951,9 +951,7 @@ def __init__( # noqa: PLR0915, PLR0912 # resolve to keys in one of its data registries. If instead we find a # dictionary, then assume that these keys are meant to be keywords in a # REF data requirement. - # obs_source names where the test data is fetched from: - # "obs4ref" for the obs4REF registry, "obs4mips" for ESGF. - # The requirement itself asks for obs4MIPs, and obs4REF fills in what ESGF lacks. + # obs_source names where the test data is fetched from, the registry or ESGF. filters: dict[str, tuple[str, ...]] = {} obs_source = None for _, source in sources.items(): diff --git a/packages/climate-ref/src/climate_ref/datasets/obs4mips.py b/packages/climate-ref/src/climate_ref/datasets/obs4mips.py index 2b7dbb0f5..95bab928d 100644 --- a/packages/climate-ref/src/climate_ref/datasets/obs4mips.py +++ b/packages/climate-ref/src/climate_ref/datasets/obs4mips.py @@ -20,6 +20,25 @@ from climate_ref.models.dataset import Dataset, Obs4MIPsDataset, Obs4REFDataset +def in_collection_directory(paths: pd.Series, activity_id: str) -> pd.Series: + """ + Whether each file sits under the named collection's directory. + + Parameters + ---------- + paths + File paths from a catalog. + activity_id + The collection to look for, ``obs4MIPs`` or ``obs4REF``. + + Returns + ------- + : + A boolean mask over ``paths``. + """ + return paths.astype(str).str.contains(f"/{activity_id}/", regex=False) + + def parse_obs4mips(file: str, **kwargs: Any) -> dict[str, Any]: """ Parser for obs4MIPs and obs4REF files @@ -213,7 +232,7 @@ def _warn_if_misfiled(self, datasets: pd.DataFrame) -> None: or activity id is only a hint. The files are ingested either way. """ other = "obs4REF" if self.activity_id == "obs4MIPs" else "obs4MIPs" - misfiled = datasets["path"].astype(str).str.contains(f"/{other}/", regex=False) + misfiled = in_collection_directory(datasets["path"], other) if other == "obs4REF": misfiled |= datasets["activity_id"] == other count = int(misfiled.sum()) diff --git a/packages/climate-ref/src/climate_ref/doctor/checks/data.py b/packages/climate-ref/src/climate_ref/doctor/checks/data.py index 3fedf533d..a9ee9e830 100644 --- a/packages/climate-ref/src/climate_ref/doctor/checks/data.py +++ b/packages/climate-ref/src/climate_ref/doctor/checks/data.py @@ -14,6 +14,7 @@ from collections.abc import Mapping from climate_ref.data_catalog import DataCatalog +from climate_ref.datasets.obs4mips import in_collection_directory from climate_ref.doctor.context import DoctorContext from climate_ref.doctor.findings import Finding, Severity from climate_ref.doctor.registry import check @@ -252,8 +253,10 @@ def check_misfiled_obs4ref(context: DoctorContext) -> list[Finding]: The cost is that the catalog no longer shows which datasets came from the registry and which from the archive, and a later obs4MIPs publication cannot take over from it. - A dataset counts as obs4REF when an obs4REF registry carries its ``source_id`` - or its files sit under an ``obs4REF`` directory. + A dataset counts as obs4REF when its files sit under an ``obs4REF`` directory, + which is how the registry lays them out. + Carrying a ``source_id`` the registry also carries is not enough, + because the four datasets published to both archives are legitimately ingested as obs4MIPs. Parameters ---------- @@ -266,17 +269,10 @@ def check_misfiled_obs4ref(context: DoctorContext) -> list[Finding]: One finding when any such data is present. """ catalog = context.catalog(SourceDatasetType.obs4MIPs) - if not len(catalog) or not {"instance_id", "source_id", "path"}.issubset(catalog.columns): + if not len(catalog) or not {"instance_id", "path"}.issubset(catalog.columns): return [] - registry_ids = { - source_id - for (source_type, source_id) in source_ids_by_registry() - if source_type == SourceDatasetType.obs4REF.value - } - misfiled = catalog["source_id"].isin(registry_ids) | catalog["path"].astype(str).str.contains( - "/obs4REF/", regex=False - ) + misfiled = in_collection_directory(catalog["path"], "obs4REF") if not misfiled.any(): return [] @@ -363,7 +359,7 @@ def check_unsolvable_diagnostics(context: DoctorContext) -> list[Finding]: from climate_ref.solver import solve_executions # noqa: PLC0415 catalogs: dict[SourceDatasetType, DataCatalog] = { - source_type: DataCatalog.from_frame(context.catalog(source_type)) for source_type in SourceDatasetType + source_type: context.data_catalog(source_type) for source_type in SourceDatasetType } findings = [] @@ -380,10 +376,7 @@ def check_unsolvable_diagnostics(context: DoctorContext) -> list[Finding]: severity=Severity.WARNING, summary=f"{provider.slug}/{diagnostic.slug} has no executions", detail=_why_unsolvable(diagnostic, catalogs), - remedy=( - "Ingest the data each diagnostic asks for. " - "The findings above list the missing reference data." - ), + remedy="Ingest the data the unmet requirement names, then run the solver again.", ) ) return findings diff --git a/packages/climate-ref/src/climate_ref/doctor/context.py b/packages/climate-ref/src/climate_ref/doctor/context.py index 59183a44a..1162191ad 100644 --- a/packages/climate-ref/src/climate_ref/doctor/context.py +++ b/packages/climate-ref/src/climate_ref/doctor/context.py @@ -3,6 +3,7 @@ """ from collections.abc import Iterable +from typing import TYPE_CHECKING import pandas as pd from attrs import define, field @@ -13,6 +14,9 @@ from climate_ref_core.providers import DiagnosticProvider from climate_ref_core.source_types import SourceDatasetType +if TYPE_CHECKING: + from climate_ref.data_catalog import DataCatalog + EMPTY_CATALOG = pd.DataFrame() """Stands in for a source type with nothing ingested.""" @@ -74,6 +78,30 @@ def providers(self) -> list[DiagnosticProvider]: self._providers = list(registry.providers) return self._providers + def data_catalog(self, source_type: SourceDatasetType) -> "DataCatalog": + """ + Load the ingested catalog as the solver sees it. + + The adapter and database are carried through where there are any, + so an unfinalised dataset can still be finalised during a solve. + + Parameters + ---------- + source_type + The source type to load. + + Returns + ------- + : + A catalog the solver can be run against. + """ + from climate_ref.data_catalog import DataCatalog # noqa: PLC0415 + + frame = self.catalog(source_type) + if self.database is None: + return DataCatalog.from_frame(frame) + return DataCatalog(database=self.database, adapter=get_dataset_adapter(source_type.value), df=frame) + def catalog(self, source_type: SourceDatasetType) -> pd.DataFrame: """ Load the ingested catalog for a source type, one row per file. diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py index aa32edf17..390104f3a 100644 --- a/packages/climate-ref/src/climate_ref/solver.py +++ b/packages/climate-ref/src/climate_ref/solver.py @@ -45,8 +45,6 @@ from climate_ref_core.exceptions import InvalidDiagnosticException from climate_ref_core.providers import DiagnosticProvider -_EMPTY_CATALOG = pd.DataFrame() - @frozen class DiagnosticExecution: @@ -286,7 +284,12 @@ def with_obs4ref_fallback( return obs4mips if obs4mips_df.empty: return obs4ref - return DataCatalog.from_frame(pd.concat([obs4mips_df, extra])) + + merged = pd.concat([obs4mips_df, extra], ignore_index=True) + if isinstance(obs4mips, DataCatalog): + # Keep the obs4MIPs adapter and database so the merged catalog can still finalise. + return DataCatalog(database=obs4mips.database, adapter=obs4mips.adapter, df=merged) + return merged def obs_dataset_key(instance_id: pd.Series) -> pd.Series: @@ -329,7 +332,7 @@ def apply_obs4ref_fallback( """ if SourceDatasetType.obs4REF not in data_catalog: return data_catalog - obs4mips = data_catalog.get(SourceDatasetType.obs4MIPs, _EMPTY_CATALOG) + obs4mips = data_catalog.get(SourceDatasetType.obs4MIPs, pd.DataFrame()) return { **data_catalog, SourceDatasetType.obs4MIPs: with_obs4ref_fallback(obs4mips, data_catalog[SourceDatasetType.obs4REF]), diff --git a/packages/climate-ref/tests/unit/test_doctor.py b/packages/climate-ref/tests/unit/test_doctor.py index 38d0c326b..bc14cd208 100644 --- a/packages/climate-ref/tests/unit/test_doctor.py +++ b/packages/climate-ref/tests/unit/test_doctor.py @@ -281,14 +281,14 @@ def test_obs4ref_layout_under_obs4mips_is_reported(self): assert "ERA-5" not in findings[0].detail assert findings[0].command == "ref datasets ingest --source-type obs4ref " - def test_registry_source_id_under_obs4mips_is_reported(self): - # WECANN-1-0 is carried by the obs4REF registry, whatever directory it was ingested from. + def test_registry_source_id_from_esgf_is_not_reported(self): + # HadISST-1-1 sits in both collections, so an ESGF copy is legitimately ingested as obs4MIPs. catalog = _catalog( - [("obs4MIPs.obs4MIPs.WECANN.gpp", "WECANN-1-0", "gpp", "2007-01-01", "2015-12-01", "/d/gpp.nc")] + [("obs4MIPs.obs4MIPs.HadISST.ts", "HadISST-1-1", "ts", "2000-01-01", "2000-12-01", "/d/ts.nc")] ) context = _context({SourceDatasetType.obs4MIPs: catalog}) - assert len(check_misfiled_obs4ref(context)) == 1 + assert check_misfiled_obs4ref(context) == [] def test_esgf_data_is_not_reported(self): catalog = _catalog([("obs4MIPs.ERA-5.ta", "ERA-5", "ta", "2000-01-01", "2000-12-01", "/d/ta.nc")]) diff --git a/packages/climate-ref/tests/unit/test_solver.py b/packages/climate-ref/tests/unit/test_solver.py index 706030663..4f6520069 100644 --- a/packages/climate-ref/tests/unit/test_solver.py +++ b/packages/climate-ref/tests/unit/test_solver.py @@ -1975,13 +1975,23 @@ def test_missing_datasets_are_added(self): obs4mips = self._frame("obs4MIPs", ["A"]) obs4ref = self._frame("obs4REF", ["A", "B"], start=10) - merged = with_obs4ref_fallback(obs4mips, obs4ref).to_frame() + merged = with_obs4ref_fallback(obs4mips, obs4ref) # A is taken from obs4MIPs only, B comes from obs4REF and keeps its dataset id. assert merged["source_id"].tolist() == ["A", "B"] - assert merged.index.tolist() == [0, 11] + assert merged.index.tolist() == [0, 1] assert merged["instance_id"].iloc[1].startswith("obs4REF.") + def test_the_merged_catalog_can_still_finalise(self): + adapter = CMIP6DatasetAdapter() + obs4mips = DataCatalog(database=None, adapter=adapter, df=self._frame("obs4MIPs", ["A"])) + obs4ref = DataCatalog.from_frame(self._frame("obs4REF", ["B"])) + + merged = with_obs4ref_fallback(obs4mips, obs4ref) + + assert isinstance(merged, DataCatalog) + assert merged.adapter is adapter + def test_obs4mips_wins_whatever_the_versions(self): obs4mips = self._frame("obs4MIPs", ["A"], version="v1") obs4ref = self._frame("obs4REF", ["A"], version="v2", start=10) From a3be335984642807aad84d7ee9ae7cfc1d00a4f0 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 17:28:45 +1000 Subject: [PATCH 11/64] refactor: tidy up the obs4REF fallback call sites Shares one helper for unwrapping a catalog, hoists the solver imports in the doctor checks now that there is no cycle to dodge, and merges the catalogs once per unsolvable-diagnostics run rather than once per diagnostic explained. Also promotes normalize_requirement_sets, which the doctor checks now use. --- .../src/climate_ref_core/summary.py | 4 +-- .../tests/unit/test_summary.py | 10 +++---- .../src/climate_ref/datasets/obs4mips.py | 1 + .../src/climate_ref/doctor/checks/data.py | 29 ++++++++++--------- .../climate-ref/src/climate_ref/solver.py | 21 ++++++++++++-- 5 files changed, 43 insertions(+), 22 deletions(-) diff --git a/packages/climate-ref-core/src/climate_ref_core/summary.py b/packages/climate-ref-core/src/climate_ref_core/summary.py index da43c4402..6ffdb6dac 100644 --- a/packages/climate-ref-core/src/climate_ref_core/summary.py +++ b/packages/climate-ref-core/src/climate_ref_core/summary.py @@ -105,7 +105,7 @@ def _extract_facet_values(filters: tuple[FacetFilter, ...], facet_name: str) -> return tuple(sorted(values)) -def _normalize_requirement_sets( +def normalize_requirement_sets( data_requirements: Sequence[DataRequirement] | Sequence[Sequence[DataRequirement]], ) -> list[Sequence[DataRequirement]]: """ @@ -179,7 +179,7 @@ def summarize_diagnostic(diagnostic: Diagnostic) -> DiagnosticSummary: : A DiagnosticSummary with all requirement sets summarized. """ - requirement_sets = _normalize_requirement_sets(diagnostic.data_requirements) + requirement_sets = normalize_requirement_sets(diagnostic.data_requirements) set_summaries = [] for req_set in requirement_sets: diff --git a/packages/climate-ref-core/tests/unit/test_summary.py b/packages/climate-ref-core/tests/unit/test_summary.py index 194ba496a..64e04071f 100644 --- a/packages/climate-ref-core/tests/unit/test_summary.py +++ b/packages/climate-ref-core/tests/unit/test_summary.py @@ -10,12 +10,12 @@ from climate_ref_core.summary import ( DiagnosticReference, _extract_facet_values, - _normalize_requirement_sets, collect_by_source_type, collect_variables_by_experiment, format_diagnostic_markdown, format_overview_markdown, format_provider_markdown, + normalize_requirement_sets, summarize_data_requirement, summarize_diagnostic, summarize_provider, @@ -206,7 +206,7 @@ def test_flat_requirements(self): group_by=None, ), ) - result = _normalize_requirement_sets(reqs) + result = normalize_requirement_sets(reqs) assert len(result) == 1 assert len(result[0]) == 1 assert isinstance(result[0][0], DataRequirement) @@ -229,18 +229,18 @@ def test_nested_or_requirements(self): ), ), ) - result = _normalize_requirement_sets(reqs) + result = normalize_requirement_sets(reqs) assert len(result) == 2 assert result[0][0].source_type == SourceDatasetType.CMIP6 assert result[1][0].source_type == SourceDatasetType.CMIP7 def test_empty_requirements(self): - result = _normalize_requirement_sets(()) + result = normalize_requirement_sets(()) assert result == [] def test_invalid_type_raises(self): with pytest.raises(TypeError, match="Expected DataRequirement"): - _normalize_requirement_sets((42,)) + normalize_requirement_sets((42,)) class TestSummarizeDataRequirement: diff --git a/packages/climate-ref/src/climate_ref/datasets/obs4mips.py b/packages/climate-ref/src/climate_ref/datasets/obs4mips.py index 95bab928d..be49e9bb8 100644 --- a/packages/climate-ref/src/climate_ref/datasets/obs4mips.py +++ b/packages/climate-ref/src/climate_ref/datasets/obs4mips.py @@ -234,6 +234,7 @@ def _warn_if_misfiled(self, datasets: pd.DataFrame) -> None: other = "obs4REF" if self.activity_id == "obs4MIPs" else "obs4MIPs" misfiled = in_collection_directory(datasets["path"], other) if other == "obs4REF": + # An obs4MIPs attribute on an obs4REF file is expected, the reverse is not. misfiled |= datasets["activity_id"] == other count = int(misfiled.sum()) if count: diff --git a/packages/climate-ref/src/climate_ref/doctor/checks/data.py b/packages/climate-ref/src/climate_ref/doctor/checks/data.py index a9ee9e830..b36e65687 100644 --- a/packages/climate-ref/src/climate_ref/doctor/checks/data.py +++ b/packages/climate-ref/src/climate_ref/doctor/checks/data.py @@ -13,11 +13,20 @@ from collections import defaultdict from collections.abc import Mapping +import pandas as pd + from climate_ref.data_catalog import DataCatalog from climate_ref.datasets.obs4mips import in_collection_directory from climate_ref.doctor.context import DoctorContext from climate_ref.doctor.findings import Finding, Severity from climate_ref.doctor.registry import check +from climate_ref.solver import ( + apply_obs4ref_fallback, + as_frame, + extract_covered_datasets, + obs_dataset_key, + solve_executions, +) from climate_ref.text import pluralise from climate_ref_core.diagnostics import Diagnostic from climate_ref_core.exceptions import InvalidDiagnosticException @@ -28,7 +37,7 @@ source_ids_by_registry, ) from climate_ref_core.source_types import SourceDatasetType -from climate_ref_core.summary import _normalize_requirement_sets, summarize_provider +from climate_ref_core.summary import normalize_requirement_sets, summarize_provider @check( @@ -312,8 +321,6 @@ def check_superseded_obs4ref(context: DoctorContext) -> list[Finding]: : One finding per superseded obs4REF dataset. """ - from climate_ref.solver import obs_dataset_key # noqa: PLC0415 - obs4mips = context.catalog(SourceDatasetType.obs4MIPs) obs4ref = context.catalog(SourceDatasetType.obs4REF) if not len(obs4mips) or not len(obs4ref) or "instance_id" not in obs4mips or "instance_id" not in obs4ref: @@ -356,12 +363,12 @@ def check_unsolvable_diagnostics(context: DoctorContext) -> list[Finding]: : One finding per diagnostic with no executions. """ - from climate_ref.solver import solve_executions # noqa: PLC0415 - catalogs: dict[SourceDatasetType, DataCatalog] = { source_type: context.data_catalog(source_type) for source_type in SourceDatasetType } + available = apply_obs4ref_fallback(catalogs) + findings = [] for provider in context.providers: for diagnostic in provider.diagnostics(): @@ -375,7 +382,7 @@ def check_unsolvable_diagnostics(context: DoctorContext) -> list[Finding]: Finding( severity=Severity.WARNING, summary=f"{provider.slug}/{diagnostic.slug} has no executions", - detail=_why_unsolvable(diagnostic, catalogs), + detail=_why_unsolvable(diagnostic, available), remedy="Ingest the data the unmet requirement names, then run the solver again.", ) ) @@ -384,7 +391,7 @@ def check_unsolvable_diagnostics(context: DoctorContext) -> list[Finding]: def _why_unsolvable( diagnostic: Diagnostic, - catalogs: Mapping[SourceDatasetType, DataCatalog], + available: Mapping[SourceDatasetType, pd.DataFrame | DataCatalog], ) -> str: """ Explain which requirement the ingested data fails to meet. @@ -393,15 +400,11 @@ def _why_unsolvable( the data to fetch. When every requirement matches something, the failure lies in how they combine, which is reported as such. """ - from climate_ref.solver import apply_obs4ref_fallback, extract_covered_datasets # noqa: PLC0415 - - available = apply_obs4ref_fallback(catalogs) - reasons = [] - for requirements in _normalize_requirement_sets(diagnostic.data_requirements): + for requirements in normalize_requirement_sets(diagnostic.data_requirements): for requirement in requirements: catalog = available[requirement.source_type] - frame = catalog.to_frame() if isinstance(catalog, DataCatalog) else catalog + frame = as_frame(catalog) if not len(frame): reasons.append(f"nothing is ingested as {requirement.source_type.value}") break diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py index 390104f3a..03fd773c1 100644 --- a/packages/climate-ref/src/climate_ref/solver.py +++ b/packages/climate-ref/src/climate_ref/solver.py @@ -248,6 +248,23 @@ def _process_group_constraints( return group +def as_frame(catalog: pd.DataFrame | DataCatalog) -> pd.DataFrame: + """ + Unwrap a catalog to the DataFrame behind it. + + Parameters + ---------- + catalog + Either a catalog wrapper or a plain frame. + + Returns + ------- + : + The catalog as a DataFrame. + """ + return catalog.to_frame() if isinstance(catalog, DataCatalog) else catalog + + def with_obs4ref_fallback( obs4mips: pd.DataFrame | DataCatalog, obs4ref: pd.DataFrame | DataCatalog, @@ -273,10 +290,10 @@ def with_obs4ref_fallback( The obs4MIPs catalog, extended with the obs4REF datasets it does not hold. The original catalog is returned untouched when there is nothing to add. """ - obs4ref_df = obs4ref.to_frame() if isinstance(obs4ref, DataCatalog) else obs4ref + obs4ref_df = as_frame(obs4ref) if obs4ref_df.empty or "instance_id" not in obs4ref_df.columns: return obs4mips - obs4mips_df = obs4mips.to_frame() if isinstance(obs4mips, DataCatalog) else obs4mips + obs4mips_df = as_frame(obs4mips) held = set(obs_dataset_key(obs4mips_df["instance_id"])) if len(obs4mips_df) else set() extra = obs4ref_df[~obs_dataset_key(obs4ref_df["instance_id"]).isin(held)] From 4c495b76d12ab94e925e67507b3a3f53dd97b8c1 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 17:44:06 +1000 Subject: [PATCH 12/64] fix: keep the merged obs4REF catalog from reloading itself Carrying the obs4MIPs adapter through the merge made the catalog reloadable, and a reload would go back to that adapter alone and drop every obs4REF row just merged in. The merge now carries no adapter, so it cannot reload. --- .../climate-ref/src/climate_ref/doctor/checks/data.py | 2 ++ .../climate-ref/src/climate_ref/doctor/context.py | 9 ++------- packages/climate-ref/src/climate_ref/solver.py | 7 ++++--- packages/climate-ref/tests/unit/test_solver.py | 11 +++++++---- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/climate-ref/src/climate_ref/doctor/checks/data.py b/packages/climate-ref/src/climate_ref/doctor/checks/data.py index b36e65687..6e5b3e9ee 100644 --- a/packages/climate-ref/src/climate_ref/doctor/checks/data.py +++ b/packages/climate-ref/src/climate_ref/doctor/checks/data.py @@ -266,6 +266,8 @@ def check_misfiled_obs4ref(context: DoctorContext) -> list[Finding]: which is how the registry lays them out. Carrying a ``source_id`` the registry also carries is not enough, because the four datasets published to both archives are legitimately ingested as obs4MIPs. + The file's own ``activity_id`` is stamped from the source type at ingest, + so only the ingest-time warning can use it. Parameters ---------- diff --git a/packages/climate-ref/src/climate_ref/doctor/context.py b/packages/climate-ref/src/climate_ref/doctor/context.py index 1162191ad..e8ca97d6b 100644 --- a/packages/climate-ref/src/climate_ref/doctor/context.py +++ b/packages/climate-ref/src/climate_ref/doctor/context.py @@ -3,20 +3,17 @@ """ from collections.abc import Iterable -from typing import TYPE_CHECKING import pandas as pd from attrs import define, field from climate_ref.config import Config +from climate_ref.data_catalog import DataCatalog from climate_ref.database import Database from climate_ref.datasets import get_dataset_adapter from climate_ref_core.providers import DiagnosticProvider from climate_ref_core.source_types import SourceDatasetType -if TYPE_CHECKING: - from climate_ref.data_catalog import DataCatalog - EMPTY_CATALOG = pd.DataFrame() """Stands in for a source type with nothing ingested.""" @@ -78,7 +75,7 @@ def providers(self) -> list[DiagnosticProvider]: self._providers = list(registry.providers) return self._providers - def data_catalog(self, source_type: SourceDatasetType) -> "DataCatalog": + def data_catalog(self, source_type: SourceDatasetType) -> DataCatalog: """ Load the ingested catalog as the solver sees it. @@ -95,8 +92,6 @@ def data_catalog(self, source_type: SourceDatasetType) -> "DataCatalog": : A catalog the solver can be run against. """ - from climate_ref.data_catalog import DataCatalog # noqa: PLC0415 - frame = self.catalog(source_type) if self.database is None: return DataCatalog.from_frame(frame) diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py index 03fd773c1..2b17ff2f6 100644 --- a/packages/climate-ref/src/climate_ref/solver.py +++ b/packages/climate-ref/src/climate_ref/solver.py @@ -289,6 +289,7 @@ def with_obs4ref_fallback( : The obs4MIPs catalog, extended with the obs4REF datasets it does not hold. The original catalog is returned untouched when there is nothing to add. + A merge of two catalogs carries no adapter, so it cannot reload itself and lose the added rows. """ obs4ref_df = as_frame(obs4ref) if obs4ref_df.empty or "instance_id" not in obs4ref_df.columns: @@ -303,9 +304,9 @@ def with_obs4ref_fallback( return obs4ref merged = pd.concat([obs4mips_df, extra], ignore_index=True) - if isinstance(obs4mips, DataCatalog): - # Keep the obs4MIPs adapter and database so the merged catalog can still finalise. - return DataCatalog(database=obs4mips.database, adapter=obs4mips.adapter, df=merged) + if isinstance(obs4mips, DataCatalog) or isinstance(obs4ref, DataCatalog): + # No adapter can reload the merge, so the result carries none and never reloads. + return DataCatalog.from_frame(merged) return merged diff --git a/packages/climate-ref/tests/unit/test_solver.py b/packages/climate-ref/tests/unit/test_solver.py index 4f6520069..068ca7a24 100644 --- a/packages/climate-ref/tests/unit/test_solver.py +++ b/packages/climate-ref/tests/unit/test_solver.py @@ -1982,15 +1982,18 @@ def test_missing_datasets_are_added(self): assert merged.index.tolist() == [0, 1] assert merged["instance_id"].iloc[1].startswith("obs4REF.") - def test_the_merged_catalog_can_still_finalise(self): - adapter = CMIP6DatasetAdapter() - obs4mips = DataCatalog(database=None, adapter=adapter, df=self._frame("obs4MIPs", ["A"])) + def test_the_merged_catalog_cannot_reload_away_the_obs4ref_rows(self): + obs4mips = DataCatalog( + database=None, adapter=CMIP6DatasetAdapter(), df=self._frame("obs4MIPs", ["A"]) + ) obs4ref = DataCatalog.from_frame(self._frame("obs4REF", ["B"])) merged = with_obs4ref_fallback(obs4mips, obs4ref) + # A reload would go back to the obs4MIPs adapter alone and drop B. assert isinstance(merged, DataCatalog) - assert merged.adapter is adapter + assert merged.adapter is None + assert merged.to_frame()["source_id"].tolist() == ["A", "B"] def test_obs4mips_wins_whatever_the_versions(self): obs4mips = self._frame("obs4MIPs", ["A"], version="v1") From 9b91594cc4ba38f6f1e87cf19bc900be1afd72b6 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 16:06:27 +1000 Subject: [PATCH 13/64] fix: update the sea ice sensitivity diagnostic for ESMValTool v2.15 The recipe now carries its own model and observational datasets inline, so drop both and supply the solved models per variable instead. The diagnostic script renamed its output to data_values.csv and gave it a period, regression and statistic column header, so read the metric bundle from the new shape. --- changelog/897.fix.md | 5 + .../diagnostics/sea_ice_sensitivity.py | 86 ++++--- .../sea-ice-sensitivity/cmip6/manifest.json | 56 ++--- .../cmip6/regression/diagnostic.json | 51 +++-- .../cmip6/regression/output.json | 10 +- .../sea-ice-sensitivity/cmip7/manifest.json | 59 ++++- .../cmip7/regression/diagnostic.json | 51 +++-- .../cmip7/regression/output.json | 10 +- .../recipe_sea_ice_sensitivity_cmip6.yml | 213 +++++++++--------- .../recipe_sea_ice_sensitivity_cmip7.yml | 163 +++++++------- 10 files changed, 411 insertions(+), 293 deletions(-) create mode 100644 changelog/897.fix.md diff --git a/changelog/897.fix.md b/changelog/897.fix.md new file mode 100644 index 000000000..a543772c2 --- /dev/null +++ b/changelog/897.fix.md @@ -0,0 +1,5 @@ +Updated the sea ice sensitivity diagnostic for the recipe changes in ESMValTool v2.15. +The recipe now carries its own model and observational datasets inline, so the REF drops both and +supplies the solved models per variable. +The diagnostic script also renamed its output to `data_values.csv` and gave it a period, regression and +statistic column header, which the metric bundle now reads. diff --git a/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py b/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py index bf3120076..ab6e2dff6 100644 --- a/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py +++ b/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py @@ -1,3 +1,4 @@ +import copy from pathlib import Path import pandas @@ -15,7 +16,7 @@ from climate_ref_core.esgf import CMIP6Request, CMIP7Request from climate_ref_core.metric_values.typing import FileDefinition from climate_ref_core.pycmec.metric import CMECMetric, MetricCV -from climate_ref_core.pycmec.output import CMECOutput +from climate_ref_core.pycmec.output import CMECOutput, OutputCV from climate_ref_core.testing import TestCase, TestDataSpecification from climate_ref_esmvaltool.diagnostics.base import ESMValToolDiagnostic, get_cmip_source_type from climate_ref_esmvaltool.recipe import dataframe_to_recipe @@ -30,6 +31,7 @@ class SeaIceSensitivity(ESMValToolDiagnostic): name = "Sea ice sensitivity" slug = "sea-ice-sensitivity" base_recipe = "recipe_seaice_sensitivity.yml" + version = 2 data_requirements = ( ( @@ -132,7 +134,8 @@ class SeaIceSensitivity(ESMValToolDiagnostic): for region in ("arctic", "antarctic") ) + tuple( FileDefinition( - file_pattern=f"work/{region}/sea_ice_sensitivity_script/plotted_values.csv", + # ESMValTool v2.15 registers this file in its provenance without the .csv suffix. + file_pattern=f"work/{region}/sea_ice_sensitivity_script/data_values*", dimensions={"region": region}, ) for region in ("arctic", "antarctic") @@ -193,25 +196,27 @@ def update_recipe( """Update the recipe.""" cmip_source = get_cmip_source_type(input_files) recipe_variables = dataframe_to_recipe(input_files[cmip_source]) - - if cmip_source == SourceDatasetType.CMIP7: - # CMIP7: use per-variable additional_datasets to preserve correct branding_suffix - recipe["datasets"] = [] - for diagnostic in recipe["diagnostics"].values(): - for var_name, variable in diagnostic.get("variables", {}).items(): - short_name = variable.get("short_name", var_name) - if short_name in recipe_variables: - datasets = recipe_variables[short_name]["additional_datasets"] - for ds in datasets: - ds.pop("mip", None) - ds["timerange"] = "1979/2014" - variable["additional_datasets"] = datasets - else: - datasets = recipe_variables["tas"]["additional_datasets"] - for dataset in datasets: - dataset.pop("mip") + for variable in recipe_variables.values(): + for dataset in variable["additional_datasets"]: + dataset.pop("mip", None) dataset["timerange"] = "1979/2014" - recipe["datasets"] = datasets + + # The recipe carries its own model and observational datasets. Drop both, because the + # REF supplies the models from the solve and has no ESMValTool observations available. + for key in ("datasets", "model_defaults", "model_datasets", "obs_defaults"): + recipe.pop(key, None) + for key in ("tasa_obs", "arctic_siconc_obs", "antarctic_siconc_obs"): + recipe.pop(key, None) + + for diagnostic in recipe["diagnostics"].values(): + variables = diagnostic["variables"] + for name in list(variables): + if name.endswith("_obs"): + del variables[name] + else: + variables[name]["additional_datasets"] = copy.deepcopy( + recipe_variables[name]["additional_datasets"] + ) @staticmethod def format_result( @@ -233,20 +238,37 @@ def format_result( } for region in "antarctic", "arctic": df = pd.read_csv( - result_dir / "work" / region / "sea_ice_sensitivity_script" / "plotted_values.csv" + result_dir / "work" / region / "sea_ice_sensitivity_script" / "data_values.csv", + header=[0, 1, 2], + index_col=0, ) - df = df.rename(columns={"Unnamed: 0": "source_id"}).drop(columns=["label"]) + # The label and type columns have no period, so pandas names their upper levels + # "Unnamed: _level_". Everything else is headed by a period. + periods = [ + period + for period in df.columns.get_level_values(0).unique() + if not period.startswith("Unnamed:") + ] + type_column = next(column for column in df.columns if column[2] == "type") + df = df[df[type_column] == "model"] + # The REF solve covers a single period, so keep the last one if the script adds more. + values = df[periods[-1]] + metric_args[MetricCV.DIMENSIONS.value]["region"][region] = {} - for metric in df.columns[1:]: - metric_args[MetricCV.DIMENSIONS.value]["metric"][metric] = {} - for row in df.itertuples(index=False): - source_id = row.source_id + for regression, statistic in values.columns: + metric_args[MetricCV.DIMENSIONS.value]["metric"][f"{regression}_{statistic}"] = {} + for source_id, row in values.iterrows(): metric_args[MetricCV.DIMENSIONS.value]["source_id"][source_id] = {} - for metric, value in zip(df.columns[1:], row[1:]): - if source_id not in metric_args[MetricCV.RESULTS.value]: - metric_args[MetricCV.RESULTS.value][source_id] = {} - if region not in metric_args[MetricCV.RESULTS.value][source_id]: - metric_args[MetricCV.RESULTS.value][source_id][region] = {} - metric_args[MetricCV.RESULTS.value][source_id][region][metric] = value + results = metric_args[MetricCV.RESULTS.value].setdefault(source_id, {}).setdefault(region, {}) + for (regression, statistic), value in row.items(): + results[f"{regression}_{statistic}"] = float(value) + + # The provenance drops the .csv suffix, so the bundle would point at a file that + # does not exist. Restore it before the output files are collected. + data = output_args[OutputCV.DATA.value] + for key in [key for key in data if key.endswith("/data_values")]: + entry = data.pop(key) + entry[OutputCV.FILENAME.value] = f"{entry[OutputCV.FILENAME.value]}.csv" + data[f"{key}.csv"] = entry return CMECMetric.model_validate(metric_args), CMECOutput.model_validate(output_args) diff --git a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/manifest.json b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/manifest.json index d64db0bcf..d1c3a90b4 100644 --- a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/manifest.json +++ b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/manifest.json @@ -1,55 +1,55 @@ { "catalog_hash": "ed2db5a8a44c55840961c2a0a30fa1160cfa42ab", "committed": { - "diagnostic.json": "1e9c270e88ad1395978103851bdb068629fd90e265229baa7d76c0310853982b", - "output.json": "0addb1511d0f758554aa7caac92ee3c254d279e2cb7da71e326dcdc191169939", + "diagnostic.json": "8dffe46455b90b10d172ff2e9f85d0f959a1efc69277e068e0830c1b879d89ee", + "output.json": "f3b3b0f75f134bb26a730e5f51ce20acd253e373fd07e15b62939963652d4775", "series.json": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570" }, - "diagnostic_version": 1, + "diagnostic_version": 2, "native": { "diagnostic.json": { - "sha256": "a9fce0fa39fbba0cdd17aca8264f0d272f6e2b3efb0412cf851b958a4a6a08e2", - "size": 1198 + "sha256": "2119b8fe5751c25caa4b82e3bc34f6ef42ea15a3cf1cda5b0730cf3d8542848d", + "size": 2317 }, "executions/recipe/index.html": { - "sha256": "93a956c8c0c2df28590a69ac37e2b78f67797097e8ae1167ecc21d2e06d76261", - "size": 15915 + "sha256": "ce0b44849e6f67626d1d77bc4713bae8c62d7884eb32946b0bd38ee06f7d428b", + "size": 15971 }, "executions/recipe/plots/antarctic/sea_ice_sensitivity_script/png/Annual Antarctic sea ice sensitivity.png": { - "sha256": "4f06a0229cc45c5fa70b9ca1a8e058334c5e660cceecb9ab4ef80fc891c07412", - "size": 15933 + "sha256": "c0c574544ccab6542148bfbcadd6be187f53e2db7816e00950fd1350e4ea3d59", + "size": 16406 }, "executions/recipe/plots/antarctic/sea_ice_sensitivity_script/png/Annual Antarctic sea ice trends.png": { - "sha256": "95f77ffa79d400184a0c188503026a754cad9a028ad6428e16f044c6ae11de2f", - "size": 37229 + "sha256": "7278db8150586ae7080715a5ce9aa5985b18556a6a1cd779b9b9411fc84f4d2e", + "size": 39561 }, "executions/recipe/plots/arctic/sea_ice_sensitivity_script/png/September Arctic sea ice sensitivity.png": { - "sha256": "6b649635fc99c069f3fa4c5708eba7234f5013a5cc15d6e0d5b60949758d6f33", - "size": 21396 + "sha256": "5693bee5d6810c0d8c58d7adf0c7ecc80adbc01c2d84c0b689134104b143133b", + "size": 20488 }, "executions/recipe/plots/arctic/sea_ice_sensitivity_script/png/September Arctic sea ice trends.png": { - "sha256": "8efdcc711f0b1f373d4069f7ff9486ba0f31a09e72c8d1cd310c5ac9cce9f652", - "size": 36062 + "sha256": "c4a54dfc60fc88af4ff633d8067f76aa977d1b40a4083d331c653612f370eaa4", + "size": 39996 }, "executions/recipe/run/antarctic/sea_ice_sensitivity_script/diagnostic_provenance.yml": { - "sha256": "0e8c755c7b7939907fb2bb38e9a845de20f7d74c075967a335d21410de87f187", - "size": 1476 + "sha256": "aa316a3d7211e624f50d97d1716570f850eb9e32a70341f8f3f2dfc7edb8fddc", + "size": 1469 }, "executions/recipe/run/arctic/sea_ice_sensitivity_script/diagnostic_provenance.yml": { - "sha256": "c12c3034076bf312f37bf26c2539ca7fe97fe1ed8793687eb08f4838f4b66de6", - "size": 1532 + "sha256": "452842ae27218a853d5d551e516f8ab7bc6e94cc09fc25e950e90d72fafb64cd", + "size": 1560 }, - "executions/recipe/work/antarctic/sea_ice_sensitivity_script/plotted_values.csv": { - "sha256": "832fccab6e7efd626a2bc56c9ea7e025279186bbc16a02f2f6783ae16336e38b", - "size": 225 + "executions/recipe/work/antarctic/sea_ice_sensitivity_script/data_values.csv": { + "sha256": "f717d22563f878c90c3c5afb203ce30930761db09f1ca046de74fb1314f28e65", + "size": 715 }, - "executions/recipe/work/arctic/sea_ice_sensitivity_script/plotted_values.csv": { - "sha256": "c0b13303ee5bfb24f343967d33f4954e47a10b8d10415b9d554135af9b0c96ca", - "size": 224 + "executions/recipe/work/arctic/sea_ice_sensitivity_script/data_values.csv": { + "sha256": "2beee01f0310e8cfd93570e09e2fb36adafeb89456d4ac926c1fc2a448c83d28", + "size": 719 }, "output.json": { - "sha256": "2fc746e657b5cd596eafed2925eb8e6dc9dafe0c388a143640d33b6681122e1d", - "size": 2912 + "sha256": "c6f41e586979162b667cb4b3bffa263652ac6bae0dc625fed9a4c35a9800c271", + "size": 2929 }, "series.json": { "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", @@ -57,5 +57,5 @@ } }, "schema": 2, - "test_case_version": 1 + "test_case_version": 2 } diff --git a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/regression/diagnostic.json b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/regression/diagnostic.json index 2c8032616..cb9a7726f 100644 --- a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/regression/diagnostic.json +++ b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/regression/diagnostic.json @@ -10,11 +10,18 @@ "metric" ], "metric": { - "annual_siconc_trend": {}, - "annual_tas_trend": {}, - "direct_p_val": {}, - "direct_r_val": {}, - "direct_sensitivity_(notz-style)": {} + "gmst_over_time_p_value": {}, + "gmst_over_time_r_value": {}, + "gmst_over_time_slope": {}, + "gmst_over_time_std_err_slope": {}, + "sia_over_gmst_p_value": {}, + "sia_over_gmst_r_value": {}, + "sia_over_gmst_slope": {}, + "sia_over_gmst_std_err_slope": {}, + "sia_over_time_p_value": {}, + "sia_over_time_r_value": {}, + "sia_over_time_slope": {}, + "sia_over_time_std_err_slope": {} }, "region": { "antarctic": {}, @@ -31,18 +38,32 @@ "historical": { "CanESM5": { "antarctic": { - "annual_siconc_trend": -0.003315054, - "annual_tas_trend": 0.03272226, - "direct_p_val": 0.3522661, - "direct_r_val": -0.159671, - "direct_sensitivity_(notz-style)": -0.2176355 + "gmst_over_time_p_value": 1.181736e-16, + "gmst_over_time_r_value": 0.9329817, + "gmst_over_time_slope": 0.03272226, + "gmst_over_time_std_err_slope": 0.002164918, + "sia_over_gmst_p_value": 0.3522657, + "sia_over_gmst_r_value": -0.159671, + "sia_over_gmst_slope": -0.2176355, + "sia_over_gmst_std_err_slope": 0.2307577, + "sia_over_time_p_value": 0.6877782, + "sia_over_time_r_value": -0.06934527, + "sia_over_time_slope": -0.003315054, + "sia_over_time_std_err_slope": 0.008178762 }, "arctic": { - "annual_siconc_trend": -0.07236914, - "annual_tas_trend": 0.03272226, - "direct_p_val": 3.949451e-12, - "direct_r_val": -0.872809, - "direct_sensitivity_(notz-style)": -2.066191 + "gmst_over_time_p_value": 1.181736e-16, + "gmst_over_time_r_value": 0.9329817, + "gmst_over_time_slope": 0.03272226, + "gmst_over_time_std_err_slope": 0.002164918, + "sia_over_gmst_p_value": 3.949456e-12, + "sia_over_gmst_r_value": -0.872809, + "sia_over_gmst_slope": -2.066191, + "sia_over_gmst_std_err_slope": 0.1981467, + "sia_over_time_p_value": 4.576936e-12, + "sia_over_time_r_value": -0.8716297, + "sia_over_time_slope": -0.07236914, + "sia_over_time_std_err_slope": 0.006979499 } } } diff --git a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/regression/output.json b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/regression/output.json index 16b043dd2..01cda3620 100644 --- a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/regression/output.json +++ b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip6/regression/output.json @@ -1,19 +1,19 @@ { "data": { - "executions/recipe/work/antarctic/sea_ice_sensitivity_script/plotted_values.csv": { + "executions/recipe/work/antarctic/sea_ice_sensitivity_script/data_values.csv": { "description": "", "dimensions": { "region": "antarctic" }, - "filename": "executions/recipe/work/antarctic/sea_ice_sensitivity_script/plotted_values.csv", + "filename": "executions/recipe/work/antarctic/sea_ice_sensitivity_script/data_values.csv", "long_name": "Annual (not decadal) figures" }, - "executions/recipe/work/arctic/sea_ice_sensitivity_script/plotted_values.csv": { + "executions/recipe/work/arctic/sea_ice_sensitivity_script/data_values.csv": { "description": "", "dimensions": { "region": "arctic" }, - "filename": "executions/recipe/work/arctic/sea_ice_sensitivity_script/plotted_values.csv", + "filename": "executions/recipe/work/arctic/sea_ice_sensitivity_script/data_values.csv", "long_name": "Annual (not decadal) figures" } }, @@ -51,7 +51,7 @@ "region": "arctic" }, "filename": "executions/recipe/plots/arctic/sea_ice_sensitivity_script/png/September Arctic sea ice sensitivity.png", - "long_name": "Sensitivity of sea ice area to annual mean global warming.Mean (dashed), standard deviation (shaded) and plausible values from 1979-2014." + "long_name": "Sensitivity of sea ice area to annual mean global warming.\nMean (dashed), standard deviation (shaded) and plausible (dotted) values from 1979-2014 are shown in grey." }, "executions/recipe/plots/arctic/sea_ice_sensitivity_script/png/September Arctic sea ice trends.png": { "description": "", diff --git a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/manifest.json b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/manifest.json index f237e17b1..a87e54b2c 100644 --- a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/manifest.json +++ b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/manifest.json @@ -1,12 +1,61 @@ { "catalog_hash": "2f092e0f5e2623c8d4fa077d6ce59043ae956260", "committed": { - "diagnostic.json": "03a1c1bae4bd062a77ae76682eb4963b33fbffa47051f1d78fb6ac585028a643", - "output.json": "0addb1511d0f758554aa7caac92ee3c254d279e2cb7da71e326dcdc191169939", + "diagnostic.json": "9fa984d14f391fd5e574159b702bd8c4881345f986038c3fdf6f216292d3e2b0", + "output.json": "f3b3b0f75f134bb26a730e5f51ce20acd253e373fd07e15b62939963652d4775", "series.json": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570" }, - "diagnostic_version": 1, - "native": {}, + "diagnostic_version": 2, + "native": { + "diagnostic.json": { + "sha256": "e96e3d780172d89db6d4950bdfa0dd2e44f6ef7e9ce4a8f5f2b7eac93e18cf41", + "size": 2318 + }, + "executions/recipe/index.html": { + "sha256": "ce0b44849e6f67626d1d77bc4713bae8c62d7884eb32946b0bd38ee06f7d428b", + "size": 15971 + }, + "executions/recipe/plots/antarctic/sea_ice_sensitivity_script/png/Annual Antarctic sea ice sensitivity.png": { + "sha256": "a4b906b9a6e64a9ca014739f5edf82732fdd5be34deddfd2b3656762fc5d5ff9", + "size": 16582 + }, + "executions/recipe/plots/antarctic/sea_ice_sensitivity_script/png/Annual Antarctic sea ice trends.png": { + "sha256": "9fdaa61e8dc69e359e75cf373aa69859e22269f428bd518e0f6fa68c04d25cc9", + "size": 39400 + }, + "executions/recipe/plots/arctic/sea_ice_sensitivity_script/png/September Arctic sea ice sensitivity.png": { + "sha256": "283b6bbbeaae0f135c7b65bf6b077fee6024ead20be1557a0f9ac29fa71d740d", + "size": 20451 + }, + "executions/recipe/plots/arctic/sea_ice_sensitivity_script/png/September Arctic sea ice trends.png": { + "sha256": "cff577a1b2c51663154f50f4d0c1734a39386c7d629f867fdb43fb8a02d24d03", + "size": 40017 + }, + "executions/recipe/run/antarctic/sea_ice_sensitivity_script/diagnostic_provenance.yml": { + "sha256": "cfa74bdac799454269a1ed181e2bb4671a6c2c124d7b9737ac494c3b10bd5107", + "size": 1532 + }, + "executions/recipe/run/arctic/sea_ice_sensitivity_script/diagnostic_provenance.yml": { + "sha256": "61e81ad16557de95a6d4103119f88cf0c1a85f3f945fbad5ea053353e62b6d08", + "size": 1623 + }, + "executions/recipe/work/antarctic/sea_ice_sensitivity_script/data_values.csv": { + "sha256": "d764824f57f8ec20e167554993d377b7a25625985ceeb11f31da788243d5784b", + "size": 715 + }, + "executions/recipe/work/arctic/sea_ice_sensitivity_script/data_values.csv": { + "sha256": "aa18e899753ab92b001df66eecd494ddc05c1076d54a7a6a8c6d9a233cb17fc1", + "size": 721 + }, + "output.json": { + "sha256": "c6f41e586979162b667cb4b3bffa263652ac6bae0dc625fed9a4c35a9800c271", + "size": 2929 + }, + "series.json": { + "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "size": 2 + } + }, "schema": 2, - "test_case_version": 1 + "test_case_version": 2 } diff --git a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/regression/diagnostic.json b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/regression/diagnostic.json index 49eb3beab..717240f4b 100644 --- a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/regression/diagnostic.json +++ b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/regression/diagnostic.json @@ -10,11 +10,18 @@ "metric" ], "metric": { - "annual_siconc_trend": {}, - "annual_tas_trend": {}, - "direct_p_val": {}, - "direct_r_val": {}, - "direct_sensitivity_(notz-style)": {} + "gmst_over_time_p_value": {}, + "gmst_over_time_r_value": {}, + "gmst_over_time_slope": {}, + "gmst_over_time_std_err_slope": {}, + "sia_over_gmst_p_value": {}, + "sia_over_gmst_r_value": {}, + "sia_over_gmst_slope": {}, + "sia_over_gmst_std_err_slope": {}, + "sia_over_time_p_value": {}, + "sia_over_time_r_value": {}, + "sia_over_time_slope": {}, + "sia_over_time_std_err_slope": {} }, "region": { "antarctic": {}, @@ -31,18 +38,32 @@ "historical": { "CanESM5": { "antarctic": { - "annual_siconc_trend": -0.003316542, - "annual_tas_trend": 0.03273005, - "direct_p_val": 0.3521369, - "direct_r_val": -0.1597133, - "direct_sensitivity_(notz-style)": -0.2176574 + "gmst_over_time_p_value": 1.169205e-16, + "gmst_over_time_r_value": 0.933025, + "gmst_over_time_slope": 0.03273005, + "gmst_over_time_std_err_slope": 0.002164657, + "sia_over_gmst_p_value": 0.3521365, + "sia_over_gmst_r_value": -0.1597134, + "sia_over_gmst_slope": -0.2176575, + "sia_over_gmst_std_err_slope": 0.2307182, + "sia_over_time_p_value": 0.6876532, + "sia_over_time_r_value": -0.06937449, + "sia_over_time_slope": -0.003316542, + "sia_over_time_std_err_slope": 0.008178971 }, "arctic": { - "annual_siconc_trend": -0.07238201, - "annual_tas_trend": 0.03273005, - "direct_p_val": 3.959278e-12, - "direct_r_val": -0.8727892, - "direct_sensitivity_(notz-style)": -2.0661 + "gmst_over_time_p_value": 1.169205e-16, + "gmst_over_time_r_value": 0.933025, + "gmst_over_time_slope": 0.03273005, + "gmst_over_time_std_err_slope": 0.002164657, + "sia_over_gmst_p_value": 3.959334e-12, + "sia_over_gmst_r_value": -0.8727891, + "sia_over_gmst_slope": -2.0661, + "sia_over_gmst_std_err_slope": 0.1981568, + "sia_over_time_p_value": 4.573249e-12, + "sia_over_time_r_value": -0.8716361, + "sia_over_time_slope": -0.07238201, + "sia_over_time_std_err_slope": 0.006980524 } } } diff --git a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/regression/output.json b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/regression/output.json index 16b043dd2..01cda3620 100644 --- a/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/regression/output.json +++ b/packages/climate-ref-esmvaltool/tests/test-data/sea-ice-sensitivity/cmip7/regression/output.json @@ -1,19 +1,19 @@ { "data": { - "executions/recipe/work/antarctic/sea_ice_sensitivity_script/plotted_values.csv": { + "executions/recipe/work/antarctic/sea_ice_sensitivity_script/data_values.csv": { "description": "", "dimensions": { "region": "antarctic" }, - "filename": "executions/recipe/work/antarctic/sea_ice_sensitivity_script/plotted_values.csv", + "filename": "executions/recipe/work/antarctic/sea_ice_sensitivity_script/data_values.csv", "long_name": "Annual (not decadal) figures" }, - "executions/recipe/work/arctic/sea_ice_sensitivity_script/plotted_values.csv": { + "executions/recipe/work/arctic/sea_ice_sensitivity_script/data_values.csv": { "description": "", "dimensions": { "region": "arctic" }, - "filename": "executions/recipe/work/arctic/sea_ice_sensitivity_script/plotted_values.csv", + "filename": "executions/recipe/work/arctic/sea_ice_sensitivity_script/data_values.csv", "long_name": "Annual (not decadal) figures" } }, @@ -51,7 +51,7 @@ "region": "arctic" }, "filename": "executions/recipe/plots/arctic/sea_ice_sensitivity_script/png/September Arctic sea ice sensitivity.png", - "long_name": "Sensitivity of sea ice area to annual mean global warming.Mean (dashed), standard deviation (shaded) and plausible values from 1979-2014." + "long_name": "Sensitivity of sea ice area to annual mean global warming.\nMean (dashed), standard deviation (shaded) and plausible (dotted) values from 1979-2014 are shown in grey." }, "executions/recipe/plots/arctic/sea_ice_sensitivity_script/png/September Arctic sea ice trends.png": { "description": "", diff --git a/packages/climate-ref-esmvaltool/tests/unit/diagnostics/recipes/recipe_sea_ice_sensitivity_cmip6.yml b/packages/climate-ref-esmvaltool/tests/unit/diagnostics/recipes/recipe_sea_ice_sensitivity_cmip6.yml index 8b557ac7e..ba667cecb 100644 --- a/packages/climate-ref-esmvaltool/tests/unit/diagnostics/recipes/recipe_sea_ice_sensitivity_cmip6.yml +++ b/packages/climate-ref-esmvaltool/tests/unit/diagnostics/recipes/recipe_sea_ice_sensitivity_cmip6.yml @@ -23,48 +23,7 @@ documentation: - blockley_ed maintainer: - parsons_naomi -defaults: - ensemble: r1i1p1f1 - exp: historical - grid: gn - project: CMIP6 -datasets: -- project: CMIP6 - activity: CMIP - dataset: ACCESS-ESM1-5 - ensemble: r1i1p1f1 - institute: CSIRO - exp: historical - grid: gn - timerange: 1979/2014 -- project: CMIP6 - activity: CMIP - dataset: MPI-ESM1-2-LR - ensemble: r1i1p1f1 - institute: MPI-M - exp: historical - grid: gn - timerange: 1979/2014 -- project: CMIP6 - activity: CMIP - dataset: CESM2 - ensemble: r1i1p1f1 - institute: NCAR - exp: historical - grid: gn - timerange: 1979/2014 preprocessors: - extract_test_period: - extract_time: - start_day: 1 - start_month: 1 - start_year: 1979 - end_day: 31 - end_month: 12 - end_year: 2014 - extract_sept: - extract_month: - month: 9 nh_total_area: extract_region: start_longitude: 0 @@ -92,14 +51,10 @@ preprocessors: annual_statistics: operator: mean keep_group_coordinates: true + extract_sept: + extract_month: + month: 9 pp_arctic_sept_sea_ice: - extract_time: - start_day: 1 - start_month: 1 - start_year: 1979 - end_day: 31 - end_month: 12 - end_year: 2014 extract_month: month: 9 extract_region: @@ -112,13 +67,6 @@ preprocessors: convert_units: units: 1e6 km2 pp_antarctic_avg_ann_sea_ice: - extract_time: - start_day: 1 - start_month: 1 - start_year: 1979 - end_day: 31 - end_month: 12 - end_year: 2014 annual_statistics: operator: mean keep_group_coordinates: true @@ -132,13 +80,6 @@ preprocessors: convert_units: units: 1e6 km2 pp_avg_ann_global_temp: - extract_time: - start_day: 1 - start_month: 1 - start_year: 1979 - end_day: 31 - end_month: 12 - end_year: 2014 area_statistics: operator: mean annual_statistics: @@ -152,34 +93,70 @@ diagnostics: siconc: preprocessor: pp_arctic_sept_sea_ice mip: SImon + additional_datasets: + - project: CMIP6 + activity: CMIP + dataset: ACCESS-ESM1-5 + ensemble: r1i1p1f1 + institute: CSIRO + exp: historical + grid: gn + timerange: 1979/2014 + - project: CMIP6 + activity: CMIP + dataset: MPI-ESM1-2-LR + ensemble: r1i1p1f1 + institute: MPI-M + exp: historical + grid: gn + timerange: 1979/2014 + - project: CMIP6 + activity: CMIP + dataset: CESM2 + ensemble: r1i1p1f1 + institute: NCAR + exp: historical + grid: gn + timerange: 1979/2014 tas: preprocessor: pp_avg_ann_global_temp mip: Amon + additional_datasets: + - project: CMIP6 + activity: CMIP + dataset: ACCESS-ESM1-5 + ensemble: r1i1p1f1 + institute: CSIRO + exp: historical + grid: gn + timerange: 1979/2014 + - project: CMIP6 + activity: CMIP + dataset: MPI-ESM1-2-LR + ensemble: r1i1p1f1 + institute: MPI-M + exp: historical + grid: gn + timerange: 1979/2014 + - project: CMIP6 + activity: CMIP + dataset: CESM2 + ensemble: r1i1p1f1 + institute: NCAR + exp: historical + grid: gn + timerange: 1979/2014 scripts: sea_ice_sensitivity_script: script: seaice/seaice_sensitivity.py observations: - observation period: 1979-2014 - sea ice sensitivity (Notz-style plot): + observation_period: + start_year: 1979 + end_year: 2014 + sea_ice_sensitivity: mean: -4.01 - standard deviation: 0.32 - plausible range: 1.28 - annual trends (Roach-style plot): - first point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null - second point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null - third point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null + standard_deviation: 0.32 + plausible_range: 1.28 antarctic: description: Plots annual mean sea ice sensitivity below 0 latitude in millions of square kilometres @@ -187,31 +164,59 @@ diagnostics: siconc: preprocessor: pp_antarctic_avg_ann_sea_ice mip: SImon + additional_datasets: + - project: CMIP6 + activity: CMIP + dataset: ACCESS-ESM1-5 + ensemble: r1i1p1f1 + institute: CSIRO + exp: historical + grid: gn + timerange: 1979/2014 + - project: CMIP6 + activity: CMIP + dataset: MPI-ESM1-2-LR + ensemble: r1i1p1f1 + institute: MPI-M + exp: historical + grid: gn + timerange: 1979/2014 + - project: CMIP6 + activity: CMIP + dataset: CESM2 + ensemble: r1i1p1f1 + institute: NCAR + exp: historical + grid: gn + timerange: 1979/2014 tas: preprocessor: pp_avg_ann_global_temp mip: Amon + additional_datasets: + - project: CMIP6 + activity: CMIP + dataset: ACCESS-ESM1-5 + ensemble: r1i1p1f1 + institute: CSIRO + exp: historical + grid: gn + timerange: 1979/2014 + - project: CMIP6 + activity: CMIP + dataset: MPI-ESM1-2-LR + ensemble: r1i1p1f1 + institute: MPI-M + exp: historical + grid: gn + timerange: 1979/2014 + - project: CMIP6 + activity: CMIP + dataset: CESM2 + ensemble: r1i1p1f1 + institute: NCAR + exp: historical + grid: gn + timerange: 1979/2014 scripts: sea_ice_sensitivity_script: script: seaice/seaice_sensitivity.py - observations: - observation period: null - sea ice sensitivity (Notz-style plot): - mean: null - standard deviation: null - plausible range: null - annual trends (Roach-style plot): - first point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null - second point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null - third point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null diff --git a/packages/climate-ref-esmvaltool/tests/unit/diagnostics/recipes/recipe_sea_ice_sensitivity_cmip7.yml b/packages/climate-ref-esmvaltool/tests/unit/diagnostics/recipes/recipe_sea_ice_sensitivity_cmip7.yml index 48479804a..e767efaaa 100644 --- a/packages/climate-ref-esmvaltool/tests/unit/diagnostics/recipes/recipe_sea_ice_sensitivity_cmip7.yml +++ b/packages/climate-ref-esmvaltool/tests/unit/diagnostics/recipes/recipe_sea_ice_sensitivity_cmip7.yml @@ -23,24 +23,7 @@ documentation: - blockley_ed maintainer: - parsons_naomi -defaults: - ensemble: r1i1p1f1 - exp: historical - grid: gn - project: CMIP6 -datasets: [] preprocessors: - extract_test_period: - extract_time: - start_day: 1 - start_month: 1 - start_year: 1979 - end_day: 31 - end_month: 12 - end_year: 2014 - extract_sept: - extract_month: - month: 9 nh_total_area: extract_region: start_longitude: 0 @@ -68,14 +51,10 @@ preprocessors: annual_statistics: operator: mean keep_group_coordinates: true + extract_sept: + extract_month: + month: 9 pp_arctic_sept_sea_ice: - extract_time: - start_day: 1 - start_month: 1 - start_year: 1979 - end_day: 31 - end_month: 12 - end_year: 2014 extract_month: month: 9 extract_region: @@ -88,13 +67,6 @@ preprocessors: convert_units: units: 1e6 km2 pp_antarctic_avg_ann_sea_ice: - extract_time: - start_day: 1 - start_month: 1 - start_year: 1979 - end_day: 31 - end_month: 12 - end_year: 2014 annual_statistics: operator: mean keep_group_coordinates: true @@ -108,13 +80,6 @@ preprocessors: convert_units: units: 1e6 km2 pp_avg_ann_global_temp: - extract_time: - start_day: 1 - start_month: 1 - start_year: 1979 - end_day: 31 - end_month: 12 - end_year: 2014 area_statistics: operator: mean annual_statistics: @@ -128,7 +93,7 @@ diagnostics: siconc: preprocessor: pp_arctic_sept_sea_ice mip: seaIce - additional_datasets: &id001 + additional_datasets: - project: CMIP7 activity: CMIP branding_suffix: tavg-u-hxy-u @@ -165,7 +130,7 @@ diagnostics: tas: preprocessor: pp_avg_ann_global_temp mip: atmos - additional_datasets: &id002 + additional_datasets: - project: CMIP7 activity: CMIP branding_suffix: tavg-h2m-hxy-u @@ -203,27 +168,13 @@ diagnostics: sea_ice_sensitivity_script: script: seaice/seaice_sensitivity.py observations: - observation period: 1979-2014 - sea ice sensitivity (Notz-style plot): + observation_period: + start_year: 1979 + end_year: 2014 + sea_ice_sensitivity: mean: -4.01 - standard deviation: 0.32 - plausible range: 1.28 - annual trends (Roach-style plot): - first point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null - second point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null - third point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null + standard_deviation: 0.32 + plausible_range: 1.28 antarctic: description: Plots annual mean sea ice sensitivity below 0 latitude in millions of square kilometres @@ -231,33 +182,77 @@ diagnostics: siconc: preprocessor: pp_antarctic_avg_ann_sea_ice mip: seaIce - additional_datasets: *id001 + additional_datasets: + - project: CMIP7 + activity: CMIP + branding_suffix: tavg-u-hxy-u + dataset: ACCESS-ESM1-5 + ensemble: r1i1p1f1 + exp: historical + frequency: mon + grid: gn + institute: CSIRO + region: glb + timerange: 1979/2014 + - project: CMIP7 + activity: CMIP + branding_suffix: tavg-u-hxy-u + dataset: MPI-ESM1-2-LR + ensemble: r1i1p1f1 + exp: historical + frequency: mon + grid: gn + institute: MPI-M + region: glb + timerange: 1979/2014 + - project: CMIP7 + activity: CMIP + branding_suffix: tavg-u-hxy-u + dataset: CESM2 + ensemble: r1i1p1f1 + exp: historical + frequency: mon + grid: gn + institute: NCAR + region: glb + timerange: 1979/2014 tas: preprocessor: pp_avg_ann_global_temp mip: atmos - additional_datasets: *id002 + additional_datasets: + - project: CMIP7 + activity: CMIP + branding_suffix: tavg-h2m-hxy-u + dataset: ACCESS-ESM1-5 + ensemble: r1i1p1f1 + exp: historical + frequency: mon + grid: gn + institute: CSIRO + region: glb + timerange: 1979/2014 + - project: CMIP7 + activity: CMIP + branding_suffix: tavg-h2m-hxy-u + dataset: MPI-ESM1-2-LR + ensemble: r1i1p1f1 + exp: historical + frequency: mon + grid: gn + institute: MPI-M + region: glb + timerange: 1979/2014 + - project: CMIP7 + activity: CMIP + branding_suffix: tavg-h2m-hxy-u + dataset: CESM2 + ensemble: r1i1p1f1 + exp: historical + frequency: mon + grid: gn + institute: NCAR + region: glb + timerange: 1979/2014 scripts: sea_ice_sensitivity_script: script: seaice/seaice_sensitivity.py - observations: - observation period: null - sea ice sensitivity (Notz-style plot): - mean: null - standard deviation: null - plausible range: null - annual trends (Roach-style plot): - first point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null - second point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null - third point: - GMST trend: null - SIA trend: null - Pearson CC of SIA over GMST: null - significance of SIA over GMST: null From 45ae29347d9d1089528b951dc2ea6d44d171aca3 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 17:52:12 +1000 Subject: [PATCH 14/64] docs(changelog): renumber the fragment for the new PR --- changelog/{897.fix.md => 899.fix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/{897.fix.md => 899.fix.md} (100%) diff --git a/changelog/897.fix.md b/changelog/899.fix.md similarity index 100% rename from changelog/897.fix.md rename to changelog/899.fix.md From 3ae41af1ca5cf23265dbfae9d3709925f4bf50a6 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 18:08:17 +1000 Subject: [PATCH 15/64] fix: report each misfiled obs4REF dataset as its own finding Tested against a real deployment on Gus, where 81 datasets are affected. One finding listing all of them wrapped into an unreadable blob, and the remedy told the reader to retract 'each of the rows above'. Findings sharing a remedy are already grouped under it once, so one per dataset reads as a list. --- .../src/climate_ref/doctor/checks/data.py | 7 +++-- .../climate-ref/tests/unit/test_doctor.py | 26 ++++++++++++++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/climate-ref/src/climate_ref/doctor/checks/data.py b/packages/climate-ref/src/climate_ref/doctor/checks/data.py index 6e5b3e9ee..4993e8608 100644 --- a/packages/climate-ref/src/climate_ref/doctor/checks/data.py +++ b/packages/climate-ref/src/climate_ref/doctor/checks/data.py @@ -277,7 +277,7 @@ def check_misfiled_obs4ref(context: DoctorContext) -> list[Finding]: Returns ------- : - One finding when any such data is present. + One finding per misfiled dataset, all sharing the one remedy. """ catalog = context.catalog(SourceDatasetType.obs4MIPs) if not len(catalog) or not {"instance_id", "path"}.issubset(catalog.columns): @@ -287,18 +287,17 @@ def check_misfiled_obs4ref(context: DoctorContext) -> list[Finding]: if not misfiled.any(): return [] - instance_ids = sorted(catalog.loc[misfiled, "instance_id"].unique()) return [ Finding( severity=Severity.WARNING, - summary=f"{pluralise(len(instance_ids), 'obs4REF dataset')} ingested as obs4mips", - detail="Affected: " + ", ".join(instance_ids) + ".", + summary=f"{instance_id} is obs4REF data ingested as obs4mips", remedy=( "Re-ingest the obs4REF collection under its own source type, " "then retract each of the obs4mips rows above with `ref datasets retract `." ), command="ref datasets ingest --source-type obs4ref ", ) + for instance_id in sorted(catalog.loc[misfiled, "instance_id"].unique()) ] diff --git a/packages/climate-ref/tests/unit/test_doctor.py b/packages/climate-ref/tests/unit/test_doctor.py index bc14cd208..52f6a2116 100644 --- a/packages/climate-ref/tests/unit/test_doctor.py +++ b/packages/climate-ref/tests/unit/test_doctor.py @@ -276,11 +276,31 @@ def test_obs4ref_layout_under_obs4mips_is_reported(self): assert len(findings) == 1 assert findings[0].severity == Severity.WARNING - assert "1 obs4REF dataset ingested as obs4mips" in findings[0].summary - assert "obs4MIPs.obs4MIPs.X.gpp" in findings[0].detail - assert "ERA-5" not in findings[0].detail + assert findings[0].summary == "obs4MIPs.obs4MIPs.X.gpp is obs4REF data ingested as obs4mips" assert findings[0].command == "ref datasets ingest --source-type obs4ref " + def test_each_misfiled_dataset_gets_its_own_finding(self): + catalog = _catalog( + [ + ( + "obs4MIPs.obs4MIPs.X.gpp", + "X-1-0", + "gpp", + "2007-01-01", + "2015-12-01", + "/d/obs4REF/X/gpp.nc", + ), + ("obs4MIPs.obs4MIPs.Y.ts", "Y-1-0", "ts", "2007-01-01", "2015-12-01", "/d/obs4REF/Y/ts.nc"), + ] + ) + context = _context({SourceDatasetType.obs4MIPs: catalog}) + + findings = check_misfiled_obs4ref(context) + + # One remedy shared across them, so the report groups them under it. + assert len(findings) == 2 + assert len({f.remedy for f in findings}) == 1 + def test_registry_source_id_from_esgf_is_not_reported(self): # HadISST-1-1 sits in both collections, so an ESGF copy is legitimately ingested as obs4MIPs. catalog = _catalog( From 45d068ff0ecfaab1f053227f8b9594dc99f8d20a Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 18:51:15 +1000 Subject: [PATCH 16/64] refactor: tidy the sea ice sensitivity recipe and result handling Merges the two identical key removal loops, because the split implied a distinction between the groups that does not exist. Prunes the recipe variables by what the solve supplies rather than by the _obs name suffix, so a variable that is neither solved nor named _obs no longer raises a KeyError. Addresses the CSV column index by level name, which drops a dependency on the placeholder names pandas gives unlabelled header levels. --- .../diagnostics/sea_ice_sensitivity.py | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py b/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py index ab6e2dff6..2c7661f28 100644 --- a/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py +++ b/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py @@ -201,22 +201,28 @@ def update_recipe( dataset.pop("mip", None) dataset["timerange"] = "1979/2014" - # The recipe carries its own model and observational datasets. Drop both, because the - # REF supplies the models from the solve and has no ESMValTool observations available. - for key in ("datasets", "model_defaults", "model_datasets", "obs_defaults"): - recipe.pop(key, None) - for key in ("tasa_obs", "arctic_siconc_obs", "antarctic_siconc_obs"): + # The REF supplies the models from the solve and has no ESMValTool observations available, + # so drop the datasets the recipe carries and the anchors holding them. + for key in ( + "datasets", + "model_defaults", + "model_datasets", + "obs_defaults", + "tasa_obs", + "arctic_siconc_obs", + "antarctic_siconc_obs", + ): recipe.pop(key, None) for diagnostic in recipe["diagnostics"].values(): variables = diagnostic["variables"] for name in list(variables): - if name.endswith("_obs"): - del variables[name] - else: + if name in recipe_variables: variables[name]["additional_datasets"] = copy.deepcopy( recipe_variables[name]["additional_datasets"] ) + else: + del variables[name] @staticmethod def format_result( @@ -236,35 +242,29 @@ def format_result( "region": {}, "metric": {}, } + dimensions = metric_args[MetricCV.DIMENSIONS.value] for region in "antarctic", "arctic": df = pd.read_csv( result_dir / "work" / region / "sea_ice_sensitivity_script" / "data_values.csv", header=[0, 1, 2], index_col=0, ) - # The label and type columns have no period, so pandas names their upper levels - # "Unnamed: _level_". Everything else is headed by a period. - periods = [ - period - for period in df.columns.get_level_values(0).unique() - if not period.startswith("Unnamed:") - ] - type_column = next(column for column in df.columns if column[2] == "type") - df = df[df[type_column] == "model"] + is_type = df.columns.get_level_values("statistic") == "type" + is_model = df.loc[:, is_type].iloc[:, 0] == "model" # The REF solve covers a single period, so keep the last one if the script adds more. - values = df[periods[-1]] + period = str(df.columns.get_level_values("period")[-1]) + values = df.loc[is_model, period] - metric_args[MetricCV.DIMENSIONS.value]["region"][region] = {} + dimensions["region"][region] = {} for regression, statistic in values.columns: - metric_args[MetricCV.DIMENSIONS.value]["metric"][f"{regression}_{statistic}"] = {} + dimensions["metric"][f"{regression}_{statistic}"] = {} for source_id, row in values.iterrows(): - metric_args[MetricCV.DIMENSIONS.value]["source_id"][source_id] = {} + dimensions["source_id"][source_id] = {} results = metric_args[MetricCV.RESULTS.value].setdefault(source_id, {}).setdefault(region, {}) for (regression, statistic), value in row.items(): results[f"{regression}_{statistic}"] = float(value) - # The provenance drops the .csv suffix, so the bundle would point at a file that - # does not exist. Restore it before the output files are collected. + # Restore the suffix so the bundle does not point at a file that does not exist. data = output_args[OutputCV.DATA.value] for key in [key for key in data if key.endswith("/data_values")]: entry = data.pop(key) From 74ec7c16b1ff88aad61c40ef782ce0d366138091 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 18:51:38 +1000 Subject: [PATCH 17/64] fix: present merged obs4REF rows as obs4MIPs data Tested against a real deployment on Gus. pmp/enso_tel groups its reference requirement by activity_id, and the adapter now stamps that from the source type, so the reference data split into an obs4MIPs group and an obs4REF group and every model solved twice against half its references. 892 extra executions across the deployment. The merged rows stand in for obs4MIPs data, so they now carry that activity_id. Their instance_id still names obs4REF, so the provenance is not lost. Also catches the ValueError a diagnostic with no data requirements raises, and adds obs4REF to the aggregate test fixture so it can exercise the fallback. --- .../climate-ref/src/climate_ref/conftest_plugin.py | 7 +++++-- .../climate-ref/src/climate_ref/doctor/checks/data.py | 3 ++- packages/climate-ref/src/climate_ref/solver.py | 6 ++++++ packages/climate-ref/tests/unit/test_solver.py | 10 ++++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/climate-ref/src/climate_ref/conftest_plugin.py b/packages/climate-ref/src/climate_ref/conftest_plugin.py index f9ea6cd6e..d1bde52e2 100644 --- a/packages/climate-ref/src/climate_ref/conftest_plugin.py +++ b/packages/climate-ref/src/climate_ref/conftest_plugin.py @@ -231,12 +231,15 @@ def obs4ref_data_catalog(sample_data: None, sample_data_dir: Path) -> pd.DataFra @pytest.fixture(scope="session") def data_catalog( - cmip6_data_catalog: pd.DataFrame, obs4mips_data_catalog: pd.DataFrame + cmip6_data_catalog: pd.DataFrame, + obs4mips_data_catalog: pd.DataFrame, + obs4ref_data_catalog: pd.DataFrame, ) -> dict[SourceDatasetType, pd.DataFrame]: - """Provide combined data catalog with CMIP6 and obs4MIPs sources.""" + """Provide combined data catalog with CMIP6, obs4MIPs and obs4REF sources.""" return { SourceDatasetType.CMIP6: cmip6_data_catalog, SourceDatasetType.obs4MIPs: obs4mips_data_catalog, + SourceDatasetType.obs4REF: obs4ref_data_catalog, } diff --git a/packages/climate-ref/src/climate_ref/doctor/checks/data.py b/packages/climate-ref/src/climate_ref/doctor/checks/data.py index 4993e8608..b2cd1104d 100644 --- a/packages/climate-ref/src/climate_ref/doctor/checks/data.py +++ b/packages/climate-ref/src/climate_ref/doctor/checks/data.py @@ -375,7 +375,8 @@ def check_unsolvable_diagnostics(context: DoctorContext) -> list[Finding]: for diagnostic in provider.diagnostics(): try: solvable = any(True for _ in solve_executions(catalogs, diagnostic, provider)) - except InvalidDiagnosticException: + except (InvalidDiagnosticException, ValueError): + # ValueError is a diagnostic declaring no data requirements at all. solvable = False if solvable: continue diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py index 2b17ff2f6..2dc21480f 100644 --- a/packages/climate-ref/src/climate_ref/solver.py +++ b/packages/climate-ref/src/climate_ref/solver.py @@ -290,6 +290,8 @@ def with_obs4ref_fallback( The obs4MIPs catalog, extended with the obs4REF datasets it does not hold. The original catalog is returned untouched when there is nothing to add. A merge of two catalogs carries no adapter, so it cannot reload itself and lose the added rows. + The added rows carry ``activity_id`` of obs4MIPs, because that is the collection they stand in for. + Their ``instance_id`` still names obs4REF, so the provenance is not lost. """ obs4ref_df = as_frame(obs4ref) if obs4ref_df.empty or "instance_id" not in obs4ref_df.columns: @@ -303,6 +305,10 @@ def with_obs4ref_fallback( if obs4mips_df.empty: return obs4ref + # The rows are served as obs4MIPs data, so they must group as obs4MIPs data too. + # A requirement grouping by activity_id would otherwise split its reference data in two. + if "activity_id" in extra.columns: + extra = extra.assign(activity_id=SourceDatasetType.obs4MIPs.name) merged = pd.concat([obs4mips_df, extra], ignore_index=True) if isinstance(obs4mips, DataCatalog) or isinstance(obs4ref, DataCatalog): # No adapter can reload the merge, so the result carries none and never reloads. diff --git a/packages/climate-ref/tests/unit/test_solver.py b/packages/climate-ref/tests/unit/test_solver.py index 068ca7a24..447eb7582 100644 --- a/packages/climate-ref/tests/unit/test_solver.py +++ b/packages/climate-ref/tests/unit/test_solver.py @@ -1995,6 +1995,16 @@ def test_the_merged_catalog_cannot_reload_away_the_obs4ref_rows(self): assert merged.adapter is None assert merged.to_frame()["source_id"].tolist() == ["A", "B"] + def test_added_rows_group_as_obs4mips(self): + # pmp/enso groups its reference data by activity_id, so a second value splits it in two. + obs4mips = self._frame("obs4MIPs", ["A"]).assign(activity_id="obs4MIPs") + obs4ref = self._frame("obs4REF", ["B"], start=10).assign(activity_id="obs4REF") + + merged = with_obs4ref_fallback(obs4mips, obs4ref) + + assert merged["activity_id"].tolist() == ["obs4MIPs", "obs4MIPs"] + assert merged["instance_id"].iloc[1].startswith("obs4REF.") + def test_obs4mips_wins_whatever_the_versions(self): obs4mips = self._frame("obs4MIPs", ["A"], version="v1") obs4ref = self._frame("obs4REF", ["A"], version="v2", start=10) From a92fdd50107d7a41deac5a69f624efc80a024ac3 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 19:30:53 +1000 Subject: [PATCH 18/64] fix: normalise the activity_id when obs4REF is the only reference catalog A deployment that fetched only the obs4REF registry is the ordinary case, and it took the branch that returned the obs4REF catalog untouched. Those rows kept an obs4REF activity_id, so a requirement grouping by it split or rejected them. Normalisation now happens on the one path every added row takes. Also normalises path separators before matching a collection directory. --- .../climate-ref/src/climate_ref/datasets/obs4mips.py | 2 +- packages/climate-ref/src/climate_ref/solver.py | 6 +++--- packages/climate-ref/tests/unit/test_solver.py | 10 +++++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/climate-ref/src/climate_ref/datasets/obs4mips.py b/packages/climate-ref/src/climate_ref/datasets/obs4mips.py index be49e9bb8..4e944f150 100644 --- a/packages/climate-ref/src/climate_ref/datasets/obs4mips.py +++ b/packages/climate-ref/src/climate_ref/datasets/obs4mips.py @@ -36,7 +36,7 @@ def in_collection_directory(paths: pd.Series, activity_id: str) -> pd.Series: : A boolean mask over ``paths``. """ - return paths.astype(str).str.contains(f"/{activity_id}/", regex=False) + return paths.astype(str).str.replace("\\", "/", regex=False).str.contains(f"/{activity_id}/", regex=False) def parse_obs4mips(file: str, **kwargs: Any) -> dict[str, Any]: diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py index 2dc21480f..512f859a4 100644 --- a/packages/climate-ref/src/climate_ref/solver.py +++ b/packages/climate-ref/src/climate_ref/solver.py @@ -288,6 +288,8 @@ def with_obs4ref_fallback( ------- : The obs4MIPs catalog, extended with the obs4REF datasets it does not hold. + This includes the case of nothing being ingested as obs4MIPs at all, which is an ordinary + deployment that fetched only the registry. The original catalog is returned untouched when there is nothing to add. A merge of two catalogs carries no adapter, so it cannot reload itself and lose the added rows. The added rows carry ``activity_id`` of obs4MIPs, because that is the collection they stand in for. @@ -302,14 +304,12 @@ def with_obs4ref_fallback( extra = obs4ref_df[~obs_dataset_key(obs4ref_df["instance_id"]).isin(held)] if extra.empty: return obs4mips - if obs4mips_df.empty: - return obs4ref # The rows are served as obs4MIPs data, so they must group as obs4MIPs data too. # A requirement grouping by activity_id would otherwise split its reference data in two. if "activity_id" in extra.columns: extra = extra.assign(activity_id=SourceDatasetType.obs4MIPs.name) - merged = pd.concat([obs4mips_df, extra], ignore_index=True) + merged = pd.concat([obs4mips_df, extra] if len(obs4mips_df) else [extra], ignore_index=True) if isinstance(obs4mips, DataCatalog) or isinstance(obs4ref, DataCatalog): # No adapter can reload the merge, so the result carries none and never reloads. return DataCatalog.from_frame(merged) diff --git a/packages/climate-ref/tests/unit/test_solver.py b/packages/climate-ref/tests/unit/test_solver.py index 447eb7582..560d53bec 100644 --- a/packages/climate-ref/tests/unit/test_solver.py +++ b/packages/climate-ref/tests/unit/test_solver.py @@ -2016,10 +2016,14 @@ def test_untouched_when_nothing_to_add(self): assert with_obs4ref_fallback(obs4mips, pd.DataFrame()) is obs4mips - def test_obs4ref_alone_is_used_as_is(self): - obs4ref = self._frame("obs4REF", ["A"]) + def test_obs4ref_alone_still_groups_as_obs4mips(self): + # A deployment that fetched only the registry is the ordinary case, not an edge case. + obs4ref = self._frame("obs4REF", ["A"]).assign(activity_id="obs4REF") - assert with_obs4ref_fallback(pd.DataFrame(), obs4ref) is obs4ref + merged = with_obs4ref_fallback(pd.DataFrame(), obs4ref) + + assert merged["activity_id"].tolist() == ["obs4MIPs"] + assert merged["instance_id"].tolist() == obs4ref["instance_id"].tolist() def test_only_the_obs4mips_catalog_is_extended(self): catalogs = { From d87a7ec7cea2eed4043296b66fdc729cd39d5522 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 19:55:33 +1000 Subject: [PATCH 19/64] chore: cleanup --- changelog/899.fix.md | 4 ---- .../climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/changelog/899.fix.md b/changelog/899.fix.md index a543772c2..439136af2 100644 --- a/changelog/899.fix.md +++ b/changelog/899.fix.md @@ -1,5 +1 @@ Updated the sea ice sensitivity diagnostic for the recipe changes in ESMValTool v2.15. -The recipe now carries its own model and observational datasets inline, so the REF drops both and -supplies the solved models per variable. -The diagnostic script also renamed its output to `data_values.csv` and gave it a period, regression and -statistic column header, which the metric bundle now reads. diff --git a/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py b/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py index 2c7661f28..c675cac71 100644 --- a/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py +++ b/packages/climate-ref-esmvaltool/src/climate_ref_esmvaltool/diagnostics/sea_ice_sensitivity.py @@ -264,7 +264,7 @@ def format_result( for (regression, statistic), value in row.items(): results[f"{regression}_{statistic}"] = float(value) - # Restore the suffix so the bundle does not point at a file that does not exist. + # Restore the data_values suffix data = output_args[OutputCV.DATA.value] for key in [key for key in data if key.endswith("/data_values")]: entry = data.pop(key) From 33fdce3b3fc9883e8091cb0cfca30068af5a74a4 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 19:57:31 +1000 Subject: [PATCH 20/64] fix: do not let a misfiled row supersede the obs4REF one Mid-upgrade the re-ingested obs4REF row and the old misfiled obs4mips row sit side by side, and every one was reported as superseded. That told the user to retract exactly the row misfiled-obs4ref asks them to keep. Only a genuine obs4MIPs publication counts as one now. --- .../src/climate_ref/doctor/checks/data.py | 12 +++++++- .../climate-ref/tests/unit/test_doctor.py | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/climate-ref/src/climate_ref/doctor/checks/data.py b/packages/climate-ref/src/climate_ref/doctor/checks/data.py index b2cd1104d..46a997c6a 100644 --- a/packages/climate-ref/src/climate_ref/doctor/checks/data.py +++ b/packages/climate-ref/src/climate_ref/doctor/checks/data.py @@ -312,6 +312,10 @@ def check_superseded_obs4ref(context: DoctorContext) -> list[Finding]: The solver takes the obs4MIPs copy, so the obs4REF one is no longer used. This is the signal that a dataset can be dropped from the obs4REF registry. + A misfiled obs4REF row does not count as a publication. + Mid-upgrade the two sit side by side, and calling that superseded would tell the user + to retract the row `misfiled-obs4ref` is asking them to keep. + Parameters ---------- context @@ -327,7 +331,13 @@ def check_superseded_obs4ref(context: DoctorContext) -> list[Finding]: if not len(obs4mips) or not len(obs4ref) or "instance_id" not in obs4mips or "instance_id" not in obs4ref: return [] - published = set(obs_dataset_key(obs4mips["instance_id"])) + genuine = obs4mips + if "path" in obs4mips.columns: + genuine = obs4mips[~in_collection_directory(obs4mips["path"], "obs4REF")] + if not len(genuine): + return [] + + published = set(obs_dataset_key(genuine["instance_id"])) superseded = obs4ref[obs_dataset_key(obs4ref["instance_id"]).isin(published)] return [ Finding( diff --git a/packages/climate-ref/tests/unit/test_doctor.py b/packages/climate-ref/tests/unit/test_doctor.py index 52f6a2116..e2df296a0 100644 --- a/packages/climate-ref/tests/unit/test_doctor.py +++ b/packages/climate-ref/tests/unit/test_doctor.py @@ -244,6 +244,36 @@ def test_dataset_in_both_is_reported(self): assert findings[0].severity == Severity.INFO assert findings[0].summary.startswith("obs4REF.obs4REF.C.WECANN-1-0.mon.gpp.gn.v1") + def test_a_misfiled_row_does_not_supersede(self): + # Mid-upgrade both rows exist. Calling this superseded contradicts misfiled-obs4ref. + obs4mips = _catalog( + [ + ( + "obs4MIPs.obs4MIPs.C.WECANN-1-0.mon.gpp.gn.v2", + "WECANN-1-0", + "gpp", + "2007-01-01", + "2015-12-01", + "/d/obs4REF/C/gpp.nc", + ) + ] + ) + obs4ref = _catalog( + [ + ( + "obs4REF.obs4REF.C.WECANN-1-0.mon.gpp.gn.v1", + "WECANN-1-0", + "gpp", + "2007-01-01", + "2015-12-01", + "/d/obs4REF/C/gpp.nc", + ) + ] + ) + context = _context({SourceDatasetType.obs4MIPs: obs4mips, SourceDatasetType.obs4REF: obs4ref}) + + assert check_superseded_obs4ref(context) == [] + def test_nothing_ingested(self): assert check_superseded_obs4ref(_context()) == [] From 4a79440fa7a13a2abcf1fd5368c6d06dd61238e8 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Wed, 2 Sep 2026 21:49:53 +1000 Subject: [PATCH 21/64] chore: trim down docs --- changelog/898.feature.md | 5 +++-- docs/getting-started/02-download-datasets.md | 8 ++++---- docs/getting-started/03-ingest.md | 2 +- .../src/climate_ref/doctor/checks/data.py | 14 ++++++-------- packages/climate-ref/src/climate_ref/solver.py | 5 +++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/changelog/898.feature.md b/changelog/898.feature.md index 3a79f6399..895ec61b4 100644 --- a/changelog/898.feature.md +++ b/changelog/898.feature.md @@ -1,8 +1,9 @@ -Ingests the obs4REF collection under its own `obs4ref` source type, -so its `instance_id` starts with `obs4REF` and it is never mistaken for data fetched from the obs4MIPs archive. +Ingests the obs4REF collection under its own `obs4ref` source type. +The instance_id for `obs4ref` datasets now starts with `obs4ref`. The diagnostics keep asking for obs4MIPs data. Where a dataset is ingested from both, the obs4MIPs copy is used and obs4REF fills in the rest, so publishing a dataset to obs4MIPs takes over from the registry copy without any change to the REF. + `ref doctor` gains three checks: `misfiled-obs4ref`, `superseded-obs4ref` and `unsolvable-diagnostics`. Data ingested with `--source-type obs4mips` in earlier releases still solves, but re-ingesting it with `--source-type obs4ref` is recommended. diff --git a/docs/getting-started/02-download-datasets.md b/docs/getting-started/02-download-datasets.md index 521bc1761..fab002057 100644 --- a/docs/getting-started/02-download-datasets.md +++ b/docs/getting-started/02-download-datasets.md @@ -103,7 +103,8 @@ so only use it if you are **not** using that registry — see the warning below. Files land in the [intake-esgf `local_cache`](https://intake-esgf.readthedocs.io/en/latest/configure.html), and are ingested with the `obs4mips` source type. -The obs4REF collection is ingested with `obs4ref` instead, so the two are never confused. + +The obs4REF collection is ingested with the `obs4ref` source type instead /// admonition | Fetching these twice type: note @@ -112,9 +113,8 @@ The script also fetches `CERES-EBAF-4-2`, `GPCP-Monthly-3-2`, `HadISST-1-1` and These are the ESGF-published copies of datasets that were curated for the REF before publication, so the obs4REF registry ships them as well. -If you fetch the same dataset from ESGF as well as from the obs4REF registry, the ESGF copy is the one used. -obs4MIPs is the official home of the reference data, and the registry only fills in what is not published yet. -`ref doctor` lists the registry copies that have been superseded this way. +If you fetch the same dataset from ESGF/obs4MIPs as well as from the obs4REF registry, the ESGF copy is the one used. +obs4MIPs is the preferred home of the reference data, and the registry only fills in what is not published yet. /// ### Future work diff --git a/docs/getting-started/03-ingest.md b/docs/getting-started/03-ingest.md index 21d8f68a2..e44908801 100644 --- a/docs/getting-started/03-ingest.md +++ b/docs/getting-started/03-ingest.md @@ -11,7 +11,7 @@ Before you begin, ensure you have: ## 1. Ingest reference datasets The `obs4REF` collection we downloaded in the previous step is ingested under the `obs4ref` source type. -The files follow the obs4MIPs conventions. +The files follow the obs4MIPs conventions, but are not yet published on ESGF. Where a dataset is ingested from both, the obs4MIPs copy is used, and then it falls back to obs4REF. This command will extract metadata from the files and store it in the Climate-REF catalog, and print a summary of the ingested datasets. diff --git a/packages/climate-ref/src/climate_ref/doctor/checks/data.py b/packages/climate-ref/src/climate_ref/doctor/checks/data.py index 46a997c6a..c2b067e00 100644 --- a/packages/climate-ref/src/climate_ref/doctor/checks/data.py +++ b/packages/climate-ref/src/climate_ref/doctor/checks/data.py @@ -312,10 +312,6 @@ def check_superseded_obs4ref(context: DoctorContext) -> list[Finding]: The solver takes the obs4MIPs copy, so the obs4REF one is no longer used. This is the signal that a dataset can be dropped from the obs4REF registry. - A misfiled obs4REF row does not count as a publication. - Mid-upgrade the two sit side by side, and calling that superseded would tell the user - to retract the row `misfiled-obs4ref` is asking them to keep. - Parameters ---------- context @@ -362,7 +358,10 @@ def check_unsolvable_diagnostics(context: DoctorContext) -> list[Finding]: This runs the solver against the ingested catalogs, diagnostic by diagnostic, so it catches everything the narrower checks do not: - a filter no dataset matches, a constraint no group satisfies, a source type nothing was ingested under. + + - a filter no dataset matches + - a constraint no group satisfies + - a source type nothing was ingested under Parameters ---------- @@ -408,9 +407,8 @@ def _why_unsolvable( """ Explain which requirement the ingested data fails to meet. - Each requirement is checked on its own, so the first one with no matching group names - the data to fetch. When every requirement matches something, the failure lies in how - they combine, which is reported as such. + Each requirement is checked on its own, so the first one with no matching group names the data to fetch. + When every requirement matches something, the failure lies in howthey combine, which is reported as such. """ reasons = [] for requirements in normalize_requirement_sets(diagnostic.data_requirements): diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py index 512f859a4..50256c635 100644 --- a/packages/climate-ref/src/climate_ref/solver.py +++ b/packages/climate-ref/src/climate_ref/solver.py @@ -288,8 +288,9 @@ def with_obs4ref_fallback( ------- : The obs4MIPs catalog, extended with the obs4REF datasets it does not hold. - This includes the case of nothing being ingested as obs4MIPs at all, which is an ordinary - deployment that fetched only the registry. + + This includes the case of nothing being ingested as obs4MIPs at all + which is an ordinary deployment that fetched only the registry. The original catalog is returned untouched when there is nothing to add. A merge of two catalogs carries no adapter, so it cannot reload itself and lose the added rows. The added rows carry ``activity_id`` of obs4MIPs, because that is the collection they stand in for. From 9930e264c93ebdba949477c5f664fabbf7d0740b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:47:14 +0000 Subject: [PATCH 22/64] chore(deps): bump mistune from 3.3.0 to 3.3.3 Bumps [mistune](https://github.com/lepture/mistune) from 3.3.0 to 3.3.3. - [Release notes](https://github.com/lepture/mistune/releases) - [Changelog](https://github.com/lepture/mistune/blob/main/docs/changes.rst) - [Commits](https://github.com/lepture/mistune/compare/v3.3.0...v3.3.3) --- updated-dependencies: - dependency-name: mistune dependency-version: 3.3.3 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 55c5245d1..8e47a0916 100644 --- a/uv.lock +++ b/uv.lock @@ -2492,11 +2492,11 @@ wheels = [ [[package]] name = "mistune" -version = "3.3.0" +version = "3.3.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/9c/1939635275ec7258e2b43b00dafabc36d89ad11aa7838d375dc1b0e561cb/mistune-3.3.0.tar.gz", hash = "sha256:3074ec4c61b384abe725128e4dcbb483f5a09cc4632012505cdee655d3a113b9", size = 110936, upload-time = "2026-06-21T13:11:39.458Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/a5/2dab368d6950e6808904dec98f54c7e726ee7be4a0c6afe00e6e011bd52d/mistune-3.3.3.tar.gz", hash = "sha256:c4c6c0c840b8637a2e9b8b6d607eb7c8f00888bf14c754409bcd339e848c2477", size = 115363, upload-time = "2026-07-09T06:18:05.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/76/b90f9d48d43fbd80a79a20d3eab2e5109859c7a56dc663b23187385898f3/mistune-3.3.0-py3-none-any.whl", hash = "sha256:a758e578acda49d8195f9a860b132dae2cf7bf409381393b1c4e6e489a65397b", size = 61250, upload-time = "2026-06-21T13:11:37.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/70/b1e4737b84163db5bb1dfde6f216dbfbf32783330a9989c965e121172830/mistune-3.3.3-py3-none-any.whl", hash = "sha256:99de1585e42dcbd826faa9e11a202727a5e202e4e4722a4c69ac1ff615793dd7", size = 63569, upload-time = "2026-07-09T06:18:03.839Z" }, ] [[package]] From ab16d020723254d1515056e1f11f334147ec78d2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:50:05 +0000 Subject: [PATCH 23/64] chore(deps): update github actions --- .github/actions/build-container/action.yml | 6 +++--- .github/workflows/deploy.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/actions/build-container/action.yml b/.github/actions/build-container/action.yml index bb6087159..ee7c2c125 100644 --- a/.github/actions/build-container/action.yml +++ b/.github/actions/build-container/action.yml @@ -10,7 +10,7 @@ inputs: runs: using: "composite" steps: - - uses: docker/metadata-action@v6.0.0 + - uses: docker/metadata-action@v6.2.0 id: metadata with: images: ghcr.io/${{ github.repository_owner }}/${{ inputs.container-name }} @@ -19,7 +19,7 @@ runs: type=ref,event=tag # set latest tag for default branch type=raw,value=main,enable={{is_default_branch}} - - uses: docker/build-push-action@v7.1.0 + - uses: docker/build-push-action@v7.3.0 id: push with: cache-from: type=gha @@ -32,7 +32,7 @@ runs: labels: ${{ steps.metadata.outputs.labels }} - name: Attest to REF image if: ${{ ! github.event.pull_request.head.repo.fork }} - uses: actions/attest@v4.1.0 + uses: actions/attest@v4.2.2 with: subject-name: ghcr.io/${{ github.repository_owner }}/${{ inputs.container-name }} subject-digest: ${{ steps.push.outputs.digest }} diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index ac1edaed4..7f7933e11 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -33,7 +33,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v10.0.1 with: - version: "0.11.6" + version: "0.12.7" python-version: "3.12" - name: Verify installable # TODO: this step fails we fix https://github.com/Climate-REF/climate-ref/issues/217 From b47b9c34a16e61ce0c634828b159c77e353f3cf8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:00:49 +0000 Subject: [PATCH 24/64] chore(deps): update dependency gitpython to v3.1.59 [security] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 55c5245d1..119ac60dd 100644 --- a/uv.lock +++ b/uv.lock @@ -1551,14 +1551,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.58" +version = "3.1.61" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/d6/5f358ff283325580c2003a6d953aea18cfe10ae87b46f5ebc80fa3a386dc/gitpython-3.1.58.tar.gz", hash = "sha256:621416df10ef3fd0e19fabf9172ddeed0fa704d353d04f194eec56a625a95b22", size = 228498, upload-time = "2026-08-04T15:05:49.47Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/61/3285044215fb596bf093e39ccb96ece0a1076a8ca57a61e069a6a33cdb1b/gitpython-3.1.61.tar.gz", hash = "sha256:f51c24d8c0f733a195447385f5774a5dfe8767f5acfd7994a33755644c6ecc95", size = 231680, upload-time = "2026-08-28T11:01:13.761Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/0c/9d8752098bc442f0726e64aa6135940b3a96809915d1aa4206c1bb97881d/gitpython-3.1.58-py3-none-any.whl", hash = "sha256:d331e722577f0fd7fc1f857419b3ecc07af66282b933d2a4d95f84a042fdd50f", size = 220183, upload-time = "2026-08-04T15:05:48.025Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/49cc172da4d0578644ba37cec5cb365b1fefc603b26edea9bcac1c7f830a/gitpython-3.1.61-py3-none-any.whl", hash = "sha256:8ab28c9da863cdd9e7d7694ec46cf3e6c9a12d8a30a1acd3447aec11975d530c", size = 222118, upload-time = "2026-08-28T11:01:12.262Z" }, ] [[package]] From 56148f06ab20d9aa16f78e5df166ddacca92d247 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:28:24 +0000 Subject: [PATCH 25/64] chore(deps-dev): bump mkdocs-material from 9.7.6 to 9.7.7 Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.7.6 to 9.7.7. - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.7.6...9.7.7) --- updated-dependencies: - dependency-name: mkdocs-material dependency-version: 9.7.7 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 55c5245d1..f0a66c723 100644 --- a/uv.lock +++ b/uv.lock @@ -2596,7 +2596,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.7.6" +version = "9.7.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -2611,9 +2611,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, ] [[package]] From f8ecfa7379eb559f004ecdf3811ce5d25f2d0e0d Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 10:38:56 +1000 Subject: [PATCH 26/64] fix: drop the immutable flag from read-only SQLite connections The immutable flag tells SQLite that the file cannot change, so it skips locking entirely. That does not hold when a background run is writing to the database, and the stale page cache shows up as flaky reads. Opens the database with mode=ro only. This still blocks writes, but it keeps normal locking so concurrent writers are handled correctly. --- docs/background/architecture.md | 2 +- docs/how-to-guides/reading-results-locally.md | 2 +- packages/climate-ref/src/climate_ref/database.py | 6 +++--- packages/climate-ref/tests/unit/test_database.py | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/background/architecture.md b/docs/background/architecture.md index 4e220bb90..04e7be92c 100644 --- a/docs/background/architecture.md +++ b/docs/background/architecture.md @@ -365,7 +365,7 @@ Key characteristics: - The backend is a **read-only consumer** of the REF database. It opens the database without running migrations - (SQLite can be opened immutable so the state volume can be mounted read-only) + (SQLite is opened read-only so the state volume can be mounted read-only) and reports the results the compute engine has already produced. - It depends on the `climate-ref` library directly for configuration, models and the provider registry. The database schema remains owned by `climate-ref` migrations. diff --git a/docs/how-to-guides/reading-results-locally.md b/docs/how-to-guides/reading-results-locally.md index a787badb0..dbc0489d1 100644 --- a/docs/how-to-guides/reading-results-locally.md +++ b/docs/how-to-guides/reading-results-locally.md @@ -38,7 +38,7 @@ with Database.from_config(config, read_only=True) as db: # `df` is a plain DataFrame and remains valid out here, after the session has closed. ``` -`read_only=True` opens SQLite in immutable read-only mode and skips migrations, +`read_only=True` opens SQLite in read-only mode and skips migrations, so the read layer never mutates the database you point it at. `Reader` is a thin entry point. diff --git a/packages/climate-ref/src/climate_ref/database.py b/packages/climate-ref/src/climate_ref/database.py index 1c029a230..25910b4de 100644 --- a/packages/climate-ref/src/climate_ref/database.py +++ b/packages/climate-ref/src/climate_ref/database.py @@ -99,10 +99,10 @@ def _make_readonly_sqlite_url(database_url: str) -> tuple[str, dict[str, Any]]: return database_url, {} if encoded_path.startswith("file:"): - # Already URI form — caller is responsible for any ro/immutable flags. + # Already URI form — caller is responsible for any read-only flags. return database_url, {"uri": True} - return f"sqlite:///file:{encoded_path}?mode=ro&immutable=1&uri=true", {"uri": True} + return f"sqlite:///file:{encoded_path}?mode=ro&uri=true", {"uri": True} def _get_database_revision(connection: sqlalchemy.engine.Connection) -> str | None: @@ -469,7 +469,7 @@ def from_config( read_only If True, open the database in read-only mode and skip migrations. - SQLite URLs are rewritten to URI form with ``mode=ro&immutable=1``. + SQLite URLs are rewritten to URI form with ``mode=ro``. For other backends, callers must configure the connecting role as read-only themselves. diff --git a/packages/climate-ref/tests/unit/test_database.py b/packages/climate-ref/tests/unit/test_database.py index e86cfb834..1f3ac8a81 100644 --- a/packages/climate-ref/tests/unit/test_database.py +++ b/packages/climate-ref/tests/unit/test_database.py @@ -556,14 +556,14 @@ class TestReadOnlyDatabase: def test_make_readonly_sqlite_url_rewrites_file_url(self): url, connect_args = _make_readonly_sqlite_url("sqlite:////tmp/foo.db") - assert url == "sqlite:///file:/tmp/foo.db?mode=ro&immutable=1&uri=true" + assert url == "sqlite:///file:/tmp/foo.db?mode=ro&uri=true" assert connect_args == {"uri": True} def test_make_readonly_sqlite_url_preserves_percent_encoding(self): """Percent-encoded characters must survive the rewrite unchanged.""" original = "sqlite:///path%20with%20spaces/db.sqlite" url, connect_args = _make_readonly_sqlite_url(original) - assert url == "sqlite:///file:path%20with%20spaces/db.sqlite?mode=ro&immutable=1&uri=true" + assert url == "sqlite:///file:path%20with%20spaces/db.sqlite?mode=ro&uri=true" assert connect_args == {"uri": True} def test_make_readonly_sqlite_url_preserves_uri_form(self): @@ -589,7 +589,7 @@ def test_get_sqlite_path_returns_none_for_uri_form(self): def test_validate_accepts_uri_form_without_mkdir(self, tmp_path): """URI-form URLs are accepted verbatim and do not trigger parent mkdir.""" missing = tmp_path / "does-not-exist" / "foo.db" - url = f"sqlite:///file:{missing}?mode=ro&immutable=1&uri=true" + url = f"sqlite:///file:{missing}?mode=ro&uri=true" # Should not create the parent directory assert validate_database_url(url) == url From 9ecfdc200d41c310a6f0011c37e964beb6c7840e Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 10:39:18 +1000 Subject: [PATCH 27/64] chore: add changelog fragment --- changelog/908.fix.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/908.fix.md diff --git a/changelog/908.fix.md b/changelog/908.fix.md new file mode 100644 index 000000000..ca5d8fe8a --- /dev/null +++ b/changelog/908.fix.md @@ -0,0 +1,3 @@ +Removed the `immutable` flag from read-only SQLite connections. +The flag told SQLite to skip locking, which did not hold when a background run was writing +to the same database. The connection is still opened read-only. From ed5614e948e22ac4e7ef693f6ed0eba00433a4ea Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 10:39:54 +1000 Subject: [PATCH 28/64] docs: tidy the read-only wording --- docs/background/architecture.md | 2 +- packages/climate-ref/src/climate_ref/database.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/background/architecture.md b/docs/background/architecture.md index 04e7be92c..bd51ca135 100644 --- a/docs/background/architecture.md +++ b/docs/background/architecture.md @@ -365,7 +365,7 @@ Key characteristics: - The backend is a **read-only consumer** of the REF database. It opens the database without running migrations - (SQLite is opened read-only so the state volume can be mounted read-only) + (SQLite is opened with `mode=ro`, so the state volume can be mounted read-only) and reports the results the compute engine has already produced. - It depends on the `climate-ref` library directly for configuration, models and the provider registry. The database schema remains owned by `climate-ref` migrations. diff --git a/packages/climate-ref/src/climate_ref/database.py b/packages/climate-ref/src/climate_ref/database.py index 25910b4de..d32ceb888 100644 --- a/packages/climate-ref/src/climate_ref/database.py +++ b/packages/climate-ref/src/climate_ref/database.py @@ -99,7 +99,7 @@ def _make_readonly_sqlite_url(database_url: str) -> tuple[str, dict[str, Any]]: return database_url, {} if encoded_path.startswith("file:"): - # Already URI form — caller is responsible for any read-only flags. + # Already URI form, so the caller is responsible for any read-only flags. return database_url, {"uri": True} return f"sqlite:///file:{encoded_path}?mode=ro&uri=true", {"uri": True} From 1711f567f89a45ef7d1833e567865d7e6c0fcd33 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 11:02:21 +1000 Subject: [PATCH 29/64] feat: add `ref test-cases diff` to render baseline changes as a local HTML report Adds a CLI verb that compares every committed `manifest.json` against a base ref and writes a static HTML site to a local directory. - Images render old and new side by side, with a button that overlays them. - Text outputs get a coloured unified diff, with JSON pretty-printed first. - NetCDF and other binaries get a size delta and a link to each blob. - There is no size cap, so every changed case gets full detail. Blobs are read through `NativeStore.fetch` rather than raw urllib, so the report reuses the existing cache and hash verification. `--no-fetch` skips downloads and reports sizes only. The rendering splits into three stages. `collect` reads the manifests, `analyse` fetches blobs and builds the diffs, and `render` places the result into Jinja templates. Python decides and templates place, so no module here builds HTML. `scripts/ci/mint_diff.py` is untouched. The mint workflow still runs it until the CI slice replaces it. --- packages/climate-ref/pyproject.toml | 1 + .../climate_ref/baseline_report/__init__.py | 16 + .../climate_ref/baseline_report/analyse.py | 361 ++++++++++++++++++ .../climate_ref/baseline_report/collect.py | 361 ++++++++++++++++++ .../src/climate_ref/baseline_report/render.py | 165 ++++++++ .../baseline_report/templates/base.html.j2 | 23 ++ .../baseline_report/templates/case.html.j2 | 60 +++ .../baseline_report/templates/index.html.j2 | 42 ++ .../baseline_report/templates/macros.html.j2 | 75 ++++ .../baseline_report/templates/report.css | 156 ++++++++ .../baseline_report/templates/report.js | 10 + .../climate_ref/cli/test_cases/__init__.py | 3 +- .../src/climate_ref/cli/test_cases/diff.py | 71 ++++ .../tests/unit/baseline_report/__init__.py | 1 + .../unit/baseline_report/test_analyse.py | 191 +++++++++ .../unit/baseline_report/test_collect.py | 162 ++++++++ .../tests/unit/baseline_report/test_render.py | 233 +++++++++++ .../tests/unit/cli/test_test_cases.py | 62 +++ uv.lock | 2 + 19 files changed, 1994 insertions(+), 1 deletion(-) create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/__init__.py create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/analyse.py create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/collect.py create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/render.py create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/templates/base.html.j2 create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2 create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/templates/index.html.j2 create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/templates/report.css create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/templates/report.js create mode 100644 packages/climate-ref/src/climate_ref/cli/test_cases/diff.py create mode 100644 packages/climate-ref/tests/unit/baseline_report/__init__.py create mode 100644 packages/climate-ref/tests/unit/baseline_report/test_analyse.py create mode 100644 packages/climate-ref/tests/unit/baseline_report/test_collect.py create mode 100644 packages/climate-ref/tests/unit/baseline_report/test_render.py diff --git a/packages/climate-ref/pyproject.toml b/packages/climate-ref/pyproject.toml index 847fc7bde..2ba683baa 100644 --- a/packages/climate-ref/pyproject.toml +++ b/packages/climate-ref/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "platformdirs>=4.3.6", "tqdm>=4.67.1", "gitpython>=3.1.58", + "jinja2>=3.1", # parsl doesn't support Windows yet # We don't target Windows either, but this __might__ allow Windows users to install the package 'parsl>=2025.5.19; sys_platform != "win32"' diff --git a/packages/climate-ref/src/climate_ref/baseline_report/__init__.py b/packages/climate-ref/src/climate_ref/baseline_report/__init__.py new file mode 100644 index 000000000..8a743e7f5 --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/__init__.py @@ -0,0 +1,16 @@ +""" +Render an HTML report of the regression baselines changed on a branch. + +A mint rewrites each test case's ``manifest.json`` and uploads the curated native outputs to the +content-addressed object store. +The manifest diff therefore names every native file that changed, and both the old and the new blob +remain fetchable by digest, so the change can be reviewed without checking anything out locally. + +The pipeline runs in three stages: + +- :mod:`~climate_ref.baseline_report.collect` reads the manifests either side of the base ref. +- :mod:`~climate_ref.baseline_report.analyse` fetches text blobs and builds the diffs. +- :mod:`~climate_ref.baseline_report.render` writes the static site. + +Python decides and templates place, so no module here builds HTML. +""" diff --git a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py new file mode 100644 index 000000000..557181c3c --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py @@ -0,0 +1,361 @@ +""" +Turn a collected report into everything the templates need. + +Text blobs are fetched from the native store and diffed here. Every URL, count and diff line +is computed in this module, so the templates only loop and place. +""" + +from __future__ import annotations + +import difflib +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from attrs import frozen + +from climate_ref.baseline_report.collect import CaseChange, FileChange, FileKind, Report + +if TYPE_CHECKING: + from climate_ref_core.regression.manifest import NativeEntry + from climate_ref_core.regression.store import NativeStore + +# A blob larger than this is summarised rather than diffed. Keeps a runaway report +# from stalling the job on the download alone. +MAX_FETCH_BYTES = 2_000_000 + +# Unified-diff lines kept per file before the rest is elided. +MAX_DIFF_LINES = 5000 + + +@frozen +class DiffLine: + """One line of a unified diff, tagged so a template can style it.""" + + kind: str + """One of ``add``, ``remove``, ``context``, ``hunk`` or ``header``.""" + + text: str + """The line itself, without a trailing newline.""" + + +@frozen +class TextDiff: + """The unified diff of one text output, or the reason there is not one.""" + + lines: tuple[DiffLine, ...] + """The diff lines, empty when :attr:`note` is set.""" + + note: str | None + """Why no diff could be produced, or ``None`` when :attr:`lines` carries one.""" + + elided: int + """Lines dropped past :data:`MAX_DIFF_LINES`.""" + + +@frozen +class AnalysedFile: + """One native file, with its blob URLs and its diff where it has one.""" + + change: FileChange + """The underlying change.""" + + old_url: str | None + """Store URL of the base blob, or ``None`` when the file is new.""" + + new_url: str | None + """Store URL of the head blob, or ``None`` when the file was removed.""" + + text: TextDiff | None + """The diff, set only for :attr:`~climate_ref.baseline_report.collect.FileKind.TEXT` files.""" + + +@frozen +class AnalysedCase: + """One test case, with its files analysed and tallied.""" + + change: CaseChange + """The underlying change.""" + + files: tuple[AnalysedFile, ...] + """Every analysed file, in the order collection produced them.""" + + counts: dict[str, dict[str, int]] + """``kind -> {added, changed, removed}``, with every kind present.""" + + +@frozen +class AnalysedReport: + """A whole report, ready to render.""" + + report: Report + """The underlying report.""" + + store_url: str + """Base URL of the native store, without a trailing slash.""" + + cases: tuple[AnalysedCase, ...] + """The analysed cases, in the order collection produced them.""" + + +def blob_url(store_url: str, digest: str) -> str: + """ + Build the URL a blob is served from. + + Parameters + ---------- + store_url + Base URL of the native store. + digest + The blob's sha256 hex digest. + + Returns + ------- + : + The URL. + """ + return f"{store_url.rstrip('/')}/{digest}" + + +def _as_lines(path: Path | None, name: str) -> list[str]: + """ + Decode a blob into diffable lines. + + JSON is re-serialised with indentation first, because a minified bundle would otherwise + diff as a single unreadable line. + + Parameters + ---------- + path + The fetched blob, or ``None`` when the file is absent on that side. + name + The file's name, used to decide whether it is JSON. + + Returns + ------- + : + The lines to diff. + """ + if path is None: + return [] + text = path.read_bytes().decode("utf-8", errors="replace") + if Path(name).suffix.lower() == ".json": + try: + text = json.dumps(json.loads(text), indent=2, sort_keys=True) + except json.JSONDecodeError: + pass + return text.splitlines() + + +def _classify_line(line: str) -> str: + """ + Tag one unified-diff line with the CSS class a template should use. + + Parameters + ---------- + line + The raw diff line. + + Returns + ------- + : + One of ``header``, ``hunk``, ``add``, ``remove`` or ``context``. + """ + if line.startswith(("---", "+++")): + return "header" + if line.startswith("@@"): + return "hunk" + if line.startswith("+"): + return "add" + if line.startswith("-"): + return "remove" + return "context" + + +def text_diff(old: Path | None, new: Path | None, name: str) -> TextDiff: + """ + Build the unified diff between two fetched blobs. + + Parameters + ---------- + old + The base blob, or ``None`` when the file is new. + new + The head blob, or ``None`` when the file was removed. + name + The file's name, used in the diff header and to detect JSON. + + Returns + ------- + : + The diff, or a note explaining why there is not one. + """ + raw = list( + difflib.unified_diff( + _as_lines(old, name), + _as_lines(new, name), + fromfile="old" if old is not None else "(absent)", + tofile="new" if new is not None else "(absent)", + lineterm="", + n=3, + ) + ) + if not raw: + return TextDiff(lines=(), note="identical after decoding", elided=0) + elided = max(len(raw) - MAX_DIFF_LINES, 0) + kept = raw[:MAX_DIFF_LINES] + return TextDiff( + lines=tuple(DiffLine(kind=_classify_line(line), text=line) for line in kept), + note=None, + elided=elided, + ) + + +def _fetch_side( + store: NativeStore, entry: NativeEntry | None, workdir: Path +) -> tuple[Path | None, str | None]: + """ + Fetch one side of a text file. + + Parameters + ---------- + store + The store to read from. + entry + The manifest entry, or ``None`` when the file is absent on that side. + workdir + Directory the blob is written into. + + Returns + ------- + : + A ``(path, note)`` pair. ``path`` is ``None`` when the blob is absent or unfetchable, + and ``note`` describes a failure. + """ + if entry is None: + return None, None + if entry.size > MAX_FETCH_BYTES: + return None, f"too large to diff ({entry.size:,} B)" + digest = entry.sha256 + dest = workdir / digest + if dest.exists(): + return dest, None + try: + store.fetch(digest, dest) + except (OSError, ValueError) as exc: + return None, f"could not fetch {digest[:12]} ({exc})" + return dest, None + + +def _analyse_file( + change: FileChange, + store: NativeStore, + store_url: str, + *, + fetch: bool, + workdir: Path, +) -> AnalysedFile: + """ + Build the URLs and, for text, the diff of one native file. + + Parameters + ---------- + change + The file that moved. + store + The store to read blobs from. + store_url + Base URL of the store, used to build links. + fetch + Whether blobs may be downloaded. + workdir + Directory fetched blobs are written into. + + Returns + ------- + : + The analysed file. + """ + old_url = blob_url(store_url, change.old.sha256) if change.old else None + new_url = blob_url(store_url, change.new.sha256) if change.new else None + if change.kind is not FileKind.TEXT: + return AnalysedFile(change=change, old_url=old_url, new_url=new_url, text=None) + if not fetch: + return AnalysedFile( + change=change, + old_url=old_url, + new_url=new_url, + text=TextDiff(lines=(), note="fetching disabled", elided=0), + ) + + old_path, old_note = _fetch_side(store, change.old, workdir) + new_path, new_note = _fetch_side(store, change.new, workdir) + note = old_note or new_note + if note is not None: + return AnalysedFile( + change=change, + old_url=old_url, + new_url=new_url, + text=TextDiff(lines=(), note=note, elided=0), + ) + return AnalysedFile( + change=change, + old_url=old_url, + new_url=new_url, + text=text_diff(old_path, new_path, change.name), + ) + + +def _counts(files: tuple[AnalysedFile, ...]) -> dict[str, dict[str, int]]: + """ + Tally each file kind's added, changed and removed counts. + + Parameters + ---------- + files + The analysed files. + + Returns + ------- + : + ``kind -> {added, changed, removed}``, with every kind present so a template + never has to test for a missing key. + """ + tally = {kind.value: {"added": 0, "changed": 0, "removed": 0} for kind in FileKind} + for analysed in files: + tally[analysed.change.kind.value][analysed.change.status] += 1 + return tally + + +def analyse(report: Report, store: NativeStore, *, fetch: bool, workdir: Path) -> AnalysedReport: + """ + Build everything the templates need from a collected report. + + A blob that cannot be fetched becomes a note on its file rather than an exception, because + one unreachable object should not cost the whole report. + + Parameters + ---------- + report + The collected report. + store + The store to read blobs from. + fetch + Whether blobs may be downloaded. With ``False`` every text file carries a note instead + of a diff and the store is never called. + workdir + Directory fetched blobs are written into. + + Returns + ------- + : + The analysed report. + """ + store_url = store.url.rstrip("/") + cases = [] + for case in report.cases: + files = tuple( + _analyse_file(change, store, store_url, fetch=fetch, workdir=workdir) for change in case.files + ) + cases.append(AnalysedCase(change=case, files=files, counts=_counts(files))) + return AnalysedReport(report=report, store_url=store_url, cases=tuple(cases)) diff --git a/packages/climate-ref/src/climate_ref/baseline_report/collect.py b/packages/climate-ref/src/climate_ref/baseline_report/collect.py new file mode 100644 index 000000000..f594bd154 --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/collect.py @@ -0,0 +1,361 @@ +""" +Read the manifests either side of a base ref and pair up every native file that moved. + +Collection performs no network access. Fetching blobs and building diffs is +:mod:`~climate_ref.baseline_report.analyse`'s job. +""" + +from __future__ import annotations + +import enum +from pathlib import Path +from typing import TYPE_CHECKING + +from attrs import frozen + +from climate_ref_core.regression.manifest import Manifest, NativeEntry + +if TYPE_CHECKING: + from git import Repo + +# A manifest path needs a diagnostic and test-case directory before a label can be built from it. +_MIN_LABEL_PARTS = 3 + +IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".svg"}) +"""Extensions rendered as a two-up image comparison.""" + +TEXT_SUFFIXES = frozenset({".json", ".csv", ".yml", ".yaml", ".html", ".txt", ".md", ".log"}) +"""Extensions whose blobs are worth fetching and diffing line by line.""" + +NETCDF_SUFFIXES = frozenset({".nc"}) +"""Extensions reported as a size delta until the NetCDF analysis slice lands.""" + + +class FileKind(enum.Enum): + """How a native output file should be presented in the report.""" + + IMAGE = "image" + TEXT = "text" + NETCDF = "netcdf" + OTHER = "other" + + +def classify(name: str) -> FileKind: + """ + Classify a native output file by its extension. + + Parameters + ---------- + name + The file's path relative to the test case's native output directory. + + Returns + ------- + : + The kind that decides how the file is rendered. + """ + suffix = Path(name).suffix.lower() + if suffix in IMAGE_SUFFIXES: + return FileKind.IMAGE + if suffix in TEXT_SUFFIXES: + return FileKind.TEXT + if suffix in NETCDF_SUFFIXES: + return FileKind.NETCDF + return FileKind.OTHER + + +@frozen +class FileChange: + """One native output file that was added, removed, or changed by the mint.""" + + name: str + """Path of the file relative to the test case's native output directory.""" + + old: NativeEntry | None + """The manifest entry on the base ref, or ``None`` when the file is new.""" + + new: NativeEntry | None + """The manifest entry on HEAD, or ``None`` when the file was removed.""" + + kind: FileKind + """How the file should be rendered.""" + + @property + def status(self) -> str: + """``added``, ``removed`` or ``changed``.""" + if self.old is None: + return "added" + if self.new is None: + return "removed" + return "changed" + + +@frozen +class CaseChange: + """Everything that changed for a single test case.""" + + label: str + """``provider/diagnostic/test-case``.""" + + slug: str + """The output subdirectory for this case's page. Equal to :attr:`label`.""" + + rel_path: str + """Repo-relative path of the case's ``manifest.json``.""" + + base: Manifest | None + """The manifest on the base ref, or ``None`` when the case is new.""" + + head: Manifest | None + """The manifest on HEAD, or ``None`` when the case was removed.""" + + files: tuple[FileChange, ...] + """Every native file that moved, in name order.""" + + committed: tuple[str, ...] + """Committed artefacts whose digest moved, described one per entry.""" + + metadata: tuple[str, ...] + """Scalar manifest fields that moved, described one per entry.""" + + @property + def is_new(self) -> bool: + """Whether the whole test case is new on this branch.""" + return self.base is None + + @property + def is_removed(self) -> bool: + """Whether the whole test case was deleted on this branch.""" + return self.head is None + + +@frozen +class Report: + """Every test case whose baseline moved on this branch.""" + + base_ref: str + """The git ref HEAD was compared against.""" + + head_sha: str + """The full sha of the commit that was compared.""" + + cases: tuple[CaseChange, ...] + """The changed cases, in label order.""" + + +def changed_manifests(repo: Repo, base: str) -> list[str]: + """ + Return the repo-relative paths of every test-case manifest that differs from ``base``. + + Uses the merge-base (``base...HEAD``) so commits landing on the base branch after the + feature branch forked are not misreported as baseline changes. + + Parameters + ---------- + repo + The repository to diff in. + base + The git ref to compare against. + + Returns + ------- + : + The manifest paths, sorted. + """ + from git import GitCommandError # noqa: PLC0415 - keeps the import cost off the CLI startup path + + pathspec = ":(glob)packages/**/test-data/**/manifest.json" + try: + out = repo.git.diff("--name-only", f"{base}...HEAD", "--", pathspec) + except GitCommandError: + # A shallow clone may have no merge-base with the base ref. A two-dot diff over-reports + # (it also shows base-branch commits), which is the safe direction for a report. + out = repo.git.diff("--name-only", base, "HEAD", "--", pathspec) + return sorted(line for line in out.splitlines() if line.strip()) + + +def load_at_ref(repo: Repo, ref: str, rel_path: str) -> Manifest | None: + """ + Load a manifest as it exists at ``ref``, or ``None`` when absent there. + + Parameters + ---------- + repo + The repository to read from. + ref + The git ref to read the manifest at. + rel_path + Repo-relative path of the manifest. + + Returns + ------- + : + The parsed manifest, or ``None`` when the path does not exist at ``ref``. + """ + from git import GitCommandError # noqa: PLC0415 - keeps the import cost off the CLI startup path + + try: + text = repo.git.show(f"{ref}:{rel_path}") + except GitCommandError: + return None + return Manifest.loads(text, source=f"{ref}:{rel_path}") + + +def case_label(rel_path: str) -> str: + """ + Derive a ``provider/diagnostic/test-case`` label from a manifest path. + + ``packages/climate-ref-pmp/tests/test-data/annual-cycle/cmip6-ts/manifest.json`` + becomes ``pmp/annual-cycle/cmip6-ts``. + + Parameters + ---------- + rel_path + Repo-relative path of the manifest. + + Returns + ------- + : + The label. + """ + parts = Path(rel_path).parts + provider = parts[1].removeprefix("climate-ref-") if len(parts) > 1 else "?" + tail = parts[-3:-1] if len(parts) >= _MIN_LABEL_PARTS else () + return "/".join((provider, *tail)) + + +def _metadata_changes(base: Manifest | None, head: Manifest | None) -> tuple[str, ...]: + """ + Describe the scalar manifest fields that moved. + + Parameters + ---------- + base + The manifest on the base ref, or ``None``. + head + The manifest on HEAD, or ``None``. + + Returns + ------- + : + One description per changed field. + """ + if head is None: + return ("test case removed",) + if base is None: + return (f"new test case at test_case_version {head.test_case_version}",) + changes = [] + for name in ("test_case_version", "diagnostic_version", "catalog_hash", "schema"): + old, new = getattr(base, name), getattr(head, name) + if old != new: + changes.append(f"{name}: {old} -> {new}") + return tuple(changes) + + +def _committed_changes(base: Manifest | None, head: Manifest | None) -> tuple[str, ...]: + """ + Name the committed regression artefacts whose digest moved. + + Parameters + ---------- + base + The manifest on the base ref, or ``None``. + head + The manifest on HEAD, or ``None``. + + Returns + ------- + : + One description per changed artefact, in name order. + """ + old = base.committed if base else {} + new = head.committed if head else {} + out = [] + for name in sorted(set(old) | set(new)): + if old.get(name) == new.get(name): + continue + if name not in old: + out.append(f"{name} (added)") + elif name not in new: + out.append(f"{name} (removed)") + else: + out.append(name) + return tuple(out) + + +def build_case_change(repo: Repo, base: str, rel_path: str) -> CaseChange | None: + """ + Collect every change to one test case. + + A case deleted on this branch has no head manifest. It is still reported, as a removal, + because dropping a test case is a baseline change a reviewer needs to see. + + Parameters + ---------- + repo + The repository to read from. + base + The git ref to compare against. + rel_path + Repo-relative path of the case's manifest. + + Returns + ------- + : + The case's changes, or ``None`` when the manifest is absent from both sides, + which leaves nothing to say. + """ + head_path = Path(repo.working_tree_dir or ".") / rel_path + head = Manifest.load(head_path) if head_path.exists() else None + base_manifest = load_at_ref(repo, base, rel_path) + if head is None and base_manifest is None: + return None + + old_native = base_manifest.native if base_manifest else {} + new_native = head.native if head else {} + files = [] + for name in sorted(set(old_native) | set(new_native)): + old, new = old_native.get(name), new_native.get(name) + if old is not None and new is not None and old.sha256 == new.sha256: + continue + files.append(FileChange(name=name, old=old, new=new, kind=classify(name))) + + label = case_label(rel_path) + return CaseChange( + label=label, + slug=label, + rel_path=rel_path, + base=base_manifest, + head=head, + files=tuple(files), + committed=_committed_changes(base_manifest, head), + metadata=_metadata_changes(base_manifest, head), + ) + + +def collect(repo: Repo, base: str) -> Report: + """ + Collect every baseline change on this branch. + + Parameters + ---------- + repo + The repository to read from. + base + The git ref to compare against. + + Returns + ------- + : + The report, with cases in label order. + """ + cases = [ + case + for rel_path in changed_manifests(repo, base) + if (case := build_case_change(repo, base, rel_path)) is not None + ] + return Report( + base_ref=base, + head_sha=repo.head.commit.hexsha, + cases=tuple(sorted(cases, key=lambda case: case.label)), + ) diff --git a/packages/climate-ref/src/climate_ref/baseline_report/render.py b/packages/climate-ref/src/climate_ref/baseline_report/render.py new file mode 100644 index 000000000..43b102eee --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/render.py @@ -0,0 +1,165 @@ +""" +Write the static HTML report. + +Python decides and templates place, so this module builds no HTML. It hands frozen objects to +Jinja, registers the three formatting filters the templates are allowed, and writes the pages out. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from jinja2 import Environment, PackageLoader, select_autoescape + +if TYPE_CHECKING: + from climate_ref.baseline_report.analyse import AnalysedCase, AnalysedReport + +# Digest prefix shown in the report. Long enough to identify a blob, short enough to read. +_SHORT_DIGEST = 12 + + +def _format_bytes(size: object) -> str: + """ + Render a byte count with thousands separators. + + Parameters + ---------- + size + The count, or ``None`` when the file is absent on that side. + + Returns + ------- + : + For example ``101,204 B``, or ``-`` when there is no count. + """ + if not isinstance(size, int) or isinstance(size, bool): + return "-" + return f"{size:,} B" + + +def _format_num(value: object) -> str: + """ + Render a number to four significant figures. + + ``g`` switches to scientific notation past 1e4 on its own, which is where a plain decimal + stops being readable. + + Parameters + ---------- + value + The number, or anything else, which is passed through as text. + + Returns + ------- + : + The formatted number. + """ + if isinstance(value, bool) or not isinstance(value, int | float): + return str(value) + return f"{value:.4g}" + + +def _format_short(digest: object) -> str: + """ + Render the readable prefix of a digest. + + Parameters + ---------- + digest + The digest, or ``None``. + + Returns + ------- + : + The first twelve hex characters, or ``-`` when there is no digest. + """ + if not isinstance(digest, str): + return "-" + return digest[:_SHORT_DIGEST] + + +def _build_env() -> Environment: + """ + Build the Jinja environment the report is rendered with. + + Returns + ------- + : + The environment, with the three permitted filters registered. + """ + env = Environment( + loader=PackageLoader("climate_ref.baseline_report", "templates"), + autoescape=select_autoescape(["html", "j2"]), + trim_blocks=True, + lstrip_blocks=True, + ) + env.filters["bytes"] = _format_bytes + env.filters["num"] = _format_num + env.filters["short"] = _format_short + return env + + +_env = _build_env() + + +def render_index(report: AnalysedReport) -> str: + """ + Render the overview page. + + Parameters + ---------- + report + The analysed report. + + Returns + ------- + : + The page's HTML. + """ + return _env.get_template("index.html.j2").render(report=report) + + +def render_case(report: AnalysedReport, case: AnalysedCase) -> str: + """ + Render one test case's page. + + Parameters + ---------- + report + The analysed report, which carries the header details. + case + The case to render. + + Returns + ------- + : + The page's HTML. + """ + return _env.get_template("case.html.j2").render(report=report, case=case) + + +def write_site(report: AnalysedReport, out_dir: Path) -> Path: + """ + Write the overview ``index.html`` plus one ``index.html`` per case, under the case slug. + + Parameters + ---------- + report + The analysed report. + out_dir + Directory to write the site into. Created if it does not exist. + + Returns + ------- + : + Path of the index page. + """ + out_dir.mkdir(parents=True, exist_ok=True) + index = out_dir / "index.html" + index.write_text(render_index(report), encoding="utf-8") + for case in report.cases: + page = out_dir / case.change.slug / "index.html" + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text(render_case(report, case), encoding="utf-8") + return index diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/base.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/base.html.j2 new file mode 100644 index 000000000..541f7a45b --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/base.html.j2 @@ -0,0 +1,23 @@ + + + + + +{% block title %}Regression baseline diff{% endblock %} + + + +
+

Regression baseline diff

+

+Base {{ report.report.base_ref }} +against {{ report.report.head_sha | short }}. +Blobs are served from {{ report.store_url }}. +

+
+
+{% block content %}{% endblock %} +
+ + + diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2 new file mode 100644 index 000000000..0ca2b4241 --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2 @@ -0,0 +1,60 @@ +{% extends "base.html.j2" %} +{% import "macros.html.j2" as m %} +{% block title %}{{ case.change.label }}{% endblock %} +{% block content %} +

Back to all cases

+

{{ case.change.label }}

+{% if case.change.metadata %} + +{% endif %} +{% if case.change.committed %} +

Committed artefacts changed

+
    +{% for name in case.change.committed %} +
  • {{ name }}
  • +{% endfor %} +
+{% endif %} + +{% set images = case.files | selectattr("change.kind.value", "equalto", "image") | list %} +{% set texts = case.files | selectattr("change.kind.value", "equalto", "text") | list %} +{% set binaries = case.files | rejectattr("change.kind.value", "in", ["image", "text"]) | list %} + +{% if images %} +
+Images ({{ images | length }}) +{% for file in images %} +{{ m.image_pair(file) }} +{% endfor %} +
+{% endif %} + +{% if texts %} +
+Text ({{ texts | length }}) +{% for file in texts %} +{{ m.text_block(file) }} +{% endfor %} +
+{% endif %} + +{% if binaries %} +
+NetCDF and other ({{ binaries | length }}) + + + + + +{% for file in binaries %} +{{ m.binary_row(file) }} +{% endfor %} + +
filestatussizeblobs
+
+{% endif %} +{% endblock %} diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/index.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/index.html.j2 new file mode 100644 index 000000000..341a69440 --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/index.html.j2 @@ -0,0 +1,42 @@ +{% extends "base.html.j2" %} +{% block content %} +{% if report.cases %} + + + + + + + + + + + + +{% for case in report.cases %} + + + +{% for kind in ("image", "text", "netcdf", "other") %} + +{% endfor %} + +{% endfor %} + +
caseversionsimagestextnetcdfother
{{ case.change.label }} +{% if case.change.is_removed %} +removed +{% elif case.change.is_new %} +new +{% else %} +v{{ case.change.base.test_case_version }} -> v{{ case.change.head.test_case_version }} +{% endif %} + +{% if case.counts[kind].added %}+{{ case.counts[kind].added }}{% endif %} +{% if case.counts[kind].changed %}~{{ case.counts[kind].changed }}{% endif %} +{% if case.counts[kind].removed %}-{{ case.counts[kind].removed }}{% endif %} +
+{% else %} +

No baseline manifests changed against {{ report.report.base_ref }}.

+{% endif %} +{% endblock %} diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 new file mode 100644 index 000000000..78979a1b6 --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 @@ -0,0 +1,75 @@ +{% macro size_delta(file) -%} +{% if file.change.old is none %} +{{- file.change.new.size | bytes -}} +{% elif file.change.new is none %} +was {{ file.change.old.size | bytes -}} +{% else %} +{{- file.change.old.size | bytes }} -> {{ file.change.new.size | bytes -}} +{% endif %} +{%- endmacro %} + +{% macro blob_links(file) -%} +{% if file.old_url %}old {% endif %} +{%- if file.new_url %}new{% endif %} +{%- endmacro %} + +{% macro image_pair(file) %} +
+
+{{ file.change.name }} +{{ file.change.status }} +{{ size_delta(file) }} +
+
+
+old +{% if file.old_url %} +old {{ file.change.name }} +{% else %} +
absent
+{% endif %} +
+
+new +{% if file.new_url %} +new {{ file.change.name }} +{% else %} +
absent
+{% endif %} +
+
+ +
+{% endmacro %} + +{% macro text_block(file) %} +
+

+{{ file.change.name }} +{{ file.change.status }} +{{ size_delta(file) }} +{{ blob_links(file) }} +

+{% if file.text.note %} +

{{ file.text.note }}

+{% else %} +
+{%- for line in file.text.lines %}
+{{ line.text }}
+{%- endfor %}
+
+{% if file.text.elided %} +

{{ file.text.elided }} further diff line(s) elided.

+{% endif %} +{% endif %} +
+{% endmacro %} + +{% macro binary_row(file) %} + +{{ file.change.name }} +{{ file.change.status }} +{{ size_delta(file) }} +{{ blob_links(file) }} + +{% endmacro %} diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css new file mode 100644 index 000000000..62eb1ee0c --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css @@ -0,0 +1,156 @@ +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1b1b1b; + --muted: #666666; + --rule: #d8d8d8; + --panel: #f6f6f6; + --add-bg: #e2f5e6; + --add-fg: #14532d; + --remove-bg: #fbe4e4; + --remove-fg: #7f1d1d; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #16181c; + --fg: #e6e6e6; + --muted: #9aa0a6; + --rule: #33363c; + --panel: #1e2126; + --add-bg: #16321f; + --add-fg: #86e29b; + --remove-bg: #3a1c1c; + --remove-fg: #f2a1a1; + } +} + +body { + margin: 0 auto; + padding: 1.5rem; + max-width: 72rem; + background: var(--bg); + color: var(--fg); + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + line-height: 1.45; +} + +code, pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.report-header { + border-bottom: 1px solid var(--rule); + margin-bottom: 1.5rem; +} + +.meta { + color: var(--muted); +} + +table { + border-collapse: collapse; + width: 100%; +} + +th, td { + border-bottom: 1px solid var(--rule); + padding: 0.35rem 0.5rem; + text-align: left; + vertical-align: top; +} + +.counts .added, .status.added { color: var(--add-fg); } +.counts .removed, .status.removed { color: var(--remove-fg); } +.counts .changed, .status.changed { color: var(--muted); } + +.status { + font-size: 0.85em; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.size { + color: var(--muted); + font-size: 0.9em; +} + +details { + border: 1px solid var(--rule); + border-radius: 4px; + margin: 1rem 0; + padding: 0.5rem 1rem; +} + +summary { + cursor: pointer; + font-weight: 600; +} + +.file { + border-top: 1px solid var(--rule); + margin: 1rem 0 0; + padding-top: 1rem; +} + +.pair { + display: grid; + gap: 1rem; + grid-template-columns: 1fr 1fr; + position: relative; +} + +.pair .side-label { + color: var(--muted); + display: block; + font-size: 0.85em; +} + +.pair img { + background: var(--panel); + display: block; + max-width: 100%; +} + +.pair.flipped { + grid-template-columns: 1fr; +} + +.pair.flipped .side { + grid-column: 1; + grid-row: 1; +} + +.pair.flipped .side:last-child img { + opacity: 0.5; +} + +.absent { + border: 1px dashed var(--rule); + color: var(--muted); + padding: 2rem; + text-align: center; +} + +.diff { + background: var(--panel); + border: 1px solid var(--rule); + border-radius: 4px; + max-height: 40rem; + overflow: auto; + padding: 0.5rem; +} + +.diff span { + display: block; + white-space: pre-wrap; +} + +.diff .add { background: var(--add-bg); color: var(--add-fg); } +.diff .remove { background: var(--remove-bg); color: var(--remove-fg); } +.diff .hunk, .diff .header { color: var(--muted); } + +.note { + color: var(--muted); + font-style: italic; +} diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.js b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.js new file mode 100644 index 000000000..0bb09e6a3 --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.js @@ -0,0 +1,10 @@ +document.addEventListener("click", function (event) { + var button = event.target.closest("[data-flip]"); + if (button === null) { + return; + } + var pair = button.parentElement.querySelector(".pair"); + if (pair !== null) { + pair.classList.toggle("flipped"); + } +}); diff --git a/packages/climate-ref/src/climate_ref/cli/test_cases/__init__.py b/packages/climate-ref/src/climate_ref/cli/test_cases/__init__.py index 03d8ff92a..fd1ce9250 100644 --- a/packages/climate-ref/src/climate_ref/cli/test_cases/__init__.py +++ b/packages/climate-ref/src/climate_ref/cli/test_cases/__init__.py @@ -10,6 +10,7 @@ - :mod:`~climate_ref.cli.test_cases.run` -- ``run`` - :mod:`~climate_ref.cli.test_cases.baselines` -- ``replay`` / ``mint`` - :mod:`~climate_ref.cli.test_cases.ci_gate` -- ``ci-gate`` +- :mod:`~climate_ref.cli.test_cases.diff` -- ``diff`` """ from importlib import import_module @@ -25,7 +26,7 @@ # Import each command module for its registration side effect on ``app``. # The sequence below is the order the verbs appear in ``ref test-cases --help`` # a dynamic import keeps it explicit, where a plain ``import`` block would be alphabetised by ruff. -for _command_module in ("discovery", "run", "baselines", "ci_gate"): +for _command_module in ("discovery", "run", "baselines", "ci_gate", "diff"): import_module(f"{__name__}.{_command_module}") __all__ = [ diff --git a/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py new file mode 100644 index 000000000..41587b477 --- /dev/null +++ b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py @@ -0,0 +1,71 @@ +""" +``ref test-cases diff``. + +Renders an HTML report of every regression baseline that moved on this branch, with images +shown old and new side by side and text outputs diffed inline. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Annotated + +import typer +from loguru import logger + +from climate_ref.cli._git_utils import get_repo_for_path +from climate_ref.cli.test_cases._app import app + +if TYPE_CHECKING: + from rich.console import Console + + from climate_ref.config import Config + + +@app.command(name="diff") +def diff_baselines( + ctx: typer.Context, + html_dir: Annotated[Path, typer.Option(help="Directory to write the HTML report into")], + base: Annotated[ + str, + typer.Option(help="Git ref to compare against (the PR base branch)"), + ] = "origin/main", + no_fetch: Annotated[ + bool, + typer.Option("--no-fetch", help="Skip blob downloads and report sizes only"), + ] = False, +) -> None: + """ + Render an HTML report of the regression baselines changed on this branch. + + Compares every committed ``manifest.json`` to its counterpart on ``--base`` and writes one page + per changed test case, with images shown old and new side by side. + Exits 0 whether or not anything changed. This reports, it does not gate. + + Examples + -------- + ref test-cases diff --html-dir build/baseline-diff + ref test-cases diff --base origin/develop --html-dir build/baseline-diff + ref test-cases diff --html-dir build/baseline-diff --no-fetch + """ + from climate_ref.baseline_report.analyse import analyse + from climate_ref.baseline_report.collect import collect + from climate_ref.baseline_report.render import write_site + from climate_ref_core.regression.store import build_native_store + + config: Config = ctx.obj.config + console: Console = ctx.obj.console + + repo = get_repo_for_path(Path.cwd()) + if repo is None: + logger.error("test-cases diff must be run inside a git repository") + raise typer.Exit(code=1) + + report = collect(repo, base) + store = build_native_store(config.native_store, writable=False) + with tempfile.TemporaryDirectory() as workdir: + analysed = analyse(report, store, fetch=not no_fetch, workdir=Path(workdir)) + index = write_site(analysed, html_dir) + + console.print(f"Wrote {len(analysed.cases)} case page(s) to {index}") diff --git a/packages/climate-ref/tests/unit/baseline_report/__init__.py b/packages/climate-ref/tests/unit/baseline_report/__init__.py new file mode 100644 index 000000000..2111eb203 --- /dev/null +++ b/packages/climate-ref/tests/unit/baseline_report/__init__.py @@ -0,0 +1 @@ +"""Tests for the local baseline diff report.""" diff --git a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py new file mode 100644 index 000000000..625ffdf90 --- /dev/null +++ b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py @@ -0,0 +1,191 @@ +"""Tests for fetching blobs and building the diffs the templates render.""" + +import json +from unittest.mock import MagicMock + +import pytest + +from climate_ref.baseline_report.analyse import MAX_FETCH_BYTES, analyse, blob_url, text_diff +from climate_ref.baseline_report.collect import ( + CaseChange, + FileChange, + FileKind, + Report, + classify, +) +from climate_ref_core.regression.manifest import NativeEntry +from climate_ref_core.regression.store import NativeStore + + +def _kinds(diff): + """Return the kind of every line in a diff.""" + return [line.kind for line in diff.lines] + + +def _file_change(name, old, new): + """Build a file change from two entries.""" + return FileChange(name=name, old=old, new=new, kind=classify(name)) + + +def _report(files): + """Wrap file changes in a single-case report.""" + case = CaseChange( + label="example/diag/case", + slug="example/diag/case", + rel_path="packages/climate-ref-example/tests/test-data/diag/case/manifest.json", + base=None, + head=None, + files=tuple(files), + committed=(), + metadata=(), + ) + return Report(base_ref="origin/main", head_sha="a" * 40, cases=(case,)) + + +class TestBlobUrl: + def test_joins_the_digest_onto_the_store(self): + assert blob_url("https://store/", "a" * 64) == f"https://store/{'a' * 64}" + + +class TestTextDiff: + def test_json_key_order_is_not_a_difference(self, tmp_path): + old = tmp_path / "old.json" + new = tmp_path / "new.json" + old.write_text(json.dumps({"b": 2, "a": 1})) + new.write_text(json.dumps({"a": 1, "b": 2})) + + diff = text_diff(old, new, "series.json") + + assert diff.note == "identical after decoding" + assert diff.lines == () + + def test_one_changed_line(self, tmp_path): + old = tmp_path / "old.csv" + new = tmp_path / "new.csv" + old.write_text("header\nvalue 1\ntail\n") + new.write_text("header\nvalue 2\ntail\n") + + diff = text_diff(old, new, "series.csv") + + assert diff.note is None + kinds = _kinds(diff) + assert kinds.count("add") == 1 + assert kinds.count("remove") == 1 + assert "hunk" in kinds + assert kinds.count("header") == 2 + + def test_added_file_is_all_additions(self, tmp_path): + new = tmp_path / "new.txt" + new.write_text("one\ntwo\n") + + diff = text_diff(None, new, "new.txt") + + assert [line.kind for line in diff.lines if line.kind not in ("header", "hunk")] == [ + "add", + "add", + ] + + +class TestAnalyse: + def test_no_fetch_notes_every_text_file(self): + store = MagicMock(spec=NativeStore) + store.url = "https://store" + report = _report( + [ + _file_change("plot.png", None, NativeEntry(sha256="1" * 64, size=10)), + _file_change("series.json", None, NativeEntry(sha256="2" * 64, size=10)), + ] + ) + + analysed = analyse(report, store, fetch=False, workdir=None) + + files = {f.change.name: f for f in analysed.cases[0].files} + assert files["series.json"].text.note == "fetching disabled" + assert files["plot.png"].text is None + store.fetch.assert_not_called() + + def test_counts_are_tallied_per_kind(self): + store = MagicMock(spec=NativeStore) + store.url = "https://store" + entry = NativeEntry(sha256="1" * 64, size=10) + other = NativeEntry(sha256="2" * 64, size=10) + report = _report( + [ + _file_change("a.png", None, entry), + _file_change("b.png", entry, other), + _file_change("c.nc", entry, None), + ] + ) + + counts = analyse(report, store, fetch=False, workdir=None).cases[0].counts + + assert counts[FileKind.IMAGE.value] == {"added": 1, "changed": 1, "removed": 0} + assert counts[FileKind.NETCDF.value] == {"added": 0, "changed": 0, "removed": 1} + assert counts[FileKind.TEXT.value] == {"added": 0, "changed": 0, "removed": 0} + + def test_local_store_produces_a_real_diff(self, tmp_path): + store = NativeStore(url=str(tmp_path / "store")) + old_file = tmp_path / "old.csv" + new_file = tmp_path / "new.csv" + old_file.write_text("a\nb\n") + new_file.write_text("a\nc\n") + old_digest = store.put(old_file) + new_digest = store.put(new_file) + + report = _report( + [ + _file_change( + "series.csv", + NativeEntry(sha256=old_digest, size=old_file.stat().st_size), + NativeEntry(sha256=new_digest, size=new_file.stat().st_size), + ) + ] + ) + + analysed = analyse(report, store, fetch=True, workdir=tmp_path / "work") + + diff = analysed.cases[0].files[0].text + assert diff.note is None + assert [line.text for line in diff.lines if line.kind == "add"] == ["+c"] + assert [line.text for line in diff.lines if line.kind == "remove"] == ["-b"] + + def test_oversized_blob_is_noted_not_fetched(self, tmp_path): + store = MagicMock(spec=NativeStore) + store.url = "https://store" + report = _report( + [ + _file_change( + "big.json", + None, + NativeEntry(sha256="1" * 64, size=MAX_FETCH_BYTES + 1), + ) + ] + ) + + analysed = analyse(report, store, fetch=True, workdir=tmp_path) + + assert "too large to diff" in analysed.cases[0].files[0].text.note + store.fetch.assert_not_called() + + def test_a_failed_fetch_becomes_a_note(self, tmp_path): + store = MagicMock(spec=NativeStore) + store.url = "https://store" + store.fetch.side_effect = FileNotFoundError("gone") + report = _report([_file_change("series.json", None, NativeEntry(sha256="1" * 64, size=10))]) + + analysed = analyse(report, store, fetch=True, workdir=tmp_path) + + assert "could not fetch" in analysed.cases[0].files[0].text.note + + @pytest.mark.parametrize("fetch", [True, False]) + def test_urls_follow_the_entries_that_exist(self, tmp_path, fetch): + store = MagicMock(spec=NativeStore) + store.url = "https://store/" + report = _report([_file_change("plot.png", NativeEntry(sha256="1" * 64, size=10), None)]) + + analysed = analyse(report, store, fetch=fetch, workdir=tmp_path) + + file = analysed.cases[0].files[0] + assert file.old_url == f"https://store/{'1' * 64}" + assert file.new_url is None + assert analysed.store_url == "https://store" diff --git a/packages/climate-ref/tests/unit/baseline_report/test_collect.py b/packages/climate-ref/tests/unit/baseline_report/test_collect.py new file mode 100644 index 000000000..c54e9d341 --- /dev/null +++ b/packages/climate-ref/tests/unit/baseline_report/test_collect.py @@ -0,0 +1,162 @@ +"""Tests for collecting the manifest changes on a branch.""" + +import pytest +from git import Repo + +from climate_ref.baseline_report.collect import ( + FileKind, + case_label, + classify, + collect, +) +from climate_ref_core.regression.manifest import SCHEMA_VERSION, Manifest, NativeEntry + +MANIFEST_PATH = "packages/climate-ref-example/tests/test-data/global-mean-timeseries/default/manifest.json" + + +def _digest(char: str) -> str: + """Build a valid sha256 digest from a single repeated hex character.""" + return char * 64 + + +def _write_manifest(repo_dir, rel_path, *, version, native, committed=None, catalog_hash=None): + """Write a manifest at ``rel_path`` inside ``repo_dir``.""" + path = repo_dir / rel_path + path.parent.mkdir(parents=True, exist_ok=True) + Manifest( + schema=SCHEMA_VERSION, + test_case_version=version, + diagnostic_version=1, + committed=committed or {}, + native=native, + catalog_hash=catalog_hash, + ).dump(path) + return path + + +@pytest.fixture +def repo(tmp_path): + """A git repository with one manifest committed twice, the second time with changes.""" + repo = Repo.init(tmp_path) + with repo.config_writer() as writer: + writer.set_value("user", "name", "test") + writer.set_value("user", "email", "test@example.com") + + _write_manifest( + tmp_path, + MANIFEST_PATH, + version=3, + native={ + "kept.png": NativeEntry(sha256=_digest("1"), size=10), + "changed.json": NativeEntry(sha256=_digest("2"), size=20), + "removed.nc": NativeEntry(sha256=_digest("3"), size=30), + }, + committed={"series.json": _digest("a")}, + ) + repo.git.add("-A") + repo.index.commit("base") + + _write_manifest( + tmp_path, + MANIFEST_PATH, + version=4, + native={ + "kept.png": NativeEntry(sha256=_digest("1"), size=10), + "changed.json": NativeEntry(sha256=_digest("4"), size=25), + "added.bin": NativeEntry(sha256=_digest("5"), size=40), + }, + committed={"series.json": _digest("b")}, + ) + repo.git.add("-A") + repo.index.commit("head") + return repo + + +class TestClassify: + @pytest.mark.parametrize( + "name, expected", + [ + ("plot.png", FileKind.IMAGE), + ("plot.JPG", FileKind.IMAGE), + ("plot.jpeg", FileKind.IMAGE), + ("plot.gif", FileKind.IMAGE), + ("plot.svg", FileKind.IMAGE), + ("a.json", FileKind.TEXT), + ("a.csv", FileKind.TEXT), + ("a.yml", FileKind.TEXT), + ("a.yaml", FileKind.TEXT), + ("a.html", FileKind.TEXT), + ("a.txt", FileKind.TEXT), + ("a.md", FileKind.TEXT), + ("a.log", FileKind.TEXT), + ("out.nc", FileKind.NETCDF), + ("blob.bin", FileKind.OTHER), + ("noextension", FileKind.OTHER), + ], + ) + def test_suffixes(self, name, expected): + assert classify(name) is expected + + +class TestCaseLabel: + def test_strips_the_provider_prefix(self): + assert case_label(MANIFEST_PATH) == "example/global-mean-timeseries/default" + + def test_short_path_keeps_only_the_provider(self): + assert case_label("packages/climate-ref-pmp") == "pmp" + + +class TestCollect: + def test_pairs_native_entries(self, repo): + report = collect(repo, "HEAD~1") + + assert report.base_ref == "HEAD~1" + assert report.head_sha == repo.head.commit.hexsha + assert len(report.cases) == 1 + + case = report.cases[0] + assert case.label == "example/global-mean-timeseries/default" + assert case.slug == case.label + assert not case.is_new + assert not case.is_removed + + # Unchanged entries are dropped, and the rest are in name order. + assert [(f.name, f.status, f.kind) for f in case.files] == [ + ("added.bin", "added", FileKind.OTHER), + ("changed.json", "changed", FileKind.TEXT), + ("removed.nc", "removed", FileKind.NETCDF), + ] + + def test_reports_metadata_and_committed_changes(self, repo): + case = collect(repo, "HEAD~1").cases[0] + + assert case.metadata == ("test_case_version: 3 -> 4",) + assert case.committed == ("series.json",) + + def test_new_case_has_no_base(self, tmp_path): + repo = Repo.init(tmp_path) + with repo.config_writer() as writer: + writer.set_value("user", "name", "test") + writer.set_value("user", "email", "test@example.com") + (tmp_path / "README.md").write_text("seed") + repo.git.add("-A") + repo.index.commit("seed") + + _write_manifest( + tmp_path, + MANIFEST_PATH, + version=7, + native={"plot.png": NativeEntry(sha256=_digest("1"), size=10)}, + ) + repo.git.add("-A") + repo.index.commit("add case") + + case = collect(repo, "HEAD~1").cases[0] + + assert case.base is None + assert case.is_new + assert case.metadata == ("new test case at test_case_version 7",) + assert [f.status for f in case.files] == ["added"] + + def test_no_changes_gives_no_cases(self, repo): + assert collect(repo, "HEAD").cases == () diff --git a/packages/climate-ref/tests/unit/baseline_report/test_render.py b/packages/climate-ref/tests/unit/baseline_report/test_render.py new file mode 100644 index 000000000..c8e598845 --- /dev/null +++ b/packages/climate-ref/tests/unit/baseline_report/test_render.py @@ -0,0 +1,233 @@ +"""Tests for the static HTML the report is written as.""" + +from html.parser import HTMLParser + +import pytest + +from climate_ref.baseline_report.analyse import AnalysedCase, AnalysedFile, AnalysedReport, DiffLine, TextDiff +from climate_ref.baseline_report.collect import CaseChange, FileChange, FileKind, Report +from climate_ref.baseline_report.render import render_case, render_index, write_site +from climate_ref_core.regression.manifest import SCHEMA_VERSION, Manifest, NativeEntry + +STORE_URL = "https://store.example" + + +class Collector(HTMLParser): + """Collect the tags and attributes of a rendered page.""" + + def __init__(self): + super().__init__() + self.tags: list[tuple[str, dict[str, str]]] = [] + + def handle_starttag(self, tag, attrs): + self.tags.append((tag, dict(attrs))) + + +def _parse(html: str) -> Collector: + """Parse a page and return its collected tags.""" + collector = Collector() + collector.feed(html) + return collector + + +def _tags(html: str, name: str) -> list[dict[str, str]]: + """Return the attributes of every ``name`` tag in a page.""" + return [attrs for tag, attrs in _parse(html).tags if tag == name] + + +def _hrefs(html: str) -> list[str]: + """Return every href in a page.""" + return [attrs["href"] for _, attrs in _parse(html).tags if "href" in attrs] + + +def _manifest(version: int) -> Manifest: + """Build a manifest carrying only the fields the templates read.""" + return Manifest( + schema=SCHEMA_VERSION, + test_case_version=version, + diagnostic_version=1, + committed={}, + native={}, + ) + + +def _entry(char: str, size: int = 10) -> NativeEntry: + """Build a manifest entry from a single repeated hex character.""" + return NativeEntry(sha256=char * 64, size=size) + + +def _analysed_file(name, kind, old, new, text=None) -> AnalysedFile: + """Build one analysed file with URLs derived from its entries.""" + change = FileChange(name=name, old=old, new=new, kind=kind) + return AnalysedFile( + change=change, + old_url=f"{STORE_URL}/{old.sha256}" if old else None, + new_url=f"{STORE_URL}/{new.sha256}" if new else None, + text=text, + ) + + +def _case(files, *, label="example/diag/case", base=None, head=None) -> AnalysedCase: + """Build one analysed case with tallied counts.""" + counts = {kind.value: {"added": 0, "changed": 0, "removed": 0} for kind in FileKind} + for file in files: + counts[file.change.kind.value][file.change.status] += 1 + change = CaseChange( + label=label, + slug=label, + rel_path=f"packages/climate-ref-{label.split('/')[0]}/tests/test-data/manifest.json", + base=base, + head=head, + files=tuple(file.change for file in files), + committed=("series.json",), + metadata=("test_case_version: 3 -> 4",), + ) + return AnalysedCase(change=change, files=tuple(files), counts=counts) + + +def _report(cases) -> AnalysedReport: + """Wrap analysed cases in a report.""" + return AnalysedReport( + report=Report(base_ref="origin/main", head_sha="a" * 40, cases=tuple(c.change for c in cases)), + store_url=STORE_URL, + cases=tuple(cases), + ) + + +@pytest.fixture +def changed_image_case(): + """A case whose single image changed.""" + return _case( + [_analysed_file("plot.png", FileKind.IMAGE, _entry("1"), _entry("2", 20))], + base=_manifest(3), + head=_manifest(4), + ) + + +class TestIndex: + def test_one_row_per_case(self): + report = _report( + [ + _case([], label="example/diag/a", base=_manifest(1), head=_manifest(2)), + _case([], label="pmp/diag/b", base=_manifest(1), head=_manifest(2)), + ] + ) + + html = render_index(report) + + assert html.count("") == 3 # one header row plus one per case + assert "example/diag/a" in html + assert "pmp/diag/b" in html + + def test_every_link_ends_in_index_html(self): + report = _report([_case([], base=_manifest(1), head=_manifest(2))]) + + assert _hrefs(render_index(report)) == ["example/diag/case/index.html"] + + def test_versions_column(self): + report = _report([_case([], base=_manifest(3), head=_manifest(4))]) + + assert "v3 -> v4" in render_index(report) + + def test_an_empty_report_says_so(self): + html = render_index(_report([])) + + assert "No baseline manifests changed" in html + assert "" not in html + + +class TestCasePage: + def test_a_changed_image_renders_two_images(self, changed_image_case): + report = _report([changed_image_case]) + + images = _tags(render_case(report, changed_image_case), "img") + + assert len(images) == 2 + assert all(image["src"].startswith(STORE_URL) for image in images) + + def test_an_added_image_renders_one_image_and_a_placeholder(self): + case = _case([_analysed_file("plot.png", FileKind.IMAGE, None, _entry("2"))]) + report = _report([case]) + + html = render_case(report, case) + + assert len(_tags(html, "img")) == 1 + assert 'class="absent"' in html + + def test_a_text_diff_renders_one_span_per_line(self): + diff = TextDiff( + lines=( + DiffLine(kind="header", text="--- old"), + DiffLine(kind="hunk", text="@@ -1 +1 @@"), + DiffLine(kind="remove", text="-a"), + DiffLine(kind="add", text="+b"), + ), + note=None, + elided=0, + ) + case = _case([_analysed_file("series.csv", FileKind.TEXT, _entry("1"), _entry("2"), text=diff)]) + report = _report([case]) + + html = render_case(report, case) + spans = [ + attrs["class"] + for attrs in _tags(html, "span") + if attrs.get("class") in {"header", "hunk", "remove", "add"} + ] + + assert spans == ["header", "hunk", "remove", "add"] + + def test_a_note_replaces_the_diff(self): + diff = TextDiff(lines=(), note="fetching disabled", elided=0) + case = _case([_analysed_file("series.csv", FileKind.TEXT, None, _entry("2"), text=diff)]) + report = _report([case]) + + html = render_case(report, case) + + assert "fetching disabled" in html + assert '
' not in html
+
+    def test_elided_lines_are_reported(self):
+        diff = TextDiff(lines=(DiffLine(kind="add", text="+a"),), note=None, elided=7)
+        case = _case([_analysed_file("series.csv", FileKind.TEXT, None, _entry("2"), text=diff)])
+        report = _report([case])
+
+        assert "7 further diff line(s) elided" in render_case(report, case)
+
+    def test_netcdf_renders_as_a_row(self):
+        case = _case([_analysed_file("out.nc", FileKind.NETCDF, _entry("1"), None)])
+        report = _report([case])
+
+        html = render_case(report, case)
+
+        assert "out.nc" in html
+        assert "was 10 B" in html
+        assert not _tags(html, "img")
+
+    def test_links_are_internal_index_pages_or_store_blobs(self, changed_image_case):
+        report = _report([changed_image_case])
+
+        for href in _hrefs(render_case(report, changed_image_case)):
+            assert href.endswith("index.html") or href.startswith(STORE_URL)
+
+    def test_metadata_and_committed_are_listed(self, changed_image_case):
+        html = render_case(_report([changed_image_case]), changed_image_case)
+
+        assert "test_case_version: 3 -> 4" in html
+        assert "series.json" in html
+
+
+class TestWriteSite:
+    def test_writes_an_index_and_a_page_per_case(self, tmp_path, changed_image_case):
+        report = _report([changed_image_case])
+
+        index = write_site(report, tmp_path / "out")
+
+        assert index == tmp_path / "out" / "index.html"
+        assert index.exists()
+        assert (tmp_path / "out" / "example" / "diag" / "case" / "index.html").exists()
+
+    def test_an_empty_report_still_writes_an_index(self, tmp_path):
+        index = write_site(_report([]), tmp_path / "out")
+
+        assert index.exists()
diff --git a/packages/climate-ref/tests/unit/cli/test_test_cases.py b/packages/climate-ref/tests/unit/cli/test_test_cases.py
index 897a16e71..3801b9e2e 100644
--- a/packages/climate-ref/tests/unit/cli/test_test_cases.py
+++ b/packages/climate-ref/tests/unit/cli/test_test_cases.py
@@ -2868,3 +2868,65 @@ def test_diagnostic_version_decrease_fails(self, invoke_cli, mocker, tmp_path):
         result = invoke_cli(["test-cases", "ci-gate"], expected_exit_code=1)
         assert result.exit_code == 1
         assert "fail" in result.output
+
+
+class TestDiff:
+    """``ref test-cases diff`` writes a local HTML report."""
+
+    def test_no_changes_still_writes_an_index(self, invoke_cli, mocker, tmp_path):
+        repo = MagicMock()
+        repo.working_tree_dir = str(tmp_path)
+        repo.git.diff.return_value = ""
+        repo.head.commit.hexsha = "a" * 40
+        mocker.patch("climate_ref.cli.test_cases.diff.get_repo_for_path", return_value=repo)
+
+        out = tmp_path / "out"
+        invoke_cli(["test-cases", "diff", "--html-dir", str(out), "--no-fetch"])
+
+        assert (out / "index.html").exists()
+        assert "No baseline manifests changed" in (out / "index.html").read_text()
+
+    def test_outside_a_repository_exits_one(self, invoke_cli, mocker, tmp_path):
+        mocker.patch("climate_ref.cli.test_cases.diff.get_repo_for_path", return_value=None)
+
+        invoke_cli(
+            ["test-cases", "diff", "--html-dir", str(tmp_path / "out"), "--no-fetch"],
+            expected_exit_code=1,
+        )
+
+    def test_a_changed_case_gets_a_page(self, invoke_cli, mocker, tmp_path):
+        from climate_ref_core.regression.manifest import SCHEMA_VERSION, Manifest, NativeEntry
+
+        rel_path = "packages/climate-ref-example/tests/test-data/diag/case/manifest.json"
+        manifest_path = tmp_path / rel_path
+        manifest_path.parent.mkdir(parents=True)
+        Manifest(
+            schema=SCHEMA_VERSION,
+            test_case_version=4,
+            diagnostic_version=1,
+            committed={},
+            native={"plot.png": NativeEntry(sha256="b" * 64, size=20)},
+        ).dump(manifest_path)
+
+        repo = MagicMock()
+        repo.working_tree_dir = str(tmp_path)
+        repo.git.diff.return_value = rel_path
+        repo.git.show.return_value = json.dumps(
+            {
+                "schema": SCHEMA_VERSION,
+                "test_case_version": 3,
+                "diagnostic_version": 1,
+                "committed": {},
+                "native": {"plot.png": {"sha256": "a" * 64, "size": 10}},
+            }
+        )
+        repo.head.commit.hexsha = "c" * 40
+        mocker.patch("climate_ref.cli.test_cases.diff.get_repo_for_path", return_value=repo)
+
+        out = tmp_path / "out"
+        invoke_cli(["test-cases", "diff", "--html-dir", str(out), "--no-fetch"])
+
+        page = out / "example" / "diag" / "case" / "index.html"
+        assert page.exists()
+        assert page.read_text().count("
Date: Fri, 4 Sep 2026 11:08:32 +1000
Subject: [PATCH 30/64] feat: let a data requirement declare which collections
 can supply it

Adds `fallback_source_types` to `DataRequirement`.
A requirement served by a collection other than its own source type now says so,
rather than relying on the solver folding obs4REF into obs4MIPs before any requirement is read.

`source_type` stays the delivery key and wins on duplicates,
so the data is delivered under it whichever collection supplied it.
The solver, `ref doctor` and `DataRequirementSummary` all read the declaration.

No provider declares a fallback yet and the implicit fold stays in place,
so this changes nothing behaviourally.
`with_obs4ref_fallback` becomes `union_with_fallbacks`, which the fold now calls too,
so there is one implementation of the union rather than two.

Adds two snapshots over the seeded catalogs, one of the solved executions and one of the
doctor findings, as the oracle for the rest of this work.
They sort the rows in each collection because a seeded solve does not fix their order
between runs, which was already true before this change.
---
 changelog/898.feature.md                      |   7 +
 .../src/climate_ref_core/diagnostics.py       |   8 +
 .../src/climate_ref_core/summary.py           |   9 +
 .../src/climate_ref/doctor/checks/data.py     |  36 +-
 .../climate-ref/src/climate_ref/solver.py     | 123 +++-
 .../climate-ref/tests/unit/test_doctor.py     |  31 +
 .../test_diagnose_snapshot_seeded.yml         | 244 +++++++
 .../climate-ref/tests/unit/test_solver.py     | 130 +++-
 .../test_solve_snapshot_all_providers.yml     | 651 ++++++++++++++++++
 9 files changed, 1192 insertions(+), 47 deletions(-)
 create mode 100644 packages/climate-ref/tests/unit/test_doctor/test_diagnose_snapshot_seeded.yml
 create mode 100644 packages/climate-ref/tests/unit/test_solver/test_solve_snapshot_all_providers.yml

diff --git a/changelog/898.feature.md b/changelog/898.feature.md
index 895ec61b4..6ae8dea5d 100644
--- a/changelog/898.feature.md
+++ b/changelog/898.feature.md
@@ -7,3 +7,10 @@ so publishing a dataset to obs4MIPs takes over from the registry copy without an
 `ref doctor` gains three checks: `misfiled-obs4ref`, `superseded-obs4ref` and `unsolvable-diagnostics`.
 Data ingested with `--source-type obs4mips` in earlier releases still solves,
 but re-ingesting it with `--source-type obs4ref` is recommended.
+
+A data requirement can now declare the collections that may supply it,
+through the new `fallback_source_types` field on `DataRequirement`.
+A dataset present under `source_type` still wins,
+and the data is delivered under `source_type` whichever collection it came from.
+The solver, `ref doctor` and the generated reference data documentation all read the declaration.
+No provider declares a fallback yet, so nothing changes behaviourally.
diff --git a/packages/climate-ref-core/src/climate_ref_core/diagnostics.py b/packages/climate-ref-core/src/climate_ref_core/diagnostics.py
index 5aaf4018f..a61e98857 100644
--- a/packages/climate-ref-core/src/climate_ref_core/diagnostics.py
+++ b/packages/climate-ref-core/src/climate_ref_core/diagnostics.py
@@ -443,6 +443,14 @@ class DataRequirement:
     This is effectively an AND operation.
     """
 
+    fallback_source_types: tuple[SourceDatasetType, ...] = field(factory=tuple)
+    """
+    Source types that may supply this requirement when ``source_type`` does not hold the dataset.
+
+    A dataset present under ``source_type`` always wins, and the data is delivered under
+    ``source_type`` whichever collection it came from.
+    """
+
     def apply_filters(self, data_catalog: pd.DataFrame) -> pd.DataFrame:
         """
         Apply filters to a DataFrame-based data catalog.
diff --git a/packages/climate-ref-core/src/climate_ref_core/summary.py b/packages/climate-ref-core/src/climate_ref_core/summary.py
index 6ffdb6dac..685022bca 100644
--- a/packages/climate-ref-core/src/climate_ref_core/summary.py
+++ b/packages/climate-ref-core/src/climate_ref_core/summary.py
@@ -42,6 +42,14 @@ class DataRequirementSummary:
     which is what :mod:`climate_ref_core.reference_data` uses to work out where that dataset comes from.
     """
 
+    fallback_source_types: tuple[str, ...] = ()
+    """
+    Values of the source types that may supply the requirement when ``source_type`` does not.
+
+    :mod:`climate_ref_core.reference_data` reads this to resolve which registry carries a dataset,
+    so an obs4MIPs requirement served from the obs4REF registry is not reported as unobtainable.
+    """
+
 
 @frozen
 class RequirementSetSummary:
@@ -162,6 +170,7 @@ def summarize_data_requirement(req: DataRequirement) -> DataRequirementSummary:
         frequencies=_extract_facet_values(req.filters, "frequency"),
         group_by=req.group_by,
         source_ids=_extract_facet_values(req.filters, "source_id"),
+        fallback_source_types=tuple(t.value for t in req.fallback_source_types),
     )
 
 
diff --git a/packages/climate-ref/src/climate_ref/doctor/checks/data.py b/packages/climate-ref/src/climate_ref/doctor/checks/data.py
index c2b067e00..baa30df73 100644
--- a/packages/climate-ref/src/climate_ref/doctor/checks/data.py
+++ b/packages/climate-ref/src/climate_ref/doctor/checks/data.py
@@ -11,7 +11,7 @@
 """
 
 from collections import defaultdict
-from collections.abc import Mapping
+from collections.abc import Iterable, Mapping
 
 import pandas as pd
 
@@ -23,6 +23,7 @@
 from climate_ref.solver import (
     apply_obs4ref_fallback,
     as_frame,
+    catalog_for_requirement,
     extract_covered_datasets,
     obs_dataset_key,
     solve_executions,
@@ -30,6 +31,7 @@
 from climate_ref.text import pluralise
 from climate_ref_core.diagnostics import Diagnostic
 from climate_ref_core.exceptions import InvalidDiagnosticException
+from climate_ref_core.providers import DiagnosticProvider
 from climate_ref_core.reference_data import (
     ESGF_OBS4MIPS,
     ReferenceDataset,
@@ -109,6 +111,29 @@ def check_duplicate_coverage(context: DoctorContext) -> list[Finding]:
     return findings
 
 
+def _declared_fallbacks(providers: Iterable[DiagnosticProvider]) -> dict[str, set[str]]:
+    """
+    Collect the fallback source types the providers' requirements declare.
+
+    Parameters
+    ----------
+    providers
+        The providers to inspect.
+
+    Returns
+    -------
+    :
+        Source type values mapped to the source type values that may stand in for them.
+    """
+    declared: dict[str, set[str]] = defaultdict(set)
+    for provider in providers:
+        for diagnostic in summarize_provider(provider).diagnostics:
+            for requirement_set in diagnostic.requirement_sets:
+                for requirement in requirement_set.requirements:
+                    declared[requirement.source_type].update(requirement.fallback_source_types)
+    return declared
+
+
 def _collection_root(path: str, depth: int = 4) -> str:
     """Shorten a file path to the directory that identifies which collection it came from."""
     parts = str(path).split("/")
@@ -148,6 +173,9 @@ def check_missing_reference_data(context: DoctorContext) -> list[Finding]:
 
     # obs4REF fills in whatever obs4MIPs lacks, so either satisfies an obs4MIPs requirement.
     ingested[SourceDatasetType.obs4MIPs.value] |= ingested[SourceDatasetType.obs4REF.value]
+    for requested_type, fallbacks in _declared_fallbacks(context.providers).items():
+        for fallback in fallbacks:
+            ingested[requested_type] |= ingested[fallback]
 
     findings = []
     for dataset in sorted(required, key=lambda d: (d.supplier, d.source_id)):
@@ -219,6 +247,7 @@ def check_unreachable_source_types(context: DoctorContext) -> list[Finding]:
             for requirement_set in diagnostic.requirement_sets:
                 for requirement in requirement_set.requirements:
                     requested.add(requirement.source_type)
+                    requested.update(requirement.fallback_source_types)
     if SourceDatasetType.obs4MIPs.value in requested:
         requested.add(SourceDatasetType.obs4REF.value)
 
@@ -413,9 +442,8 @@ def _why_unsolvable(
     reasons = []
     for requirements in normalize_requirement_sets(diagnostic.data_requirements):
         for requirement in requirements:
-            catalog = available[requirement.source_type]
-            frame = as_frame(catalog)
-            if not len(frame):
+            catalog = catalog_for_requirement(available, requirement)
+            if catalog is None or not len(as_frame(catalog)):
                 reasons.append(f"nothing is ingested as {requirement.source_type.value}")
                 break
             if not extract_covered_datasets(catalog, requirement):
diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py
index 50256c635..22aa18731 100644
--- a/packages/climate-ref/src/climate_ref/solver.py
+++ b/packages/climate-ref/src/climate_ref/solver.py
@@ -265,53 +265,67 @@ def as_frame(catalog: pd.DataFrame | DataCatalog) -> pd.DataFrame:
     return catalog.to_frame() if isinstance(catalog, DataCatalog) else catalog
 
 
-def with_obs4ref_fallback(
-    obs4mips: pd.DataFrame | DataCatalog,
-    obs4ref: pd.DataFrame | DataCatalog,
+def union_with_fallbacks(
+    primary: pd.DataFrame | DataCatalog,
+    fallbacks: Sequence[pd.DataFrame | DataCatalog],
+    primary_type: SourceDatasetType,
 ) -> pd.DataFrame | DataCatalog:
     """
-    Fill an obs4MIPs catalog with the obs4REF datasets it lacks.
+    Fill a reference catalog with the datasets its fallback collections hold and it lacks.
 
     The obs4MIPs archive on ESGF is the official home of the reference data,
     and the obs4REF registry carries the datasets that are not published there yet.
-    A dataset present in both is taken from obs4MIPs, whichever version each holds,
+    A dataset present in both is taken from the primary, whichever version each holds,
     so publishing a dataset takes over from the registry copy without any re-ingest.
 
     Parameters
     ----------
-    obs4mips
-        The obs4MIPs catalog.
-    obs4ref
-        The obs4REF catalog.
+    primary
+        The catalog the requirement asks for, which wins on duplicates.
+    fallbacks
+        Catalogs that may stand in for it, tried in order.
+    primary_type
+        Source type the merged rows are delivered under.
 
     Returns
     -------
     :
-        The obs4MIPs catalog, extended with the obs4REF datasets it does not hold.
+        The primary catalog, extended with the fallback datasets it does not hold.
 
-        This includes the case of nothing being ingested as obs4MIPs at all
+        This includes the case of nothing being ingested as the primary type at all
         which is an ordinary deployment that fetched only the registry.
         The original catalog is returned untouched when there is nothing to add.
         A merge of two catalogs carries no adapter, so it cannot reload itself and lose the added rows.
-        The added rows carry ``activity_id`` of obs4MIPs, because that is the collection they stand in for.
-        Their ``instance_id`` still names obs4REF, so the provenance is not lost.
-    """
-    obs4ref_df = as_frame(obs4ref)
-    if obs4ref_df.empty or "instance_id" not in obs4ref_df.columns:
-        return obs4mips
-    obs4mips_df = as_frame(obs4mips)
-
-    held = set(obs_dataset_key(obs4mips_df["instance_id"])) if len(obs4mips_df) else set()
-    extra = obs4ref_df[~obs_dataset_key(obs4ref_df["instance_id"]).isin(held)]
-    if extra.empty:
-        return obs4mips
-
-    # The rows are served as obs4MIPs data, so they must group as obs4MIPs data too.
-    # A requirement grouping by activity_id would otherwise split its reference data in two.
-    if "activity_id" in extra.columns:
-        extra = extra.assign(activity_id=SourceDatasetType.obs4MIPs.name)
-    merged = pd.concat([obs4mips_df, extra] if len(obs4mips_df) else [extra], ignore_index=True)
-    if isinstance(obs4mips, DataCatalog) or isinstance(obs4ref, DataCatalog):
+        The added rows carry ``activity_id`` of the primary, because that is the collection they
+        stand in for.
+        Their ``instance_id`` still names the collection they came from, so the provenance is not lost.
+    """
+    primary_df = as_frame(primary)
+    held = set(obs_dataset_key(primary_df["instance_id"])) if len(primary_df) else set()
+
+    additions = []
+    for fallback in fallbacks:
+        fallback_df = as_frame(fallback)
+        if fallback_df.empty or "instance_id" not in fallback_df.columns:
+            continue
+        extra = fallback_df[~obs_dataset_key(fallback_df["instance_id"]).isin(held)]
+        if extra.empty:
+            continue
+        held |= set(obs_dataset_key(extra["instance_id"]))
+        # The rows are served as the primary's data, so they must group as the primary's data too.
+        # A requirement grouping by activity_id would otherwise split its reference data in two.
+        if "activity_id" in extra.columns:
+            extra = extra.assign(activity_id=primary_type.name)
+        additions.append(extra)
+
+    if not additions:
+        return primary
+
+    merged = pd.concat(
+        [primary_df, *additions] if len(primary_df) else additions,
+        ignore_index=True,
+    )
+    if isinstance(primary, DataCatalog) or any(isinstance(f, DataCatalog) for f in fallbacks):
         # No adapter can reload the merge, so the result carries none and never reloads.
         return DataCatalog.from_frame(merged)
     return merged
@@ -352,7 +366,7 @@ def apply_obs4ref_fallback(
     -------
     :
         The catalogs with obs4MIPs extended by the obs4REF datasets it lacks
-        (see `with_obs4ref_fallback`).
+        (see `union_with_fallbacks`).
         The mapping is returned unchanged when nothing is ingested as obs4REF.
     """
     if SourceDatasetType.obs4REF not in data_catalog:
@@ -360,7 +374,9 @@ def apply_obs4ref_fallback(
     obs4mips = data_catalog.get(SourceDatasetType.obs4MIPs, pd.DataFrame())
     return {
         **data_catalog,
-        SourceDatasetType.obs4MIPs: with_obs4ref_fallback(obs4mips, data_catalog[SourceDatasetType.obs4REF]),
+        SourceDatasetType.obs4MIPs: union_with_fallbacks(
+            obs4mips, [data_catalog[SourceDatasetType.obs4REF]], SourceDatasetType.obs4MIPs
+        ),
     }
 
 
@@ -427,6 +443,42 @@ def solve_executions(
         raise TypeError(f"Expected a DataRequirement, got {type(first_item)}")
 
 
+def catalog_for_requirement(
+    data_catalog: Mapping[SourceDatasetType, pd.DataFrame | DataCatalog],
+    requirement: DataRequirement,
+) -> pd.DataFrame | DataCatalog | None:
+    """
+    Resolve the catalog a requirement is solved against, folding in its declared fallbacks.
+
+    Parameters
+    ----------
+    data_catalog
+        Data catalogs for each source dataset type.
+    requirement
+        The requirement to resolve a catalog for.
+
+    Returns
+    -------
+    :
+        The requirement's catalog, or ``None`` when neither it nor any of its fallbacks
+        has been ingested.
+    """
+    fallbacks = [
+        data_catalog[source_type]
+        for source_type in requirement.fallback_source_types
+        if source_type in data_catalog
+    ]
+    if requirement.source_type not in data_catalog:
+        if not fallbacks:
+            return None
+        return union_with_fallbacks(pd.DataFrame(), fallbacks, requirement.source_type)
+
+    primary = data_catalog[requirement.source_type]
+    if not fallbacks:
+        return primary
+    return union_with_fallbacks(primary, fallbacks, requirement.source_type)
+
+
 def _solve_from_data_requirements(
     data_catalog: Mapping[SourceDatasetType, pd.DataFrame | DataCatalog],
     diagnostic: Diagnostic,
@@ -439,16 +491,15 @@ def _solve_from_data_requirements(
     for requirement in data_requirements:
         if not isinstance(requirement, DataRequirement):
             raise TypeError(f"Expected a DataRequirement, got {type(requirement)}")
-        if requirement.source_type not in data_catalog:
+        catalog = catalog_for_requirement(data_catalog, requirement)
+        if catalog is None:
             logger.debug(
                 f"No data catalog for source type {requirement.source_type} of "
                 f"{provider.slug} diagnostic {diagnostic.slug}"
             )
             return
 
-        dataset_groups[requirement.source_type] = extract_covered_datasets(
-            data_catalog[requirement.source_type], requirement
-        )
+        dataset_groups[requirement.source_type] = extract_covered_datasets(catalog, requirement)
 
     # Calculate the product across each of the source types
     for items in itertools.product(*dataset_groups.values()):
diff --git a/packages/climate-ref/tests/unit/test_doctor.py b/packages/climate-ref/tests/unit/test_doctor.py
index e2df296a0..50cdd89f3 100644
--- a/packages/climate-ref/tests/unit/test_doctor.py
+++ b/packages/climate-ref/tests/unit/test_doctor.py
@@ -7,6 +7,10 @@
 
 import pandas as pd
 import pytest
+from climate_ref_esmvaltool import provider as esmvaltool_provider
+from climate_ref_example import provider as example_provider
+from climate_ref_ilamb import provider as ilamb_provider
+from climate_ref_pmp import provider as pmp_provider
 
 from climate_ref.doctor import (
     DoctorContext,
@@ -478,3 +482,30 @@ def test_worst_severity(self, severities, expected):
         findings = [Finding(severity=s, summary="s") for s in severities]
 
         assert worst_severity(findings) == expected
+
+
+def test_diagnose_snapshot_seeded(db_seeded, config, data_regression):
+    """
+    Pin the doctor findings over the seeded catalogs and every in-repo provider.
+
+    This is the doctor half of the oracle for the obs4REF fallback work,
+    so do not regenerate it with ``--force-regen``.
+    """
+    context = DoctorContext(config=config, database=db_seeded)
+    context._providers = [example_provider, pmp_provider, esmvaltool_provider, ilamb_provider]
+
+    findings = diagnose(context).findings
+
+    reported = [
+        {
+            "check": finding.check,
+            "severity": str(finding.severity),
+            "summary": finding.summary,
+            "detail": finding.detail,
+            "remedy": finding.remedy,
+            "command": finding.command,
+        }
+        for finding in findings
+    ]
+
+    data_regression.check(sorted(reported, key=lambda entry: tuple(sorted(entry.items()))))
diff --git a/packages/climate-ref/tests/unit/test_doctor/test_diagnose_snapshot_seeded.yml b/packages/climate-ref/tests/unit/test_doctor/test_diagnose_snapshot_seeded.yml
new file mode 100644
index 000000000..a4b7b5775
--- /dev/null
+++ b/packages/climate-ref/tests/unit/test_doctor/test_diagnose_snapshot_seeded.yml
@@ -0,0 +1,244 @@
+- check: missing-reference-data
+  command: ''
+  detail: Needed for areacello, sic by esmvaltool/sea-ice-area-basic.
+  remedy: No registry carries these and they are not known to be on ESGF.
+  severity: warning
+  summary: OSI-450-nh (esmvaltool-reference) is not ingested, so 1 diagnostic will
+    not run
+- check: missing-reference-data
+  command: ''
+  detail: Needed for areacello, sic by esmvaltool/sea-ice-area-basic.
+  remedy: No registry carries these and they are not known to be on ESGF.
+  severity: warning
+  summary: OSI-450-sh (esmvaltool-reference) is not ingested, so 1 diagnostic will
+    not run
+- check: missing-reference-data
+  command: ''
+  detail: Needed for lai by ilamb/lai-avh15c1.
+  remedy: These are published to obs4MIPs on ESGF, and `scripts/fetch-esgf.py` has
+    a request for each. See the 'Download required datasets' guide.
+  severity: warning
+  summary: NOAA-NCEI-LAI-AVHRR-5-0 (obs4mips) is not ingested, so 1 diagnostic will
+    not run
+- check: missing-reference-data
+  command: ''
+  detail: Needed for psl by pmp/extratropical-modes-of-variability-nam, pmp/extratropical-modes-of-variability-nao,
+    pmp/extratropical-modes-of-variability-npo, pmp/extratropical-modes-of-variability-pna,
+    pmp/extratropical-modes-of-variability-sam.
+  remedy: These are published to obs4MIPs on ESGF, and `scripts/fetch-esgf.py` has
+    a request for each. See the 'Download required datasets' guide.
+  severity: warning
+  summary: 20CR-V2 (obs4mips) is not ingested, so 5 diagnostics will not run
+- check: missing-reference-data
+  command: ''
+  detail: Needed for rlut, rlutcs, rsut, rsutcs by esmvaltool/cloud-radiative-effects.
+  remedy: These are published to obs4MIPs on ESGF, and `scripts/fetch-esgf.py` has
+    a request for each. See the 'Download required datasets' guide.
+  severity: warning
+  summary: CERES-EBAF-4-2-1 (obs4mips) is not ingested, so 1 diagnostic will not run
+- check: missing-reference-data
+  command: ref datasets fetch-data --registry obs4ref --output-directory 
+  detail: Needed for burntFractionAll by ilamb/burntfractionall-gfed.
+  remedy: Fetch these, then ingest the directory they land in.
+  severity: warning
+  summary: GFED-5-0 (obs4mips) is not ingested, so 1 diagnostic will not run
+- check: missing-reference-data
+  command: ref datasets fetch-data --registry obs4ref --output-directory 
+  detail: Needed for cSoil by ilamb/csoil-hwsd2.
+  remedy: Fetch these, then ingest the directory they land in.
+  severity: warning
+  summary: HWSD-2-0 (obs4mips) is not ingested, so 1 diagnostic will not run
+- check: missing-reference-data
+  command: ref datasets fetch-data --registry obs4ref --output-directory 
+  detail: Needed for mrro by ilamb/mrro-lora.
+  remedy: Fetch these, then ingest the directory they land in.
+  severity: warning
+  summary: LORA-1-0 (obs4mips) is not ingested, so 1 diagnostic will not run
+- check: missing-reference-data
+  command: ref datasets fetch-data --registry obs4ref --output-directory 
+  detail: Needed for msftmz by ilamb/amoc-rapid.
+  remedy: Fetch these, then ingest the directory they land in.
+  severity: warning
+  summary: RAPID-2023-1a (obs4mips) is not ingested, so 1 diagnostic will not run
+- check: missing-reference-data
+  command: ref datasets fetch-data --registry obs4ref --output-directory 
+  detail: Needed for nbp by ilamb/nbp-hoffman.
+  remedy: Fetch these, then ingest the directory they land in.
+  severity: warning
+  summary: Hoffman-1-0 (obs4mips) is not ingested, so 1 diagnostic will not run
+- check: missing-reference-data
+  command: ref datasets fetch-data --registry obs4ref --output-directory 
+  detail: Needed for snc by ilamb/snc-esacci.
+  remedy: Fetch these, then ingest the directory they land in.
+  severity: warning
+  summary: CCI-CryoClim-FSC-1 (obs4mips) is not ingested, so 1 diagnostic will not
+    run
+- check: missing-reference-data
+  command: ref datasets fetch-data --registry obs4ref --output-directory 
+  detail: Needed for so, thetao by ilamb/so-woa2023-surface, ilamb/thetao-woa2023-surface.
+  remedy: Fetch these, then ingest the directory they land in.
+  severity: warning
+  summary: WOA-23 (obs4mips) is not ingested, so 2 diagnostics will not run
+- check: missing-reference-data
+  command: ref datasets fetch-data --registry pmp-climatology --output-directory 
+  detail: Needed for pr by pmp/annual-cycle.
+  remedy: Fetch these, then ingest the directory they land in.
+  severity: warning
+  summary: GPCP-3-3 (pmp-climatology) is not ingested, so 1 diagnostic will not run
+- check: missing-reference-data
+  command: ref datasets fetch-data --registry pmp-climatology --output-directory 
+  detail: Needed for psl, ta, ts, ua, uas, va, vas, zg by pmp/annual-cycle.
+  remedy: Fetch these, then ingest the directory they land in.
+  severity: warning
+  summary: ERA-5 (pmp-climatology) is not ingested, so 1 diagnostic will not run
+- check: missing-reference-data
+  command: ref datasets fetch-data --registry pmp-climatology --output-directory 
+  detail: Needed for rlds, rlus, rlut, rsds, rsdt, rsus, rsut by pmp/annual-cycle.
+  remedy: Fetch these, then ingest the directory they land in.
+  severity: warning
+  summary: CERES-EBAF-4-2 (pmp-climatology) is not ingested, so 1 diagnostic will
+    not run
+- check: overlapping-registries
+  command: ''
+  detail: 'Carried by: obs4ref, quickstart.'
+  remedy: Fetch each of these from one registry only. Ingesting two copies of the
+    same version gives one dataset holding both sets of files.
+  severity: info
+  summary: HadISST-1-1 is carried by 2 registries
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no cmip6 datasets match experiment_id=historical, table_id=Amon,
+    variable_id=hurs|pr|tas|tasmax; experiment_id=historical, table_id=Lmon, variable_id=cVeg|treeFrac;
+    experiment_id=historical, table_id=Emon, variable_id=vegFrac. nothing is ingested
+    as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: esmvaltool/climate-drivers-for-fire has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no cmip6 datasets match experiment_id=historical|land-hist, frequency=mon,
+    table_id=AERmonZ|Amon|CFmon|Emon|EmonZ|LImon|Lmon|Omon|SImon, variable_id=emp|evspsbl.
+    nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: ilamb/emp-gleamgpcp2.3 has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match frequency=mon, source_id=CERES-EBAF-4-2-1,
+    variable_id=rlut|rlutcs|rsut|rsutcs. nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: esmvaltool/cloud-radiative-effects has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match grid_label=gm, source_id=Hoffman-1-0,
+    variable_id=nbp, version=v20251117. nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: ilamb/nbp-hoffman has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match grid_label=gm, source_id=RAPID-2023-1a,
+    variable_id=msftmz, version=v20250902. nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: ilamb/amoc-rapid has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match grid_label=gn, source_id=CCI-CryoClim-FSC-1,
+    variable_id=snc, version=v20250519. nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: ilamb/snc-esacci has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match grid_label=gn, source_id=HWSD-2-0, variable_id=cSoil,
+    version=v20250903. nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: ilamb/csoil-hwsd2 has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match grid_label=gn, source_id=LORA-1-0, variable_id=mrro,
+    version=v20250902. nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: ilamb/mrro-lora has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match grid_label=gn, source_id=NOAA-NCEI-LAI-AVHRR-5-0,
+    variable_id=lai. nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: ilamb/lai-avh15c1 has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match grid_label=gn, source_id=WECANN-1-0,
+    variable_id=gpp, version=v20250902. nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: ilamb/gpp-wecann has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match grid_label=gn, source_id=WOA-23, variable_id=so,
+    version=v20251024. nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: ilamb/so-woa2023-surface has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match grid_label=gn, source_id=WOA-23, variable_id=thetao,
+    version=v20251024. nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: ilamb/thetao-woa2023-surface has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match grid_label=gr, source_id=GFED-5-0, variable_id=burntFractionAll,
+    version=v20260128. nothing is ingested as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: ilamb/burntfractionall-gfed has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match source_id=20CR-V2, variable_id=psl.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: pmp/extratropical-modes-of-variability-nam has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match source_id=20CR-V2, variable_id=psl.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: pmp/extratropical-modes-of-variability-nao has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match source_id=20CR-V2, variable_id=psl.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: pmp/extratropical-modes-of-variability-npo has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match source_id=20CR-V2, variable_id=psl.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: pmp/extratropical-modes-of-variability-pna has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: no obs4mips datasets match source_id=20CR-V2, variable_id=psl.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: pmp/extratropical-modes-of-variability-sam has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: nothing is ingested as esmvaltool-reference. nothing is ingested
+    as cmip7.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: esmvaltool/sea-ice-area-basic has no executions
+- check: unsolvable-diagnostics
+  command: ''
+  detail: 'Unmet: nothing is ingested as pmp-climatology.'
+  remedy: Ingest the data the unmet requirement names, then run the solver again.
+  severity: warning
+  summary: pmp/annual-cycle has no executions
diff --git a/packages/climate-ref/tests/unit/test_solver.py b/packages/climate-ref/tests/unit/test_solver.py
index 560d53bec..364c856c2 100644
--- a/packages/climate-ref/tests/unit/test_solver.py
+++ b/packages/climate-ref/tests/unit/test_solver.py
@@ -20,13 +20,15 @@
     DiagnosticExecution,
     ExecutionSolver,
     SolveFilterOptions,
+    _solve_from_data_requirements,
     apply_dataset_filters,
     apply_obs4ref_fallback,
+    catalog_for_requirement,
     extract_covered_datasets,
     matches_filter,
     solve_executions,
     solve_required_executions,
-    with_obs4ref_fallback,
+    union_with_fallbacks,
 )
 from climate_ref_core.constraints import AddParentDataset, AddSupplementaryDataset, RequireFacets
 from climate_ref_core.datasets import SourceDatasetType
@@ -789,6 +791,31 @@ def test_two_executions_same_group_distinct_output_fragments(
     assert fragment_b.split("/")[-1] == str(executions[1].id)
 
 
+def test_solve_snapshot_all_providers(db_seeded, config, data_regression):
+    """
+    Pin the solved output across every in-repo provider.
+
+    This is the oracle for the obs4REF fallback work. It must not move when the implicit
+    fold is replaced by declared fallbacks, so do not regenerate it with ``--force-regen``.
+
+    Rows are sorted because a seeded solve does not fix their order between runs.
+    `TestObs4REFFallback` pins the order the fallback itself produces.
+    """
+    solver = ExecutionSolver.build_from_db(config, db_seeded)
+    solver.provider_registry = ProviderRegistry(
+        providers=[example_provider, pmp_provider, esmvaltool_provider, ilamb_provider]
+    )
+
+    output = {}
+    for execution in solver.solve():
+        output[execution.execution_slug()] = {
+            str(source_type): sorted(collection.datasets[collection.slug_column])
+            for source_type, collection in execution.datasets.items()
+        }
+
+    data_regression.check(output)
+
+
 def test_solve_metrics(mocker, db_seeded, solver, data_regression, mock_executor):
     mock_build_solver = mocker.patch.object(ExecutionSolver, "build_from_db")
 
@@ -1975,7 +2002,7 @@ def test_missing_datasets_are_added(self):
         obs4mips = self._frame("obs4MIPs", ["A"])
         obs4ref = self._frame("obs4REF", ["A", "B"], start=10)
 
-        merged = with_obs4ref_fallback(obs4mips, obs4ref)
+        merged = union_with_fallbacks(obs4mips, [obs4ref], SourceDatasetType.obs4MIPs)
 
         # A is taken from obs4MIPs only, B comes from obs4REF and keeps its dataset id.
         assert merged["source_id"].tolist() == ["A", "B"]
@@ -1988,7 +2015,7 @@ def test_the_merged_catalog_cannot_reload_away_the_obs4ref_rows(self):
         )
         obs4ref = DataCatalog.from_frame(self._frame("obs4REF", ["B"]))
 
-        merged = with_obs4ref_fallback(obs4mips, obs4ref)
+        merged = union_with_fallbacks(obs4mips, [obs4ref], SourceDatasetType.obs4MIPs)
 
         # A reload would go back to the obs4MIPs adapter alone and drop B.
         assert isinstance(merged, DataCatalog)
@@ -2000,7 +2027,7 @@ def test_added_rows_group_as_obs4mips(self):
         obs4mips = self._frame("obs4MIPs", ["A"]).assign(activity_id="obs4MIPs")
         obs4ref = self._frame("obs4REF", ["B"], start=10).assign(activity_id="obs4REF")
 
-        merged = with_obs4ref_fallback(obs4mips, obs4ref)
+        merged = union_with_fallbacks(obs4mips, [obs4ref], SourceDatasetType.obs4MIPs)
 
         assert merged["activity_id"].tolist() == ["obs4MIPs", "obs4MIPs"]
         assert merged["instance_id"].iloc[1].startswith("obs4REF.")
@@ -2009,18 +2036,18 @@ def test_obs4mips_wins_whatever_the_versions(self):
         obs4mips = self._frame("obs4MIPs", ["A"], version="v1")
         obs4ref = self._frame("obs4REF", ["A"], version="v2", start=10)
 
-        assert with_obs4ref_fallback(obs4mips, obs4ref) is obs4mips
+        assert union_with_fallbacks(obs4mips, [obs4ref], SourceDatasetType.obs4MIPs) is obs4mips
 
     def test_untouched_when_nothing_to_add(self):
         obs4mips = self._frame("obs4MIPs", ["A"])
 
-        assert with_obs4ref_fallback(obs4mips, pd.DataFrame()) is obs4mips
+        assert union_with_fallbacks(obs4mips, [pd.DataFrame()], SourceDatasetType.obs4MIPs) is obs4mips
 
     def test_obs4ref_alone_still_groups_as_obs4mips(self):
         # A deployment that fetched only the registry is the ordinary case, not an edge case.
         obs4ref = self._frame("obs4REF", ["A"]).assign(activity_id="obs4REF")
 
-        merged = with_obs4ref_fallback(pd.DataFrame(), obs4ref)
+        merged = union_with_fallbacks(pd.DataFrame(), [obs4ref], SourceDatasetType.obs4MIPs)
 
         assert merged["activity_id"].tolist() == ["obs4MIPs"]
         assert merged["instance_id"].tolist() == obs4ref["instance_id"].tolist()
@@ -2040,3 +2067,92 @@ def test_untouched_without_an_obs4ref_catalog(self):
         catalogs = {SourceDatasetType.obs4MIPs: self._frame("obs4MIPs", ["A"])}
 
         assert apply_obs4ref_fallback(catalogs) is catalogs
+
+
+class TestDeclaredFallbacks:
+    """A requirement names the collections that may stand in for its source type."""
+
+    # PMPClimatology is never folded globally, so a union of it can only come from the requirement.
+    FALLBACK = SourceDatasetType.PMPClimatology
+
+    @staticmethod
+    def _frame(prefix, source_ids, version="v1", start=0):
+        return pd.DataFrame(
+            {
+                "instance_id": [f"{prefix}.{prefix}.INST.{s}.mon.ts.gn.{version}" for s in source_ids],
+                "source_id": list(source_ids),
+                "variable_id": "ts",
+                "activity_id": prefix,
+                "path": [f"/data/{prefix}/{s}.nc" for s in source_ids],
+            },
+            index=range(start, start + len(source_ids)),
+        )
+
+    def _requirement(self, fallbacks):
+        return DataRequirement(
+            source_type=SourceDatasetType.obs4MIPs,
+            filters=(),
+            group_by=("activity_id",),
+            fallback_source_types=fallbacks,
+        )
+
+    def test_a_declared_fallback_is_unioned_into_the_requirement(self):
+        catalogs = {
+            SourceDatasetType.obs4MIPs: self._frame("obs4MIPs", ["A"]),
+            self.FALLBACK: self._frame("pmp-climatology", ["B"], start=10),
+        }
+
+        catalog = catalog_for_requirement(catalogs, self._requirement((self.FALLBACK,)))
+
+        assert catalog["source_id"].tolist() == ["A", "B"]
+        assert catalog["activity_id"].tolist() == ["obs4MIPs", "obs4MIPs"]
+        assert catalog.index.tolist() == [0, 1]
+
+    def test_an_undeclared_collection_is_left_out(self):
+        catalogs = {
+            SourceDatasetType.obs4MIPs: self._frame("obs4MIPs", ["A"]),
+            self.FALLBACK: self._frame("pmp-climatology", ["B"], start=10),
+        }
+
+        catalog = catalog_for_requirement(catalogs, self._requirement(()))
+
+        assert catalog["source_id"].tolist() == ["A"]
+
+    def test_a_requirement_solves_from_its_fallback_alone(self, provider, mock_diagnostic):
+        # An ordinary registry-only deployment ingests nothing under the primary type.
+        catalogs = {self.FALLBACK: self._frame("pmp-climatology", ["A", "B"])}
+
+        executions = list(
+            _solve_from_data_requirements(
+                catalogs, mock_diagnostic, [self._requirement((self.FALLBACK,))], provider
+            )
+        )
+
+        # The data is delivered under the primary type whichever collection supplied it.
+        assert len(executions) == 1
+        datasets = executions[0].datasets[SourceDatasetType.obs4MIPs]
+        assert datasets["source_id"].tolist() == ["A", "B"]
+        assert datasets.selector_dict() == {"activity_id": "obs4MIPs"}
+
+    def test_a_missing_fallback_catalog_is_tolerated(self):
+        catalogs = {SourceDatasetType.obs4MIPs: self._frame("obs4MIPs", ["A"])}
+
+        catalog = catalog_for_requirement(catalogs, self._requirement((self.FALLBACK,)))
+
+        assert catalog["source_id"].tolist() == ["A"]
+
+    def test_nothing_ingested_under_the_primary_or_any_fallback(self):
+        assert catalog_for_requirement({}, self._requirement((self.FALLBACK,))) is None
+
+    def test_the_union_is_idempotent(self):
+        # The global fold and the per-requirement union both run, so applying twice must not double up.
+        primary = self._frame("obs4MIPs", ["A"])
+        fallback = self._frame("obs4REF", ["A", "B"], start=10)
+
+        once = union_with_fallbacks(primary, [fallback], SourceDatasetType.obs4MIPs)
+        twice = union_with_fallbacks(once, [fallback], SourceDatasetType.obs4MIPs)
+
+        assert twice is once
+        pd.testing.assert_frame_equal(once, twice)
+        assert once["source_id"].tolist() == ["A", "B"]
+        assert once.index.tolist() == [0, 1]
diff --git a/packages/climate-ref/tests/unit/test_solver/test_solve_snapshot_all_providers.yml b/packages/climate-ref/tests/unit/test_solver/test_solve_snapshot_all_providers.yml
new file mode 100644
index 000000000..2f7dac014
--- /dev/null
+++ b/packages/climate-ref/tests/unit/test_solver/test_solve_snapshot_all_providers.yml
@@ -0,0 +1,651 @@
+esmvaltool/climate-at-global-warming-levels/cmip6_ssp126:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.pr.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+  - CMIP6.ScenarioMIP.CSIRO.ACCESS-ESM1-5.ssp126.r1i1p1f1.Amon.pr.gn.v20210318
+  - CMIP6.ScenarioMIP.CSIRO.ACCESS-ESM1-5.ssp126.r1i1p1f1.Amon.tas.gn.v20210318
+  - CMIP6.ScenarioMIP.CSIRO.ACCESS-ESM1-5.ssp126.r1i1p1f1.fx.areacella.gn.v20210318
+esmvaltool/cloud-scatterplots-cli-ta/cmip6_historical_mon_gn_r1i1p1f1_CESM2:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.cli.gn.v20190308
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.ta.gn.v20190308
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308
+esmvaltool/cloud-scatterplots-clivi-lwcre/cmip6_historical_mon_gn_r1i1p1f1_CESM2:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.clivi.gn.v20190308
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.rlut.gn.v20190308
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.rlutcs.gn.v20190308
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308
+esmvaltool/cloud-scatterplots-clt-swcre/cmip6_historical_mon_gn_r1i1p1f1_CESM2:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.clt.gn.v20190308
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.rsut.gn.v20190308
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.rsutcs.gn.v20190308
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308
+esmvaltool/cloud-scatterplots-clwvi-pr/cmip6_historical_mon_gn_r1i1p1f1_CESM2:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.clwvi.gn.v20190308
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.pr.gn.v20190401
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308
+esmvaltool/cloud-scatterplots-reference/obs4mips_obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ta.25km.gn.v20250220:
+  SourceDatasetType.obs4MIPs:
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ta.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ta.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ta.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ta.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ta.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ta.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ta.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ta.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ta.25km.gn.v20250220
+esmvaltool/enso-basic-climatology/cmip6_gn_r1i1p1f1_ACCESS-ESM1-5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.pr.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.tauu.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Omon.tos.gn.v20191115
+esmvaltool/enso-characteristics/cmip6_gn_r1i1p1f1_ACCESS-ESM1-5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Ofx.areacello.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Omon.tos.gn.v20191115
+esmvaltool/equilibrium-climate-sensitivity/cmip6_gn_r1i1p1f1_ACCESS-ESM1-5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.abrupt-4xCO2.r1i1p1f1.Amon.rlut.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.abrupt-4xCO2.r1i1p1f1.Amon.rsdt.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.abrupt-4xCO2.r1i1p1f1.Amon.rsut.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.abrupt-4xCO2.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.abrupt-4xCO2.r1i1p1f1.fx.areacella.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.Amon.rlut.gn.v20210316
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.Amon.rsdt.gn.v20210316
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.Amon.rsut.gn.v20210316
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.Amon.tas.gn.v20210316
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.fx.areacella.gn.v20210316
+esmvaltool/global-mean-timeseries/cmip6_1pctCO2_gn_r1i1p1f1_ACCESS-ESM1-5_Amon_tas:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.1pctCO2.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.1pctCO2.r1i1p1f1.fx.areacella.gn.v20191115
+esmvaltool/global-mean-timeseries/cmip6_abrupt-4xCO2_gn_r1i1p1f1_ACCESS-ESM1-5_Amon_tas:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.abrupt-4xCO2.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.abrupt-4xCO2.r1i1p1f1.fx.areacella.gn.v20191115
+esmvaltool/global-mean-timeseries/cmip6_esm-1pct-brch-1000PgC_gn_r1i1p1f1_ACCESS-ESM1-5_Amon_tas:
+  SourceDatasetType.CMIP6:
+  - CMIP6.C4MIP CDRMIP.CSIRO.ACCESS-ESM1-5.esm-1pct-brch-1000PgC.r1i1p1f1.Amon.tas.gn.v20191206
+  - CMIP6.C4MIP CDRMIP.CSIRO.ACCESS-ESM1-5.esm-1pct-brch-1000PgC.r1i1p1f1.fx.areacella.gn.v20191206
+esmvaltool/global-mean-timeseries/cmip6_esm-1pctCO2_gn_r1i1p1f1_MPI-ESM1-2-LR_Amon_tas:
+  SourceDatasetType.CMIP6:
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.fx.areacella.gn.v20190815
+esmvaltool/global-mean-timeseries/cmip6_esm-piControl_gn_r1i1p1f1_MPI-ESM1-2-LR_Amon_tas:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.fx.areacella.gn.v20190815
+esmvaltool/global-mean-timeseries/cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5_Amon_tas:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+esmvaltool/global-mean-timeseries/cmip6_historical_gn_r1i1p1f1_CanESM5_Amon_tas:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Amon.tas.gn.v20190429
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.areacella.gn.v20190429
+esmvaltool/global-mean-timeseries/cmip6_historical_gn_r1i1p1f3_HadGEM3-GC31-LL_Amon_tas:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.Amon.tas.gn.v20190624
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.piControl.r1i1p1f1.fx.areacella.gn.v20190709
+esmvaltool/global-mean-timeseries/cmip6_piControl_gn_r1i1p1f1_ACCESS-ESM1-5_Amon_tas:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.Amon.tas.gn.v20210316
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.fx.areacella.gn.v20210316
+esmvaltool/global-mean-timeseries/cmip6_ssp126_gn_r1i1p1f1_ACCESS-ESM1-5_Amon_tas:
+  SourceDatasetType.CMIP6:
+  - CMIP6.ScenarioMIP.CSIRO.ACCESS-ESM1-5.ssp126.r1i1p1f1.Amon.tas.gn.v20210318
+  - CMIP6.ScenarioMIP.CSIRO.ACCESS-ESM1-5.ssp126.r1i1p1f1.fx.areacella.gn.v20210318
+esmvaltool/ozone-annual-cycle/cmip6_gr_r1i1p1f2_CNRM-ESM2-1__obs4mips_C3S-GTO-ECV-9-0:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.AERmon.toz.gr.v20181206
+  SourceDatasetType.obs4MIPs:
+  - obs4MIPs.obs4MIPs.DLR-BIRA.C3S-GTO-ECV-9-0.mon.toz.1x1degree.gn.v20231115
+esmvaltool/ozone-lat-time/cmip6_gr_r1i1p1f2_CNRM-ESM2-1__obs4mips_C3S-GTO-ECV-9-0:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.AERmon.toz.gr.v20181206
+  SourceDatasetType.obs4MIPs:
+  - obs4MIPs.obs4MIPs.DLR-BIRA.C3S-GTO-ECV-9-0.mon.toz.1x1degree.gn.v20231115
+esmvaltool/ozone-nh-mar/cmip6_gr_r1i1p1f2_CNRM-ESM2-1__obs4mips_C3S-GTO-ECV-9-0:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.AERmon.toz.gr.v20181206
+  SourceDatasetType.obs4MIPs:
+  - obs4MIPs.obs4MIPs.DLR-BIRA.C3S-GTO-ECV-9-0.mon.toz.1x1degree.gn.v20231115
+esmvaltool/ozone-sh-oct/cmip6_gr_r1i1p1f2_CNRM-ESM2-1__obs4mips_C3S-GTO-ECV-9-0:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.AERmon.toz.gr.v20181206
+  SourceDatasetType.obs4MIPs:
+  - obs4MIPs.obs4MIPs.DLR-BIRA.C3S-GTO-ECV-9-0.mon.toz.1x1degree.gn.v20231115
+esmvaltool/ozone-zonal/cmip6_gr_r1i1p1f2_CNRM-ESM2-1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CNRM-CERFACS.CNRM-ESM2-1.historical.r1i1p1f2.Amon.o3.gr.v20181206
+esmvaltool/regional-historical-annual-cycle/cmip6_gn_r1i1p1f1_ACCESS-ESM1-5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.hus.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.pr.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.psl.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.ua.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+esmvaltool/regional-historical-annual-cycle/cmip6_gn_r1i1p1f1_CanESM5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Amon.tas.gn.v20190429
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.areacella.gn.v20190429
+esmvaltool/regional-historical-annual-cycle/cmip6_gn_r1i1p1f3_HadGEM3-GC31-LL:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.Amon.tas.gn.v20190624
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.piControl.r1i1p1f1.fx.areacella.gn.v20190709
+esmvaltool/regional-historical-annual-cycle/obs4mips_ERA-5:
+  SourceDatasetType.obs4MIPs:
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+esmvaltool/regional-historical-timeseries/cmip6_gn_r1i1p1f1_ACCESS-ESM1-5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.hus.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.pr.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.psl.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.ua.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+esmvaltool/regional-historical-timeseries/cmip6_gn_r1i1p1f1_CanESM5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Amon.tas.gn.v20190429
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.areacella.gn.v20190429
+esmvaltool/regional-historical-timeseries/cmip6_gn_r1i1p1f3_HadGEM3-GC31-LL:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.Amon.tas.gn.v20190624
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.piControl.r1i1p1f1.fx.areacella.gn.v20190709
+esmvaltool/regional-historical-timeseries/obs4mips_ERA-5:
+  SourceDatasetType.obs4MIPs:
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+esmvaltool/regional-historical-trend/cmip6_gn_r1i1p1f1_ACCESS-ESM1-5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.hus.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.pr.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.psl.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.ua.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+esmvaltool/regional-historical-trend/cmip6_gn_r1i1p1f1_CanESM5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Amon.tas.gn.v20190429
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.areacella.gn.v20190429
+esmvaltool/regional-historical-trend/cmip6_gn_r1i1p1f3_HadGEM3-GC31-LL:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.Amon.tas.gn.v20190624
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.piControl.r1i1p1f1.fx.areacella.gn.v20190709
+esmvaltool/regional-historical-trend/obs4mips_ERA-5:
+  SourceDatasetType.obs4MIPs:
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.psl.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.tas.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+  - obs4MIPs.obs4MIPs.ECMWF.ERA-5.mon.ua.25km.gn.v20250220
+esmvaltool/sea-ice-sensitivity/cmip6_historical:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Amon.tas.gn.v20190429
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Ofx.areacello.gn.v20190429
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.SImon.siconc.gn.v20190429
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.areacella.gn.v20190429
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Ofx.areacello.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.SImon.siconc.gn.v20200817
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.Amon.tas.gn.v20190624
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.SImon.siconc.gn.v20200330
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.piControl.r1i1p1f1.Ofx.areacello.gn.v20190709
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.piControl.r1i1p1f1.fx.areacella.gn.v20190709
+esmvaltool/transient-climate-response-emissions/cmip6_gn_r1i1p1f1_MPI-ESM1-2-LR:
+  SourceDatasetType.CMIP6:
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.fco2antt.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.fco2antt.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.fco2antt.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.fco2antt.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.fx.areacella.gn.v20190815
+esmvaltool/transient-climate-response/cmip6_gn_r1i1p1f1_ACCESS-ESM1-5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.1pctCO2.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.1pctCO2.r1i1p1f1.fx.areacella.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.Amon.tas.gn.v20210316
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.fx.areacella.gn.v20210316
+esmvaltool/zero-emission-commitment/cmip6_gn_r1i1p1f1_ACCESS-ESM1-5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.C4MIP CDRMIP.CSIRO.ACCESS-ESM1-5.esm-1pct-brch-1000PgC.r1i1p1f1.Amon.tas.gn.v20191206
+  - CMIP6.C4MIP CDRMIP.CSIRO.ACCESS-ESM1-5.esm-1pct-brch-1000PgC.r1i1p1f1.fx.areacella.gn.v20191206
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.1pctCO2.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.1pctCO2.r1i1p1f1.fx.areacella.gn.v20191115
+example/global-mean-timeseries/cmip6_1pctCO2_ACCESS-ESM1-5_tas_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.1pctCO2.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.1pctCO2.r1i1p1f1.fx.areacella.gn.v20191115
+example/global-mean-timeseries/cmip6_abrupt-4xCO2_ACCESS-ESM1-5_rsut_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.abrupt-4xCO2.r1i1p1f1.Amon.rsut.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.abrupt-4xCO2.r1i1p1f1.fx.areacella.gn.v20191115
+example/global-mean-timeseries/cmip6_abrupt-4xCO2_ACCESS-ESM1-5_tas_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.abrupt-4xCO2.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.abrupt-4xCO2.r1i1p1f1.fx.areacella.gn.v20191115
+example/global-mean-timeseries/cmip6_esm-1pct-brch-1000PgC_ACCESS-ESM1-5_tas_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.C4MIP CDRMIP.CSIRO.ACCESS-ESM1-5.esm-1pct-brch-1000PgC.r1i1p1f1.Amon.tas.gn.v20191206
+  - CMIP6.C4MIP CDRMIP.CSIRO.ACCESS-ESM1-5.esm-1pct-brch-1000PgC.r1i1p1f1.fx.areacella.gn.v20191206
+example/global-mean-timeseries/cmip6_esm-1pctCO2_MPI-ESM1-2-LR_tas_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.C4MIP CDRMIP.MPI-M.MPI-ESM1-2-LR.esm-1pctCO2.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.fx.areacella.gn.v20190815
+example/global-mean-timeseries/cmip6_esm-piControl_MPI-ESM1-2-LR_tas_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.Amon.tas.gn.v20190815
+  - CMIP6.CMIP.MPI-M.MPI-ESM1-2-LR.esm-piControl.r1i1p1f1.fx.areacella.gn.v20190815
+example/global-mean-timeseries/cmip6_historical_ACCESS-ESM1-5_rsut_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.rsut.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+example/global-mean-timeseries/cmip6_historical_ACCESS-ESM1-5_tas_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.tas.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+example/global-mean-timeseries/cmip6_historical_CESM2_rsut_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.rsut.gn.v20190308
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308
+example/global-mean-timeseries/cmip6_historical_CanESM5_tas_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.Amon.tas.gn.v20190429
+  - CMIP6.CMIP.CCCma.CanESM5.historical.r1i1p1f1.fx.areacella.gn.v20190429
+example/global-mean-timeseries/cmip6_historical_HadGEM3-GC31-LL_tas_r1i1p1f3:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.historical.r1i1p1f3.Amon.tas.gn.v20190624
+  - CMIP6.CMIP.MOHC.HadGEM3-GC31-LL.piControl.r1i1p1f1.fx.areacella.gn.v20190709
+example/global-mean-timeseries/cmip6_piControl_ACCESS-ESM1-5_rsut_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.Amon.rsut.gn.v20210316
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.fx.areacella.gn.v20210316
+example/global-mean-timeseries/cmip6_piControl_ACCESS-ESM1-5_tas_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.Amon.tas.gn.v20210316
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.piControl.r1i1p1f1.fx.areacella.gn.v20210316
+example/global-mean-timeseries/cmip6_ssp126_ACCESS-ESM1-5_rsut_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.ScenarioMIP.CSIRO.ACCESS-ESM1-5.ssp126.r1i1p1f1.Amon.rsut.gn.v20210318
+  - CMIP6.ScenarioMIP.CSIRO.ACCESS-ESM1-5.ssp126.r1i1p1f1.fx.areacella.gn.v20210318
+example/global-mean-timeseries/cmip6_ssp126_ACCESS-ESM1-5_tas_r1i1p1f1:
+  SourceDatasetType.CMIP6:
+  - CMIP6.ScenarioMIP.CSIRO.ACCESS-ESM1-5.ssp126.r1i1p1f1.Amon.tas.gn.v20210318
+  - CMIP6.ScenarioMIP.CSIRO.ACCESS-ESM1-5.ssp126.r1i1p1f1.fx.areacella.gn.v20210318
+example/global-sst-bias/cmip6_historical_ACCESS-ESM1-5_tos_r1i1p1f1__obs4mips_HadISST-1-1_ts:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Ofx.areacello.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Omon.tos.gn.v20191115
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+ilamb/gpp-fluxnet2015/cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Lmon.gpp.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.sftlf.gn.v20191115
+ilamb/mrsos-wangmao/cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Lmon.mrsos.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.sftlf.gn.v20191115
+pmp/enso_proc/cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_obs4MIPs:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.tauu.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.ts.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.sftlf.gn.v20191115
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.hfls.250km.gn.v20250415
+  - obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.hfss.250km.gn.v20250415
+  - obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.tauu.250km.gn.v20250415
+  - obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+  - obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rlds.100km.gn.v20230209
+  - obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rlus.100km.gn.v20230209
+  - obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rsds.100km.gn.v20230209
+  - obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rsus.100km.gn.v20230209
+pmp/enso_proc/cmip6_historical_gn_r2i1p1f1_ACCESS-ESM1-5__obs4mips_obs4MIPs:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.sftlf.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r2i1p1f1.Amon.ts.gn.v20191128
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r2i1p1f1.fx.areacella.gn.v20191128
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.hfls.250km.gn.v20250415
+  - obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.hfss.250km.gn.v20250415
+  - obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.tauu.250km.gn.v20250415
+  - obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+  - obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rlds.100km.gn.v20230209
+  - obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rlus.100km.gn.v20230209
+  - obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rsds.100km.gn.v20230209
+  - obs4REF.obs4REF.NASA-LaRC.CERES-EBAF-4-2.mon.rsus.100km.gn.v20230209
+pmp/enso_tel/cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_obs4MIPs:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.pr.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.ts.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.areacella.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.sftlf.gn.v20191115
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+  - obs4REF.obs4REF.NASA-GSFC.GPCP-Monthly-3-2.mon.pr.50km.gn.v20231205
+pmp/enso_tel/cmip6_historical_gn_r1i1p1f1_CESM2__obs4mips_obs4MIPs:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.Amon.pr.gn.v20190401
+  - CMIP6.CMIP.NCAR.CESM2.historical.r1i1p1f1.fx.areacella.gn.v20190308
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+  - obs4REF.obs4REF.NASA-GSFC.GPCP-Monthly-3-2.mon.pr.50km.gn.v20231205
+pmp/enso_tel/cmip6_historical_gn_r2i1p1f1_ACCESS-ESM1-5__obs4mips_obs4MIPs:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.fx.sftlf.gn.v20191115
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r2i1p1f1.Amon.ts.gn.v20191128
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r2i1p1f1.fx.areacella.gn.v20191128
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.ESSO.TropFlux-1-0.mon.ts.250km.gn.v20210727
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+  - obs4REF.obs4REF.NASA-GSFC.GPCP-Monthly-3-2.mon.pr.50km.gn.v20231205
+pmp/extratropical-modes-of-variability-npgo/cmip6_hist-GHG_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_HadISST-1-1_ts:
+  SourceDatasetType.CMIP6:
+  - CMIP6.DAMIP.CSIRO.ACCESS-ESM1-5.hist-GHG.r1i1p1f1.Amon.ts.gn.v20200615
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+pmp/extratropical-modes-of-variability-npgo/cmip6_hist-GHG_gn_r2i1p1f1_ACCESS-ESM1-5__obs4mips_HadISST-1-1_ts:
+  SourceDatasetType.CMIP6:
+  - CMIP6.DAMIP.CSIRO.ACCESS-ESM1-5.hist-GHG.r2i1p1f1.Amon.ts.gn.v20200615
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+pmp/extratropical-modes-of-variability-npgo/cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_HadISST-1-1_ts:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.ts.gn.v20191115
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+pmp/extratropical-modes-of-variability-npgo/cmip6_historical_gn_r2i1p1f1_ACCESS-ESM1-5__obs4mips_HadISST-1-1_ts:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r2i1p1f1.Amon.ts.gn.v20191128
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+pmp/extratropical-modes-of-variability-pdo/cmip6_hist-GHG_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_HadISST-1-1_ts:
+  SourceDatasetType.CMIP6:
+  - CMIP6.DAMIP.CSIRO.ACCESS-ESM1-5.hist-GHG.r1i1p1f1.Amon.ts.gn.v20200615
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+pmp/extratropical-modes-of-variability-pdo/cmip6_hist-GHG_gn_r2i1p1f1_ACCESS-ESM1-5__obs4mips_HadISST-1-1_ts:
+  SourceDatasetType.CMIP6:
+  - CMIP6.DAMIP.CSIRO.ACCESS-ESM1-5.hist-GHG.r2i1p1f1.Amon.ts.gn.v20200615
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+pmp/extratropical-modes-of-variability-pdo/cmip6_historical_gn_r1i1p1f1_ACCESS-ESM1-5__obs4mips_HadISST-1-1_ts:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r1i1p1f1.Amon.ts.gn.v20191115
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415
+pmp/extratropical-modes-of-variability-pdo/cmip6_historical_gn_r2i1p1f1_ACCESS-ESM1-5__obs4mips_HadISST-1-1_ts:
+  SourceDatasetType.CMIP6:
+  - CMIP6.CMIP.CSIRO.ACCESS-ESM1-5.historical.r2i1p1f1.Amon.ts.gn.v20191128
+  SourceDatasetType.obs4MIPs:
+  - obs4REF.obs4REF.MOHC.HadISST-1-1.mon.ts.250km.gn.v20250415

From 46cf3e9a00055b8539a77a99a2ee607fe44ec19a Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 11:08:40 +1000
Subject: [PATCH 31/64] chore: cap the parallel test run at four workers

`make test-quick` ran pytest with `-n auto`, which takes every core
and leaves the machine unusable while the suite runs.
It also produced three failures that all pass on a re-run, so the contention was costing
signal as well as the machine.

The count is a variable, so `make test-quick PYTEST_WORKERS=8` still works.
---
 Makefile | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/Makefile b/Makefile
index c25ad2363..b9904261e 100644
--- a/Makefile
+++ b/Makefile
@@ -5,6 +5,10 @@
 # compatible so we're not too worried
 TEMP_FILE := $(shell mktemp)
 
+# Workers used by the parallel test run.
+# `auto` takes every core, which leaves a developer machine unusable while the suite runs.
+PYTEST_WORKERS ?= 4
+
 # A helper script to get short descriptions of each target in the Makefile
 define PRINT_HELP_PYSCRIPT
 import re, sys
@@ -164,7 +168,7 @@ test-quick: clean  ## run all the tests at once
 	# A new resource_intensive test has to live under a path listed here.
 	uv run \
 		pytest tests packages \
-		-r a -v  --cov-report=term -n auto \
+		-r a -v  --cov-report=term -n $(PYTEST_WORKERS) \
 		-m "not resource_intensive"
 	uv run \
 		pytest packages/climate-ref-core/tests/integration packages/climate-ref/tests/integration \

From 65cd12139a0f8fd210b948415cc460a08f120d36 Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 11:11:02 +1000
Subject: [PATCH 32/64] fix: skip a baseline case whose manifest cannot be
 parsed

One manifest written by an incompatible schema version aborted the whole report with a traceback. It now logs a warning and skips that case, so the remaining cases still render.

Found by running the verb against a branch whose baselines predate the current manifest schema.
---
 .../src/climate_ref/baseline_report/collect.py     | 14 ++++++++++----
 .../tests/unit/baseline_report/test_collect.py     |  8 ++++++++
 2 files changed, 18 insertions(+), 4 deletions(-)

diff --git a/packages/climate-ref/src/climate_ref/baseline_report/collect.py b/packages/climate-ref/src/climate_ref/baseline_report/collect.py
index f594bd154..47be321da 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/collect.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/collect.py
@@ -12,6 +12,7 @@
 from typing import TYPE_CHECKING
 
 from attrs import frozen
+from loguru import logger
 
 from climate_ref_core.regression.manifest import Manifest, NativeEntry
 
@@ -302,12 +303,17 @@ def build_case_change(repo: Repo, base: str, rel_path: str) -> CaseChange | None
     Returns
     -------
     :
-        The case's changes, or ``None`` when the manifest is absent from both sides,
-        which leaves nothing to say.
+        The case's changes, or ``None`` when the manifest is absent from both sides
+        or cannot be parsed, both of which leave nothing to say.
     """
     head_path = Path(repo.working_tree_dir or ".") / rel_path
-    head = Manifest.load(head_path) if head_path.exists() else None
-    base_manifest = load_at_ref(repo, base, rel_path)
+    # One unreadable manifest should cost its own case, not the whole report.
+    try:
+        head = Manifest.load(head_path) if head_path.exists() else None
+        base_manifest = load_at_ref(repo, base, rel_path)
+    except ValueError as exc:
+        logger.warning(f"Skipping {rel_path}: {exc}")
+        return None
     if head is None and base_manifest is None:
         return None
 
diff --git a/packages/climate-ref/tests/unit/baseline_report/test_collect.py b/packages/climate-ref/tests/unit/baseline_report/test_collect.py
index c54e9d341..8a386d99a 100644
--- a/packages/climate-ref/tests/unit/baseline_report/test_collect.py
+++ b/packages/climate-ref/tests/unit/baseline_report/test_collect.py
@@ -160,3 +160,11 @@ def test_new_case_has_no_base(self, tmp_path):
 
     def test_no_changes_gives_no_cases(self, repo):
         assert collect(repo, "HEAD").cases == ()
+
+    def test_an_unreadable_manifest_is_skipped(self, repo, tmp_path):
+        # A manifest written by an incompatible version should cost its own case, not the report.
+        (tmp_path / MANIFEST_PATH).write_text('{"schema": 1}')
+        repo.git.add("-A")
+        repo.index.commit("break the manifest")
+
+        assert collect(repo, "HEAD~1").cases == ()

From e7f2c899d616b8e1bec34dbc5ae6f71a7fbc91ab Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 11:11:53 +1000
Subject: [PATCH 33/64] docs: add the changelog fragment for the baseline diff
 report

---
 changelog/910.feature.md | 4 ++++
 1 file changed, 4 insertions(+)
 create mode 100644 changelog/910.feature.md

diff --git a/changelog/910.feature.md b/changelog/910.feature.md
new file mode 100644
index 000000000..8326cdab7
--- /dev/null
+++ b/changelog/910.feature.md
@@ -0,0 +1,4 @@
+Added `ref test-cases diff`, which renders the regression baselines changed on a branch as a local HTML report.
+Images are shown old and new side by side, with a button that overlays them.
+Text outputs get a coloured diff, and NetCDF and other binaries are listed with a size delta and a link to each blob.
+Pass `--html-dir` to choose where the report is written, and `--no-fetch` to skip blob downloads and report sizes only.

From 5e94375845f9a3b03dd99db91f557195aea3254f Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 11:18:16 +1000
Subject: [PATCH 34/64] fix: cap PMP at two threads per execution

The scientific stack sizes its thread pools from the machine's core count.
Running several executions at once then oversubscribes the machine.
Two threads recover most of the available speedup, so the rest are wasted.
---
 .../src/climate_ref_pmp/__init__.py           | 18 ++++++++++++
 .../tests/unit/test_provider.py               | 28 ++++++++++++++++++-
 2 files changed, 45 insertions(+), 1 deletion(-)

diff --git a/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py b/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py
index 18543b265..e6ff3855b 100644
--- a/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py
+++ b/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py
@@ -30,6 +30,19 @@
 
 _REGISTRY_NAME = "pmp-climatology"
 
+_THREAD_LIMIT_VARS = (
+    "OMP_NUM_THREADS",
+    "OPENBLAS_NUM_THREADS",
+    "MKL_NUM_THREADS",
+    "NUMEXPR_NUM_THREADS",
+    "VECLIB_MAXIMUM_THREADS",
+)
+
+# Conservative default
+# Without a default BLAS will take as many CPUs as available
+# ~10% improvement over a single thread
+_DEFAULT_THREAD_LIMIT = "2"
+
 
 # Create the PMP diagnostics provider
 # PMP uses a conda environment to run the diagnostics
@@ -49,6 +62,11 @@ def configure(self, config: Config) -> None:
             logger.debug("Setting env variable 'FI_PROVIDER=tcp'")
             self.env_overrides["FI_PROVIDER"] = "tcp"
 
+        for name in _THREAD_LIMIT_VARS:
+            if name not in os.environ:
+                logger.debug(f"Setting env variable '{name}={_DEFAULT_THREAD_LIMIT}'")
+                self.env_overrides[name] = _DEFAULT_THREAD_LIMIT
+
     def fetch_data(self, config: Config) -> None:
         """Fetch PMP climatology data."""
         registry = dataset_registry_manager[_REGISTRY_NAME]
diff --git a/packages/climate-ref-pmp/tests/unit/test_provider.py b/packages/climate-ref-pmp/tests/unit/test_provider.py
index 2a67d4275..a13726b13 100644
--- a/packages/climate-ref-pmp/tests/unit/test_provider.py
+++ b/packages/climate-ref-pmp/tests/unit/test_provider.py
@@ -2,7 +2,13 @@
 from pathlib import Path
 
 import pooch
-from climate_ref_pmp import PMPDiagnosticProvider, __version__, provider
+from climate_ref_pmp import (
+    _DEFAULT_THREAD_LIMIT,
+    _THREAD_LIMIT_VARS,
+    PMPDiagnosticProvider,
+    __version__,
+    provider,
+)
 
 from climate_ref_core.data import LayeredResource, PackagedResource
 
@@ -110,6 +116,26 @@ def test_configure_sets_env_vars(self, mocker, tmp_path):
         )
         assert "FI_PROVIDER" in test_provider.env_overrides
         assert test_provider.env_overrides["FI_PROVIDER"] == "tcp"
+        for name in _THREAD_LIMIT_VARS:
+            assert test_provider.env_overrides[name] == _DEFAULT_THREAD_LIMIT
+
+    def test_configure_keeps_user_thread_limits(self, mocker, tmp_path, monkeypatch):
+        """A thread limit already in the environment is left alone."""
+        monkeypatch.setenv("OMP_NUM_THREADS", "4")
+        test_provider = PMPDiagnosticProvider("PMP-Test", "1.0")
+        mock_config = mocker.Mock()
+        mock_config.paths.software = tmp_path / "software"
+        mock_config.ignore_datasets_file = tmp_path / "ignore.yaml"
+        mock_config.ignore_datasets_file.touch()
+        mock_config.ignore_datasets_resource = LayeredResource(
+            packaged=PackagedResource("climate_ref", "default_ignore_datasets.yaml"),
+            override=mock_config.ignore_datasets_file,
+        )
+
+        test_provider.configure(mock_config)
+
+        assert "OMP_NUM_THREADS" not in test_provider.env_overrides
+        assert test_provider.env_overrides["MKL_NUM_THREADS"] == _DEFAULT_THREAD_LIMIT
 
     def test_ingest_data_skips_when_climate_ref_not_installed(self, mocker, caplog):
         """Test ingest_data gracefully skips when climate-ref package is not installed."""

From a3f456db8aa903f5e86f139057222eca85eca83e Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 11:18:37 +1000
Subject: [PATCH 35/64] chore: add changelog fragment

---
 changelog/911.fix.md | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 changelog/911.fix.md

diff --git a/changelog/911.fix.md b/changelog/911.fix.md
new file mode 100644
index 000000000..438eafda0
--- /dev/null
+++ b/changelog/911.fix.md
@@ -0,0 +1 @@
+Caps PMP at two threads per execution by default.

From 7e9b42eac3de4d84f9688e309ac7fc4485149665 Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 11:18:52 +1000
Subject: [PATCH 36/64] chore: expand the changelog fragment

---
 changelog/911.fix.md | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/changelog/911.fix.md b/changelog/911.fix.md
index 438eafda0..19c883be8 100644
--- a/changelog/911.fix.md
+++ b/changelog/911.fix.md
@@ -1 +1,3 @@
 Caps PMP at two threads per execution by default.
+The scientific stack sizes its thread pools from the machine's core count, so several concurrent executions oversubscribe the machine.
+Two threads recover most of the available speedup, and setting any of the thread limit variables in the environment keeps that value.

From 56e4a3bc119713721e213f41e6ce678e95725f57 Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 11:20:59 +1000
Subject: [PATCH 37/64] refactor: move the report's grouping and arithmetic out
 of the templates

Addresses the standards and spec review of the draft.

- `AnalysedCase` now carries the per-kind file lists and its own back link, so the case template only loops. The back link previously hardcoded three levels, which broke for a slug with fewer segments.
- `AnalysedFile` now carries the signed size delta, restoring the `(+652)` column the markdown script had.
- The text diff caption names both short digests, so the report says which blobs were compared.
- Drops the unused `num` filter and adds `signed`, which the size column needs.
- The overlay handler now resolves the pair through the enclosing figure and ignores non-element click targets.
---
 changelog/910.feature.md                      |  6 +-
 .../climate_ref/baseline_report/analyse.py    | 86 +++++++++++++------
 .../climate_ref/baseline_report/collect.py    |  7 +-
 .../src/climate_ref/baseline_report/render.py | 25 +++---
 .../baseline_report/templates/case.html.j2    | 24 +++---
 .../baseline_report/templates/macros.html.j2  |  6 +-
 .../baseline_report/templates/report.js       |  9 +-
 .../unit/baseline_report/test_analyse.py      | 60 ++++++++++++-
 .../tests/unit/baseline_report/test_render.py | 35 +++++++-
 9 files changed, 191 insertions(+), 67 deletions(-)

diff --git a/changelog/910.feature.md b/changelog/910.feature.md
index 8326cdab7..d27a88601 100644
--- a/changelog/910.feature.md
+++ b/changelog/910.feature.md
@@ -1,4 +1,4 @@
-Added `ref test-cases diff`, which renders the regression baselines changed on a branch as a local HTML report.
-Images are shown old and new side by side, with a button that overlays them.
+`ref test-cases diff` renders the regression baselines changed on a branch as a local HTML report.
+Images appear old and new side by side, with a button that overlays them.
 Text outputs get a coloured diff, and NetCDF and other binaries are listed with a size delta and a link to each blob.
-Pass `--html-dir` to choose where the report is written, and `--no-fetch` to skip blob downloads and report sizes only.
+`--html-dir` chooses where the report is written, and `--no-fetch` skips blob downloads and reports sizes only.
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
index 557181c3c..2a9ed577b 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
@@ -1,15 +1,14 @@
 """
 Turn a collected report into everything the templates need.
 
-Text blobs are fetched from the native store and diffed here. Every URL, count and diff line
-is computed in this module, so the templates only loop and place.
+Text blobs are fetched from the native store and diffed here.
 """
 
 from __future__ import annotations
 
 import difflib
 import json
-from pathlib import Path
+from pathlib import Path, PurePosixPath
 from typing import TYPE_CHECKING
 
 from attrs import frozen
@@ -69,6 +68,9 @@ class AnalysedFile:
     text: TextDiff | None
     """The diff, set only for :attr:`~climate_ref.baseline_report.collect.FileKind.TEXT` files."""
 
+    size_delta: int | None
+    """Signed byte change, or ``None`` when the file exists on only one side."""
+
 
 @frozen
 class AnalysedCase:
@@ -83,6 +85,18 @@ class AnalysedCase:
     counts: dict[str, dict[str, int]]
     """``kind -> {added, changed, removed}``, with every kind present."""
 
+    images: tuple[AnalysedFile, ...]
+    """The image files, which render as a two-up comparison."""
+
+    texts: tuple[AnalysedFile, ...]
+    """The text files, which render as a diff."""
+
+    binaries: tuple[AnalysedFile, ...]
+    """The NetCDF and other files, which render as a table row."""
+
+    back_link: str
+    """Relative link from this case's page back to the index, one ``..`` per slug segment."""
+
 
 @frozen
 class AnalysedReport:
@@ -276,34 +290,47 @@ def _analyse_file(
     :
         The analysed file.
     """
-    old_url = blob_url(store_url, change.old.sha256) if change.old else None
-    new_url = blob_url(store_url, change.new.sha256) if change.new else None
-    if change.kind is not FileKind.TEXT:
-        return AnalysedFile(change=change, old_url=old_url, new_url=new_url, text=None)
-    if not fetch:
+
+    def build(text: TextDiff | None) -> AnalysedFile:
+        """Build the file with the URLs and delta that do not depend on the diff."""
         return AnalysedFile(
             change=change,
-            old_url=old_url,
-            new_url=new_url,
-            text=TextDiff(lines=(), note="fetching disabled", elided=0),
+            old_url=blob_url(store_url, change.old.sha256) if change.old else None,
+            new_url=blob_url(store_url, change.new.sha256) if change.new else None,
+            text=text,
+            size_delta=change.new.size - change.old.size if change.old and change.new else None,
         )
 
+    if change.kind is not FileKind.TEXT:
+        return build(None)
+    if not fetch:
+        return build(TextDiff(lines=(), note="fetching disabled", elided=0))
+
     old_path, old_note = _fetch_side(store, change.old, workdir)
     new_path, new_note = _fetch_side(store, change.new, workdir)
     note = old_note or new_note
     if note is not None:
-        return AnalysedFile(
-            change=change,
-            old_url=old_url,
-            new_url=new_url,
-            text=TextDiff(lines=(), note=note, elided=0),
-        )
-    return AnalysedFile(
-        change=change,
-        old_url=old_url,
-        new_url=new_url,
-        text=text_diff(old_path, new_path, change.name),
-    )
+        return build(TextDiff(lines=(), note=note, elided=0))
+    return build(text_diff(old_path, new_path, change.name))
+
+
+def _of_kind(files: tuple[AnalysedFile, ...], *kinds: FileKind) -> tuple[AnalysedFile, ...]:
+    """
+    Select the files of the given kinds, keeping their order.
+
+    Parameters
+    ----------
+    files
+        The analysed files.
+    kinds
+        The kinds to keep.
+
+    Returns
+    -------
+    :
+        The matching files.
+    """
+    return tuple(file for file in files if file.change.kind in kinds)
 
 
 def _counts(files: tuple[AnalysedFile, ...]) -> dict[str, dict[str, int]]:
@@ -357,5 +384,16 @@ def analyse(report: Report, store: NativeStore, *, fetch: bool, workdir: Path) -
         files = tuple(
             _analyse_file(change, store, store_url, fetch=fetch, workdir=workdir) for change in case.files
         )
-        cases.append(AnalysedCase(change=case, files=files, counts=_counts(files)))
+        depth = len(PurePosixPath(case.slug).parts)
+        cases.append(
+            AnalysedCase(
+                change=case,
+                files=files,
+                counts=_counts(files),
+                images=_of_kind(files, FileKind.IMAGE),
+                texts=_of_kind(files, FileKind.TEXT),
+                binaries=_of_kind(files, FileKind.NETCDF, FileKind.OTHER),
+                back_link="/".join([*[".."] * depth, "index.html"]),
+            )
+        )
     return AnalysedReport(report=report, store_url=store_url, cases=tuple(cases))
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/collect.py b/packages/climate-ref/src/climate_ref/baseline_report/collect.py
index 47be321da..d5a6ea2bf 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/collect.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/collect.py
@@ -29,7 +29,7 @@
 """Extensions whose blobs are worth fetching and diffing line by line."""
 
 NETCDF_SUFFIXES = frozenset({".nc"})
-"""Extensions reported as a size delta until the NetCDF analysis slice lands."""
+"""Extensions reported as a size delta and a link rather than a content diff."""
 
 
 class FileKind(enum.Enum):
@@ -163,7 +163,7 @@ def changed_manifests(repo: Repo, base: str) -> list[str]:
     :
         The manifest paths, sorted.
     """
-    from git import GitCommandError  # noqa: PLC0415 - keeps the import cost off the CLI startup path
+    from git import GitCommandError  # noqa: PLC0415
 
     pathspec = ":(glob)packages/**/test-data/**/manifest.json"
     try:
@@ -193,7 +193,7 @@ def load_at_ref(repo: Repo, ref: str, rel_path: str) -> Manifest | None:
     :
         The parsed manifest, or ``None`` when the path does not exist at ``ref``.
     """
-    from git import GitCommandError  # noqa: PLC0415 - keeps the import cost off the CLI startup path
+    from git import GitCommandError  # noqa: PLC0415
 
     try:
         text = repo.git.show(f"{ref}:{rel_path}")
@@ -307,7 +307,6 @@ def build_case_change(repo: Repo, base: str, rel_path: str) -> CaseChange | None
         or cannot be parsed, both of which leave nothing to say.
     """
     head_path = Path(repo.working_tree_dir or ".") / rel_path
-    # One unreadable manifest should cost its own case, not the whole report.
     try:
         head = Manifest.load(head_path) if head_path.exists() else None
         base_manifest = load_at_ref(repo, base, rel_path)
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/render.py b/packages/climate-ref/src/climate_ref/baseline_report/render.py
index 43b102eee..731d9c9c0 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/render.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/render.py
@@ -1,8 +1,8 @@
 """
 Write the static HTML report.
 
-Python decides and templates place, so this module builds no HTML. It hands frozen objects to
-Jinja, registers the three formatting filters the templates are allowed, and writes the pages out.
+Hands frozen objects to Jinja, registers the formatting filters the templates are allowed,
+and writes the pages out.
 """
 
 from __future__ import annotations
@@ -38,26 +38,23 @@ def _format_bytes(size: object) -> str:
     return f"{size:,} B"
 
 
-def _format_num(value: object) -> str:
+def _format_signed(delta: object) -> str:
     """
-    Render a number to four significant figures.
-
-    ``g`` switches to scientific notation past 1e4 on its own, which is where a plain decimal
-    stops being readable.
+    Render a signed byte change.
 
     Parameters
     ----------
-    value
-        The number, or anything else, which is passed through as text.
+    delta
+        The change, or ``None`` when the file exists on only one side.
 
     Returns
     -------
     :
-        The formatted number.
+        For example ``+1,024``, or ``-`` when there is no change to show.
     """
-    if isinstance(value, bool) or not isinstance(value, int | float):
-        return str(value)
-    return f"{value:.4g}"
+    if not isinstance(delta, int) or isinstance(delta, bool):
+        return "-"
+    return f"{delta:+,}"
 
 
 def _format_short(digest: object) -> str:
@@ -95,7 +92,7 @@ def _build_env() -> Environment:
         lstrip_blocks=True,
     )
     env.filters["bytes"] = _format_bytes
-    env.filters["num"] = _format_num
+    env.filters["signed"] = _format_signed
     env.filters["short"] = _format_short
     return env
 
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2
index 0ca2b4241..cd5646a00 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2
+++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2
@@ -2,7 +2,7 @@
 {% import "macros.html.j2" as m %}
 {% block title %}{{ case.change.label }}{% endblock %}
 {% block content %}
-

Back to all cases

+

Back to all cases

{{ case.change.label }}

{% if case.change.metadata %} {% endif %} -{% set images = case.files | selectattr("change.kind.value", "equalto", "image") | list %} -{% set texts = case.files | selectattr("change.kind.value", "equalto", "text") | list %} -{% set binaries = case.files | rejectattr("change.kind.value", "in", ["image", "text"]) | list %} - -{% if images %} +{% if case.images %}
-Images ({{ images | length }}) -{% for file in images %} +Images +{% for file in case.images %} {{ m.image_pair(file) }} {% endfor %}
{% endif %} -{% if texts %} +{% if case.texts %}
-Text ({{ texts | length }}) -{% for file in texts %} +Text +{% for file in case.texts %} {{ m.text_block(file) }} {% endfor %}
{% endif %} -{% if binaries %} +{% if case.binaries %}
-NetCDF and other ({{ binaries | length }}) +NetCDF and other -{% for file in binaries %} +{% for file in case.binaries %} {{ m.binary_row(file) }} {% endfor %} diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 index 78979a1b6..47fd0159a 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 @@ -4,7 +4,7 @@ {% elif file.change.new is none %} was {{ file.change.old.size | bytes -}} {% else %} -{{- file.change.old.size | bytes }} -> {{ file.change.new.size | bytes -}} +{{- file.change.old.size | bytes }} -> {{ file.change.new.size | bytes }} ({{ file.size_delta | signed }}) {% endif %} {%- endmacro %} @@ -48,6 +48,10 @@ was {{ file.change.old.size | bytes -}} {{ file.change.name }}{{ file.change.status }}{{ size_delta(file) }} + +{% if file.change.old %}{{ file.change.old.sha256 | short }}{% endif %} +{%- if file.change.new %} -> {{ file.change.new.sha256 | short }}{% endif %} + {{ blob_links(file) }} {% if file.text.note %} diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.js b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.js index 0bb09e6a3..a78703aca 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.js +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.js @@ -1,9 +1,14 @@ document.addEventListener("click", function (event) { - var button = event.target.closest("[data-flip]"); + var target = event.target; + if (!(target instanceof Element)) { + return; + } + var button = target.closest("[data-flip]"); if (button === null) { return; } - var pair = button.parentElement.querySelector(".pair"); + var figure = button.closest("figure"); + var pair = figure === null ? null : figure.querySelector(".pair"); if (pair !== null) { pair.classList.toggle("flipped"); } diff --git a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py index 625ffdf90..a0b1a6f65 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock import pytest +from attrs import evolve from climate_ref.baseline_report.analyse import MAX_FETCH_BYTES, analyse, blob_url, text_diff from climate_ref.baseline_report.collect import ( @@ -87,7 +88,7 @@ def test_added_file_is_all_additions(self, tmp_path): class TestAnalyse: - def test_no_fetch_notes_every_text_file(self): + def test_no_fetch_notes_every_text_file(self, tmp_path): store = MagicMock(spec=NativeStore) store.url = "https://store" report = _report( @@ -97,14 +98,14 @@ def test_no_fetch_notes_every_text_file(self): ] ) - analysed = analyse(report, store, fetch=False, workdir=None) + analysed = analyse(report, store, fetch=False, workdir=tmp_path) files = {f.change.name: f for f in analysed.cases[0].files} assert files["series.json"].text.note == "fetching disabled" assert files["plot.png"].text is None store.fetch.assert_not_called() - def test_counts_are_tallied_per_kind(self): + def test_counts_are_tallied_per_kind(self, tmp_path): store = MagicMock(spec=NativeStore) store.url = "https://store" entry = NativeEntry(sha256="1" * 64, size=10) @@ -117,12 +118,63 @@ def test_counts_are_tallied_per_kind(self): ] ) - counts = analyse(report, store, fetch=False, workdir=None).cases[0].counts + counts = analyse(report, store, fetch=False, workdir=tmp_path).cases[0].counts assert counts[FileKind.IMAGE.value] == {"added": 1, "changed": 1, "removed": 0} assert counts[FileKind.NETCDF.value] == {"added": 0, "changed": 0, "removed": 1} assert counts[FileKind.TEXT.value] == {"added": 0, "changed": 0, "removed": 0} + def test_partitions_and_back_link(self, tmp_path): + store = MagicMock(spec=NativeStore) + store.url = "https://store" + entry = NativeEntry(sha256="1" * 64, size=10) + report = _report( + [ + _file_change("a.png", None, entry), + _file_change("b.json", None, entry), + _file_change("c.nc", None, entry), + _file_change("d.bin", None, entry), + ] + ) + + case = analyse(report, store, fetch=False, workdir=tmp_path).cases[0] + + assert [f.change.name for f in case.images] == ["a.png"] + assert [f.change.name for f in case.texts] == ["b.json"] + assert [f.change.name for f in case.binaries] == ["c.nc", "d.bin"] + # The slug has three segments, so the index sits three levels up. + assert case.back_link == "../../../index.html" + + def test_the_back_link_follows_the_slug_depth(self, tmp_path): + store = MagicMock(spec=NativeStore) + store.url = "https://store" + report = _report([]) + shallow = evolve(report.cases[0], label="pmp", slug="pmp") + report = evolve(report, cases=(shallow,)) + + case = analyse(report, store, fetch=False, workdir=tmp_path).cases[0] + + assert case.back_link == "../index.html" + + def test_size_delta_is_signed_and_absent_on_one_sided_files(self, tmp_path): + store = MagicMock(spec=NativeStore) + store.url = "https://store" + report = _report( + [ + _file_change( + "grew.png", NativeEntry(sha256="1" * 64, size=10), NativeEntry(sha256="2" * 64, size=25) + ), + _file_change("added.png", None, NativeEntry(sha256="3" * 64, size=25)), + ] + ) + + files = { + f.change.name: f for f in analyse(report, store, fetch=False, workdir=tmp_path).cases[0].files + } + + assert files["grew.png"].size_delta == 15 + assert files["added.png"].size_delta is None + def test_local_store_produces_a_real_diff(self, tmp_path): store = NativeStore(url=str(tmp_path / "store")) old_file = tmp_path / "old.csv" diff --git a/packages/climate-ref/tests/unit/baseline_report/test_render.py b/packages/climate-ref/tests/unit/baseline_report/test_render.py index c8e598845..be970c1b3 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_render.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_render.py @@ -64,6 +64,7 @@ def _analysed_file(name, kind, old, new, text=None) -> AnalysedFile: old_url=f"{STORE_URL}/{old.sha256}" if old else None, new_url=f"{STORE_URL}/{new.sha256}" if new else None, text=text, + size_delta=new.size - old.size if old and new else None, ) @@ -82,7 +83,15 @@ def _case(files, *, label="example/diag/case", base=None, head=None) -> Analysed committed=("series.json",), metadata=("test_case_version: 3 -> 4",), ) - return AnalysedCase(change=change, files=tuple(files), counts=counts) + return AnalysedCase( + change=change, + files=tuple(files), + counts=counts, + images=tuple(f for f in files if f.change.kind is FileKind.IMAGE), + texts=tuple(f for f in files if f.change.kind is FileKind.TEXT), + binaries=tuple(f for f in files if f.change.kind in (FileKind.NETCDF, FileKind.OTHER)), + back_link="/".join([*[".."] * len(label.split("/")), "index.html"]), + ) def _report(cases) -> AnalysedReport: @@ -204,6 +213,30 @@ def test_netcdf_renders_as_a_row(self): assert "was 10 B" in html assert not _tags(html, "img") + def test_the_back_link_matches_the_slug_depth(self): + case = _case([], label="pmp/diag/one") + report = _report([case]) + + assert "../../../index.html" in _hrefs(render_case(report, case)) + + def test_a_shallow_slug_gets_a_shallow_back_link(self): + case = _case([], label="pmp") + report = _report([case]) + + assert "../index.html" in _hrefs(render_case(report, case)) + + def test_a_changed_file_shows_its_signed_size_delta(self, changed_image_case): + assert "(+10)" in render_case(_report([changed_image_case]), changed_image_case) + + def test_a_text_diff_names_both_digests(self): + diff = TextDiff(lines=(DiffLine(kind="add", text="+a"),), note=None, elided=0) + case = _case([_analysed_file("series.csv", FileKind.TEXT, _entry("1"), _entry("2"), text=diff)]) + + html = render_case(_report([case]), case) + + assert "1" * 12 in html + assert "2" * 12 in html + def test_links_are_internal_index_pages_or_store_blobs(self, changed_image_case): report = _report([changed_image_case]) From 52ad5037a530303a534c80f891889e283f505889 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 11:21:33 +1000 Subject: [PATCH 38/64] fix: treat the thread budget as all or nothing Setting a single variable now suppresses the default for all five, so a deliberate OMP_NUM_THREADS is not half-overridden by a forced MKL limit. --- changelog/911.fix.md | 7 +-- .../src/climate_ref_pmp/__init__.py | 7 +-- .../tests/unit/test_provider.py | 45 +++++++++---------- 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/changelog/911.fix.md b/changelog/911.fix.md index 19c883be8..96e40ae50 100644 --- a/changelog/911.fix.md +++ b/changelog/911.fix.md @@ -1,3 +1,4 @@ -Caps PMP at two threads per execution by default. -The scientific stack sizes its thread pools from the machine's core count, so several concurrent executions oversubscribe the machine. -Two threads recover most of the available speedup, and setting any of the thread limit variables in the environment keeps that value. +PMP executions are now capped at two threads each. +Without a limit the scientific stack sized its thread pools from the machine's core count, +so a handful of concurrent executions oversubscribed the machine. +Setting any of the thread limit variables in the environment still keeps that value. diff --git a/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py b/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py index e6ff3855b..19caa8779 100644 --- a/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py +++ b/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py @@ -62,9 +62,10 @@ def configure(self, config: Config) -> None: logger.debug("Setting env variable 'FI_PROVIDER=tcp'") self.env_overrides["FI_PROVIDER"] = "tcp" - for name in _THREAD_LIMIT_VARS: - if name not in os.environ: - logger.debug(f"Setting env variable '{name}={_DEFAULT_THREAD_LIMIT}'") + # One of these being set means someone has chosen a thread budget, so leave all five alone. + if not any(name in os.environ for name in _THREAD_LIMIT_VARS): + logger.debug(f"Limiting threads to {_DEFAULT_THREAD_LIMIT}") + for name in _THREAD_LIMIT_VARS: self.env_overrides[name] = _DEFAULT_THREAD_LIMIT def fetch_data(self, config: Config) -> None: diff --git a/packages/climate-ref-pmp/tests/unit/test_provider.py b/packages/climate-ref-pmp/tests/unit/test_provider.py index a13726b13..f4475b59e 100644 --- a/packages/climate-ref-pmp/tests/unit/test_provider.py +++ b/packages/climate-ref-pmp/tests/unit/test_provider.py @@ -13,6 +13,21 @@ from climate_ref_core.data import LayeredResource, PackagedResource +def configured_provider(mocker, tmp_path): + """Build a provider and run `configure` against a mock config rooted at `tmp_path`.""" + test_provider = PMPDiagnosticProvider("PMP-Test", "1.0") + mock_config = mocker.Mock() + mock_config.paths.software = tmp_path / "software" + mock_config.ignore_datasets_file = tmp_path / "ignore.yaml" + mock_config.ignore_datasets_file.touch() + mock_config.ignore_datasets_resource = LayeredResource( + packaged=PackagedResource("climate_ref", "default_ignore_datasets.yaml"), + override=mock_config.ignore_datasets_file, + ) + test_provider.configure(mock_config) + return test_provider, mock_config + + def test_provider(): assert provider.name == "PMP" assert provider.slug == "pmp" @@ -97,17 +112,7 @@ def test_validate_setup_all_valid(self, mocker): def test_configure_sets_env_vars(self, mocker, tmp_path): """Test that configure sets the required environment variables.""" - test_provider = PMPDiagnosticProvider("PMP-Test", "1.0") - mock_config = mocker.Mock() - mock_config.paths.software = tmp_path / "software" - mock_config.ignore_datasets_file = tmp_path / "ignore.yaml" - mock_config.ignore_datasets_file.touch() - mock_config.ignore_datasets_resource = LayeredResource( - packaged=PackagedResource("climate_ref", "default_ignore_datasets.yaml"), - override=mock_config.ignore_datasets_file, - ) - - test_provider.configure(mock_config) + test_provider, mock_config = configured_provider(mocker, tmp_path) assert "PCMDI_CONDA_EXE" in test_provider.env_overrides # The path is recorded without installing anything, so `configure` stays offline. @@ -120,22 +125,12 @@ def test_configure_sets_env_vars(self, mocker, tmp_path): assert test_provider.env_overrides[name] == _DEFAULT_THREAD_LIMIT def test_configure_keeps_user_thread_limits(self, mocker, tmp_path, monkeypatch): - """A thread limit already in the environment is left alone.""" + """One thread limit in the environment suppresses the default for all of them.""" monkeypatch.setenv("OMP_NUM_THREADS", "4") - test_provider = PMPDiagnosticProvider("PMP-Test", "1.0") - mock_config = mocker.Mock() - mock_config.paths.software = tmp_path / "software" - mock_config.ignore_datasets_file = tmp_path / "ignore.yaml" - mock_config.ignore_datasets_file.touch() - mock_config.ignore_datasets_resource = LayeredResource( - packaged=PackagedResource("climate_ref", "default_ignore_datasets.yaml"), - override=mock_config.ignore_datasets_file, - ) + test_provider, _ = configured_provider(mocker, tmp_path) - test_provider.configure(mock_config) - - assert "OMP_NUM_THREADS" not in test_provider.env_overrides - assert test_provider.env_overrides["MKL_NUM_THREADS"] == _DEFAULT_THREAD_LIMIT + for name in _THREAD_LIMIT_VARS: + assert name not in test_provider.env_overrides def test_ingest_data_skips_when_climate_ref_not_installed(self, mocker, caplog): """Test ingest_data gracefully skips when climate-ref package is not installed.""" From 61d1a5c9461c13743fedddb31ab7ab2229a28567 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 11:22:55 +1000 Subject: [PATCH 39/64] test: pin the thread limit tests to a known environment --- changelog/911.fix.md | 2 +- packages/climate-ref-pmp/tests/unit/test_provider.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/changelog/911.fix.md b/changelog/911.fix.md index 96e40ae50..b705c4e13 100644 --- a/changelog/911.fix.md +++ b/changelog/911.fix.md @@ -1,4 +1,4 @@ PMP executions are now capped at two threads each. Without a limit the scientific stack sized its thread pools from the machine's core count, so a handful of concurrent executions oversubscribed the machine. -Setting any of the thread limit variables in the environment still keeps that value. +Any of the thread limit variables already set in the environment is left alone. diff --git a/packages/climate-ref-pmp/tests/unit/test_provider.py b/packages/climate-ref-pmp/tests/unit/test_provider.py index f4475b59e..90e170da3 100644 --- a/packages/climate-ref-pmp/tests/unit/test_provider.py +++ b/packages/climate-ref-pmp/tests/unit/test_provider.py @@ -110,8 +110,10 @@ def test_validate_setup_all_valid(self, mocker): result = provider.validate_setup(mock_config) assert result is True - def test_configure_sets_env_vars(self, mocker, tmp_path): + def test_configure_sets_env_vars(self, mocker, tmp_path, monkeypatch): """Test that configure sets the required environment variables.""" + for name in _THREAD_LIMIT_VARS: + monkeypatch.delenv(name, raising=False) test_provider, mock_config = configured_provider(mocker, tmp_path) assert "PCMDI_CONDA_EXE" in test_provider.env_overrides From 2230705d58c0b38ca08e2b698ee32c3d8752e1cc Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 11:25:58 +1000 Subject: [PATCH 40/64] fix: keep tolerating a primary catalog that carries no instance_id Reworking the fold into `union_with_fallbacks` moved the read of the primary's `instance_id` ahead of the check that any fallback is usable, so a primary holding rows without that column raised rather than being returned untouched. The fallbacks are now screened first, which restores the original early exit. Also collapses the two `catalog_for_requirement` branches that differed only in whether the primary catalog was present. --- .../climate-ref/src/climate_ref/solver.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py index 22aa18731..df4e931b1 100644 --- a/packages/climate-ref/src/climate_ref/solver.py +++ b/packages/climate-ref/src/climate_ref/solver.py @@ -300,14 +300,19 @@ def union_with_fallbacks( stand in for. Their ``instance_id`` still names the collection they came from, so the provenance is not lost. """ + usable = [ + frame + for frame in (as_frame(fallback) for fallback in fallbacks) + if not frame.empty and "instance_id" in frame.columns + ] + if not usable: + return primary + primary_df = as_frame(primary) held = set(obs_dataset_key(primary_df["instance_id"])) if len(primary_df) else set() additions = [] - for fallback in fallbacks: - fallback_df = as_frame(fallback) - if fallback_df.empty or "instance_id" not in fallback_df.columns: - continue + for fallback_df in usable: extra = fallback_df[~obs_dataset_key(fallback_df["instance_id"]).isin(held)] if extra.empty: continue @@ -468,12 +473,10 @@ def catalog_for_requirement( for source_type in requirement.fallback_source_types if source_type in data_catalog ] - if requirement.source_type not in data_catalog: - if not fallbacks: - return None - return union_with_fallbacks(pd.DataFrame(), fallbacks, requirement.source_type) + if requirement.source_type not in data_catalog and not fallbacks: + return None - primary = data_catalog[requirement.source_type] + primary = data_catalog.get(requirement.source_type, pd.DataFrame()) if not fallbacks: return primary return union_with_fallbacks(primary, fallbacks, requirement.source_type) From 8234c0a1fb1d495db194942d8c901c784f83e3bf Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 11:30:36 +1000 Subject: [PATCH 41/64] chore: tweak text --- changelog/911.fix.md | 4 +--- packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py | 5 ++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/changelog/911.fix.md b/changelog/911.fix.md index b705c4e13..c318c36bd 100644 --- a/changelog/911.fix.md +++ b/changelog/911.fix.md @@ -1,4 +1,2 @@ PMP executions are now capped at two threads each. -Without a limit the scientific stack sized its thread pools from the machine's core count, -so a handful of concurrent executions oversubscribed the machine. -Any of the thread limit variables already set in the environment is left alone. +Without a limit the scientific stack sized its thread pools from the machine's core count. diff --git a/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py b/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py index 19caa8779..158a36169 100644 --- a/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py +++ b/packages/climate-ref-pmp/src/climate_ref_pmp/__init__.py @@ -38,9 +38,8 @@ "VECLIB_MAXIMUM_THREADS", ) -# Conservative default +# Conservative default for ~10% improvement over a single thread # Without a default BLAS will take as many CPUs as available -# ~10% improvement over a single thread _DEFAULT_THREAD_LIMIT = "2" @@ -62,7 +61,7 @@ def configure(self, config: Config) -> None: logger.debug("Setting env variable 'FI_PROVIDER=tcp'") self.env_overrides["FI_PROVIDER"] = "tcp" - # One of these being set means someone has chosen a thread budget, so leave all five alone. + # If any budgets are set, don't set default values if not any(name in os.environ for name in _THREAD_LIMIT_VARS): logger.debug(f"Limiting threads to {_DEFAULT_THREAD_LIMIT}") for name in _THREAD_LIMIT_VARS: From 8b2b54aed51698edf73c03c4bf670c8103542165 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 11:41:36 +1000 Subject: [PATCH 42/64] docs: describe the fallback field where the API surface is listed `union_with_fallbacks` kept an obs4MIPs-only opening in its docstring after it was generalised, so it read as if the primary had to be obs4MIPs. Also names `DataRequirement.fallback_source_types` in the extension points, because it is provider-facing and the field is otherwise only discoverable from the class. --- docs/api-surface.md | 1 + packages/climate-ref/src/climate_ref/solver.py | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api-surface.md b/docs/api-surface.md index 53d1d86da..90fcafe66 100644 --- a/docs/api-surface.md +++ b/docs/api-surface.md @@ -34,6 +34,7 @@ The primary module providers interact with. - Override `Diagnostic.build_execution_result(definition) -> ExecutionResult` - Set `Diagnostic.data_requirements`, `facets`, `slug`, `name` - Set `Diagnostic.test_data_spec` for test case support +- Set `DataRequirement.fallback_source_types` to name the collections that may supply a requirement --- diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py index df4e931b1..3ca6809a7 100644 --- a/packages/climate-ref/src/climate_ref/solver.py +++ b/packages/climate-ref/src/climate_ref/solver.py @@ -273,10 +273,9 @@ def union_with_fallbacks( """ Fill a reference catalog with the datasets its fallback collections hold and it lacks. - The obs4MIPs archive on ESGF is the official home of the reference data, - and the obs4REF registry carries the datasets that are not published there yet. - A dataset present in both is taken from the primary, whichever version each holds, - so publishing a dataset takes over from the registry copy without any re-ingest. + A dataset the primary holds is taken from the primary, whichever version each collection holds. + So for obs4MIPs standing in front of obs4REF, publishing a dataset to the archive + takes over from the registry copy without any re-ingest. Parameters ---------- From d650bfbe1af8694008b50e2c67161594caa2d080 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 11:44:57 +1000 Subject: [PATCH 43/64] refactor: tighten the baseline report after the cleanup reviews - Replaces the stringly `counts` dict with an ordered tuple of `KindCounts`, so the index template stops declaring the kind list and its column headers by hand. - Drops `CaseChange.slug`, which was always equal to `label`. - Caps the diff with `islice` instead of building the whole unified diff and discarding most of it. - Flattens the closure in `_analyse_file` into a module-level `_diff_for`. - States the short-digest length once and shares it with the renderer. - Types the Jinja filters as the values they actually receive, dropping the bool guards. - The render tests now build their fixtures through `analyse` rather than recomputing its counts, partitions and back link by hand. --- changelog/910.feature.md | 1 - .../climate_ref/baseline_report/analyse.py | 151 ++++++++---- .../climate_ref/baseline_report/collect.py | 7 +- .../src/climate_ref/baseline_report/render.py | 23 +- .../baseline_report/templates/index.html.j2 | 17 +- .../src/climate_ref/cli/test_cases/diff.py | 11 +- .../unit/baseline_report/test_analyse.py | 20 +- .../unit/baseline_report/test_collect.py | 1 - .../tests/unit/baseline_report/test_render.py | 224 ++++++++++-------- 9 files changed, 271 insertions(+), 184 deletions(-) diff --git a/changelog/910.feature.md b/changelog/910.feature.md index d27a88601..4b82e7f45 100644 --- a/changelog/910.feature.md +++ b/changelog/910.feature.md @@ -1,4 +1,3 @@ `ref test-cases diff` renders the regression baselines changed on a branch as a local HTML report. Images appear old and new side by side, with a button that overlays them. Text outputs get a coloured diff, and NetCDF and other binaries are listed with a size delta and a link to each blob. -`--html-dir` chooses where the report is written, and `--no-fetch` skips blob downloads and reports sizes only. diff --git a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py index 2a9ed577b..2193a2ad1 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py +++ b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py @@ -8,6 +8,8 @@ import difflib import json +from collections import Counter +from itertools import islice from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING @@ -23,6 +25,9 @@ # from stalling the job on the download alone. MAX_FETCH_BYTES = 2_000_000 +# Digest prefix shown wherever a blob is named. Long enough to identify it, short enough to read. +SHORT_DIGEST = 12 + # Unified-diff lines kept per file before the rest is elided. MAX_DIFF_LINES = 5000 @@ -72,6 +77,23 @@ class AnalysedFile: """Signed byte change, or ``None`` when the file exists on only one side.""" +@frozen +class KindCounts: + """How many files of one kind were added, changed and removed.""" + + label: str + """The kind's name, as the report column header.""" + + added: int + """Files present only on HEAD.""" + + changed: int + """Files present on both sides with a different digest.""" + + removed: int + """Files present only on the base ref.""" + + @frozen class AnalysedCase: """One test case, with its files analysed and tallied.""" @@ -82,8 +104,8 @@ class AnalysedCase: files: tuple[AnalysedFile, ...] """Every analysed file, in the order collection produced them.""" - counts: dict[str, dict[str, int]] - """``kind -> {added, changed, removed}``, with every kind present.""" + counts: tuple[KindCounts, ...] + """One entry per kind, in report column order.""" images: tuple[AnalysedFile, ...] """The image files, which render as a two-up comparison.""" @@ -111,6 +133,9 @@ class AnalysedReport: cases: tuple[AnalysedCase, ...] """The analysed cases, in the order collection produced them.""" + kinds: tuple[str, ...] + """The count column headers, matching the order of every case's ``counts``.""" + def blob_url(store_url: str, digest: str) -> str: """ @@ -204,24 +229,21 @@ def text_diff(old: Path | None, new: Path | None, name: str) -> TextDiff: : The diff, or a note explaining why there is not one. """ - raw = list( - difflib.unified_diff( - _as_lines(old, name), - _as_lines(new, name), - fromfile="old" if old is not None else "(absent)", - tofile="new" if new is not None else "(absent)", - lineterm="", - n=3, - ) + raw = difflib.unified_diff( + _as_lines(old, name), + _as_lines(new, name), + fromfile="old" if old is not None else "(absent)", + tofile="new" if new is not None else "(absent)", + lineterm="", + n=3, ) - if not raw: + kept = list(islice(raw, MAX_DIFF_LINES)) + if not kept: return TextDiff(lines=(), note="identical after decoding", elided=0) - elided = max(len(raw) - MAX_DIFF_LINES, 0) - kept = raw[:MAX_DIFF_LINES] return TextDiff( lines=tuple(DiffLine(kind=_classify_line(line), text=line) for line in kept), note=None, - elided=elided, + elided=sum(1 for _ in raw), ) @@ -257,20 +279,19 @@ def _fetch_side( try: store.fetch(digest, dest) except (OSError, ValueError) as exc: - return None, f"could not fetch {digest[:12]} ({exc})" + return None, f"could not fetch {digest[:SHORT_DIGEST]} ({exc})" return dest, None -def _analyse_file( +def _diff_for( change: FileChange, store: NativeStore, - store_url: str, *, fetch: bool, workdir: Path, -) -> AnalysedFile: +) -> TextDiff | None: """ - Build the URLs and, for text, the diff of one native file. + Build the diff for one file, or ``None`` when its kind is not diffed. Parameters ---------- @@ -278,8 +299,6 @@ def _analyse_file( The file that moved. store The store to read blobs from. - store_url - Base URL of the store, used to build links. fetch Whether blobs may be downloaded. workdir @@ -288,30 +307,57 @@ def _analyse_file( Returns ------- : - The analysed file. + The diff, a note explaining why there is not one, or ``None`` for a non-text file. """ - - def build(text: TextDiff | None) -> AnalysedFile: - """Build the file with the URLs and delta that do not depend on the diff.""" - return AnalysedFile( - change=change, - old_url=blob_url(store_url, change.old.sha256) if change.old else None, - new_url=blob_url(store_url, change.new.sha256) if change.new else None, - text=text, - size_delta=change.new.size - change.old.size if change.old and change.new else None, - ) - if change.kind is not FileKind.TEXT: - return build(None) + return None if not fetch: - return build(TextDiff(lines=(), note="fetching disabled", elided=0)) + return TextDiff(lines=(), note="fetching disabled", elided=0) old_path, old_note = _fetch_side(store, change.old, workdir) new_path, new_note = _fetch_side(store, change.new, workdir) note = old_note or new_note if note is not None: - return build(TextDiff(lines=(), note=note, elided=0)) - return build(text_diff(old_path, new_path, change.name)) + return TextDiff(lines=(), note=note, elided=0) + return text_diff(old_path, new_path, change.name) + + +def _analyse_file( + change: FileChange, + store: NativeStore, + store_url: str, + *, + fetch: bool, + workdir: Path, +) -> AnalysedFile: + """ + Build the URLs and, for text, the diff of one native file. + + Parameters + ---------- + change + The file that moved. + store + The store to read blobs from. + store_url + Base URL of the store, used to build links. + fetch + Whether blobs may be downloaded. + workdir + Directory fetched blobs are written into. + + Returns + ------- + : + The analysed file. + """ + return AnalysedFile( + change=change, + old_url=blob_url(store_url, change.old.sha256) if change.old else None, + new_url=blob_url(store_url, change.new.sha256) if change.new else None, + text=_diff_for(change, store, fetch=fetch, workdir=workdir), + size_delta=change.new.size - change.old.size if change.old and change.new else None, + ) def _of_kind(files: tuple[AnalysedFile, ...], *kinds: FileKind) -> tuple[AnalysedFile, ...]: @@ -333,7 +379,7 @@ def _of_kind(files: tuple[AnalysedFile, ...], *kinds: FileKind) -> tuple[Analyse return tuple(file for file in files if file.change.kind in kinds) -def _counts(files: tuple[AnalysedFile, ...]) -> dict[str, dict[str, int]]: +def _counts(files: tuple[AnalysedFile, ...]) -> tuple[KindCounts, ...]: """ Tally each file kind's added, changed and removed counts. @@ -345,13 +391,21 @@ def _counts(files: tuple[AnalysedFile, ...]) -> dict[str, dict[str, int]]: Returns ------- : - ``kind -> {added, changed, removed}``, with every kind present so a template - never has to test for a missing key. + One entry per kind, in report column order, so a template never has to test + for a missing kind or decide the column order itself. """ - tally = {kind.value: {"added": 0, "changed": 0, "removed": 0} for kind in FileKind} + tally: dict[FileKind, Counter[str]] = {kind: Counter() for kind in FileKind} for analysed in files: - tally[analysed.change.kind.value][analysed.change.status] += 1 - return tally + tally[analysed.change.kind][analysed.change.status] += 1 + return tuple( + KindCounts( + label=kind.value, + added=tally[kind]["added"], + changed=tally[kind]["changed"], + removed=tally[kind]["removed"], + ) + for kind in FileKind + ) def analyse(report: Report, store: NativeStore, *, fetch: bool, workdir: Path) -> AnalysedReport: @@ -384,7 +438,7 @@ def analyse(report: Report, store: NativeStore, *, fetch: bool, workdir: Path) - files = tuple( _analyse_file(change, store, store_url, fetch=fetch, workdir=workdir) for change in case.files ) - depth = len(PurePosixPath(case.slug).parts) + depth = len(PurePosixPath(case.label).parts) cases.append( AnalysedCase( change=case, @@ -396,4 +450,9 @@ def analyse(report: Report, store: NativeStore, *, fetch: bool, workdir: Path) - back_link="/".join([*[".."] * depth, "index.html"]), ) ) - return AnalysedReport(report=report, store_url=store_url, cases=tuple(cases)) + return AnalysedReport( + report=report, + store_url=store_url, + cases=tuple(cases), + kinds=tuple(kind.value for kind in FileKind), + ) diff --git a/packages/climate-ref/src/climate_ref/baseline_report/collect.py b/packages/climate-ref/src/climate_ref/baseline_report/collect.py index d5a6ea2bf..820310e2f 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/collect.py +++ b/packages/climate-ref/src/climate_ref/baseline_report/collect.py @@ -98,9 +98,6 @@ class CaseChange: label: str """``provider/diagnostic/test-case``.""" - slug: str - """The output subdirectory for this case's page. Equal to :attr:`label`.""" - rel_path: str """Repo-relative path of the case's ``manifest.json``.""" @@ -325,10 +322,8 @@ def build_case_change(repo: Repo, base: str, rel_path: str) -> CaseChange | None continue files.append(FileChange(name=name, old=old, new=new, kind=classify(name))) - label = case_label(rel_path) return CaseChange( - label=label, - slug=label, + label=case_label(rel_path), rel_path=rel_path, base=base_manifest, head=head, diff --git a/packages/climate-ref/src/climate_ref/baseline_report/render.py b/packages/climate-ref/src/climate_ref/baseline_report/render.py index 731d9c9c0..39717951c 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/render.py +++ b/packages/climate-ref/src/climate_ref/baseline_report/render.py @@ -12,14 +12,13 @@ from jinja2 import Environment, PackageLoader, select_autoescape +from climate_ref.baseline_report.analyse import SHORT_DIGEST + if TYPE_CHECKING: from climate_ref.baseline_report.analyse import AnalysedCase, AnalysedReport -# Digest prefix shown in the report. Long enough to identify a blob, short enough to read. -_SHORT_DIGEST = 12 - -def _format_bytes(size: object) -> str: +def _format_bytes(size: int | None) -> str: """ Render a byte count with thousands separators. @@ -33,12 +32,12 @@ def _format_bytes(size: object) -> str: : For example ``101,204 B``, or ``-`` when there is no count. """ - if not isinstance(size, int) or isinstance(size, bool): + if size is None: return "-" return f"{size:,} B" -def _format_signed(delta: object) -> str: +def _format_signed(delta: int | None) -> str: """ Render a signed byte change. @@ -52,12 +51,12 @@ def _format_signed(delta: object) -> str: : For example ``+1,024``, or ``-`` when there is no change to show. """ - if not isinstance(delta, int) or isinstance(delta, bool): + if delta is None: return "-" return f"{delta:+,}" -def _format_short(digest: object) -> str: +def _format_short(digest: str | None) -> str: """ Render the readable prefix of a digest. @@ -71,9 +70,9 @@ def _format_short(digest: object) -> str: : The first twelve hex characters, or ``-`` when there is no digest. """ - if not isinstance(digest, str): + if digest is None: return "-" - return digest[:_SHORT_DIGEST] + return digest[:SHORT_DIGEST] def _build_env() -> Environment: @@ -138,7 +137,7 @@ def render_case(report: AnalysedReport, case: AnalysedCase) -> str: def write_site(report: AnalysedReport, out_dir: Path) -> Path: """ - Write the overview ``index.html`` plus one ``index.html`` per case, under the case slug. + Write the overview ``index.html`` plus one ``index.html`` per case, under the case label. Parameters ---------- @@ -156,7 +155,7 @@ def write_site(report: AnalysedReport, out_dir: Path) -> Path: index = out_dir / "index.html" index.write_text(render_index(report), encoding="utf-8") for case in report.cases: - page = out_dir / case.change.slug / "index.html" + page = out_dir / case.change.label / "index.html" page.parent.mkdir(parents=True, exist_ok=True) page.write_text(render_case(report, case), encoding="utf-8") return index diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/index.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/index.html.j2 index 341a69440..167311d76 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/templates/index.html.j2 +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/index.html.j2 @@ -6,16 +6,15 @@ - - - - +{% for kind in report.kinds %} + +{% endfor %} {% for case in report.cases %} - + -{% for kind in ("image", "text", "netcdf", "other") %} +{% for count in case.counts %} {% endfor %} diff --git a/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py index 41587b477..9063513fb 100644 --- a/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py +++ b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py @@ -1,8 +1,8 @@ """ ``ref test-cases diff``. -Renders an HTML report of every regression baseline that moved on this branch, with images -shown old and new side by side and text outputs diffed inline. +Renders an HTML report of every regression baseline that moved on this branch. +Images are shown old and new side by side and text outputs diffed inline. """ from __future__ import annotations @@ -39,9 +39,10 @@ def diff_baselines( """ Render an HTML report of the regression baselines changed on this branch. - Compares every committed ``manifest.json`` to its counterpart on ``--base`` and writes one page - per changed test case, with images shown old and new side by side. - Exits 0 whether or not anything changed. This reports, it does not gate. + Compares every committed ``manifest.json`` to its counterpart on ``--base`` + and writes one page per changed test case. + + Exits 0 whether or not anything changed. Examples -------- diff --git a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py index a0b1a6f65..beb040deb 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py @@ -32,7 +32,6 @@ def _report(files): """Wrap file changes in a single-case report.""" case = CaseChange( label="example/diag/case", - slug="example/diag/case", rel_path="packages/climate-ref-example/tests/test-data/diag/case/manifest.json", base=None, head=None, @@ -118,11 +117,16 @@ def test_counts_are_tallied_per_kind(self, tmp_path): ] ) - counts = analyse(report, store, fetch=False, workdir=tmp_path).cases[0].counts + rows = analyse(report, store, fetch=False, workdir=tmp_path).cases[0].counts + counts = {row.label: row for row in rows} - assert counts[FileKind.IMAGE.value] == {"added": 1, "changed": 1, "removed": 0} - assert counts[FileKind.NETCDF.value] == {"added": 0, "changed": 0, "removed": 1} - assert counts[FileKind.TEXT.value] == {"added": 0, "changed": 0, "removed": 0} + assert (counts["image"].added, counts["image"].changed, counts["image"].removed) == (1, 1, 0) + assert (counts["netcdf"].added, counts["netcdf"].changed, counts["netcdf"].removed) == (0, 0, 1) + assert (counts["text"].added, counts["text"].changed, counts["text"].removed) == (0, 0, 0) + # Every kind gets a column, in the enum's order, so the index header cannot drift. + assert [ + row.label for row in analyse(report, store, fetch=False, workdir=tmp_path).cases[0].counts + ] == [kind.value for kind in FileKind] def test_partitions_and_back_link(self, tmp_path): store = MagicMock(spec=NativeStore) @@ -142,14 +146,14 @@ def test_partitions_and_back_link(self, tmp_path): assert [f.change.name for f in case.images] == ["a.png"] assert [f.change.name for f in case.texts] == ["b.json"] assert [f.change.name for f in case.binaries] == ["c.nc", "d.bin"] - # The slug has three segments, so the index sits three levels up. + # The label has three segments, so the index sits three levels up. assert case.back_link == "../../../index.html" - def test_the_back_link_follows_the_slug_depth(self, tmp_path): + def test_the_back_link_follows_the_label_depth(self, tmp_path): store = MagicMock(spec=NativeStore) store.url = "https://store" report = _report([]) - shallow = evolve(report.cases[0], label="pmp", slug="pmp") + shallow = evolve(report.cases[0], label="pmp") report = evolve(report, cases=(shallow,)) case = analyse(report, store, fetch=False, workdir=tmp_path).cases[0] diff --git a/packages/climate-ref/tests/unit/baseline_report/test_collect.py b/packages/climate-ref/tests/unit/baseline_report/test_collect.py index 8a386d99a..a2b1c84cd 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_collect.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_collect.py @@ -116,7 +116,6 @@ def test_pairs_native_entries(self, repo): case = report.cases[0] assert case.label == "example/global-mean-timeseries/default" - assert case.slug == case.label assert not case.is_new assert not case.is_removed diff --git a/packages/climate-ref/tests/unit/baseline_report/test_render.py b/packages/climate-ref/tests/unit/baseline_report/test_render.py index be970c1b3..c99dbc99e 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_render.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_render.py @@ -1,13 +1,16 @@ """Tests for the static HTML the report is written as.""" from html.parser import HTMLParser +from unittest.mock import MagicMock import pytest +from attrs import evolve -from climate_ref.baseline_report.analyse import AnalysedCase, AnalysedFile, AnalysedReport, DiffLine, TextDiff -from climate_ref.baseline_report.collect import CaseChange, FileChange, FileKind, Report +from climate_ref.baseline_report.analyse import AnalysedReport, DiffLine, TextDiff, analyse +from climate_ref.baseline_report.collect import CaseChange, FileChange, FileKind, Report, classify from climate_ref.baseline_report.render import render_case, render_index, write_site from climate_ref_core.regression.manifest import SCHEMA_VERSION, Manifest, NativeEntry +from climate_ref_core.regression.store import NativeStore STORE_URL = "https://store.example" @@ -56,70 +59,76 @@ def _entry(char: str, size: int = 10) -> NativeEntry: return NativeEntry(sha256=char * 64, size=size) -def _analysed_file(name, kind, old, new, text=None) -> AnalysedFile: - """Build one analysed file with URLs derived from its entries.""" - change = FileChange(name=name, old=old, new=new, kind=kind) - return AnalysedFile( - change=change, - old_url=f"{STORE_URL}/{old.sha256}" if old else None, - new_url=f"{STORE_URL}/{new.sha256}" if new else None, - text=text, - size_delta=new.size - old.size if old and new else None, - ) +def _change(name: str, old: NativeEntry | None, new: NativeEntry | None) -> FileChange: + """Build one file change with the kind its name implies.""" + return FileChange(name=name, old=old, new=new, kind=classify(name)) -def _case(files, *, label="example/diag/case", base=None, head=None) -> AnalysedCase: - """Build one analysed case with tallied counts.""" - counts = {kind.value: {"added": 0, "changed": 0, "removed": 0} for kind in FileKind} - for file in files: - counts[file.change.kind.value][file.change.status] += 1 - change = CaseChange( +def _case_change(changes, label="example/diag/case", base=None, head=None) -> CaseChange: + """Build one collected case.""" + return CaseChange( label=label, - slug=label, rel_path=f"packages/climate-ref-{label.split('/')[0]}/tests/test-data/manifest.json", base=base, head=head, - files=tuple(file.change for file in files), + files=tuple(changes), committed=("series.json",), metadata=("test_case_version: 3 -> 4",), ) - return AnalysedCase( - change=change, - files=tuple(files), - counts=counts, - images=tuple(f for f in files if f.change.kind is FileKind.IMAGE), - texts=tuple(f for f in files if f.change.kind is FileKind.TEXT), - binaries=tuple(f for f in files if f.change.kind in (FileKind.NETCDF, FileKind.OTHER)), - back_link="/".join([*[".."] * len(label.split("/")), "index.html"]), - ) -def _report(cases) -> AnalysedReport: - """Wrap analysed cases in a report.""" - return AnalysedReport( - report=Report(base_ref="origin/main", head_sha="a" * 40, cases=tuple(c.change for c in cases)), - store_url=STORE_URL, - cases=tuple(cases), +def _analysed(cases, tmp_path, diffs=None) -> AnalysedReport: + """ + Analyse collected cases with a store that is never read. + + Running the real :func:`analyse` keeps these tests pinned to the counts, partitions and + back links the pages are actually built from. ``diffs`` replaces the placeholder note on a + named file, which is how a specific diff shape is put in front of the templates. + """ + store = MagicMock(spec=NativeStore) + store.url = STORE_URL + report = analyse( + Report(base_ref="origin/main", head_sha="a" * 40, cases=tuple(cases)), + store, + fetch=False, + workdir=tmp_path, + ) + if not diffs: + return report + return evolve( + report, + cases=tuple( + evolve( + case, + files=tuple(evolve(file, text=diffs.get(file.change.name, file.text)) for file in case.files), + texts=tuple(evolve(file, text=diffs.get(file.change.name, file.text)) for file in case.texts), + ) + for case in report.cases + ), ) @pytest.fixture -def changed_image_case(): - """A case whose single image changed.""" - return _case( - [_analysed_file("plot.png", FileKind.IMAGE, _entry("1"), _entry("2", 20))], - base=_manifest(3), - head=_manifest(4), +def changed_image_case(tmp_path): + """A report whose single case has one changed image.""" + return _analysed( + [ + _case_change( + [_change("plot.png", _entry("1"), _entry("2", 20))], base=_manifest(3), head=_manifest(4) + ) + ], + tmp_path, ) class TestIndex: - def test_one_row_per_case(self): - report = _report( + def test_one_row_per_case(self, tmp_path): + report = _analysed( [ - _case([], label="example/diag/a", base=_manifest(1), head=_manifest(2)), - _case([], label="pmp/diag/b", base=_manifest(1), head=_manifest(2)), - ] + _case_change([], label="example/diag/a", base=_manifest(1), head=_manifest(2)), + _case_change([], label="pmp/diag/b", base=_manifest(1), head=_manifest(2)), + ], + tmp_path, ) html = render_index(report) @@ -128,18 +137,43 @@ def test_one_row_per_case(self): assert "example/diag/a" in html assert "pmp/diag/b" in html - def test_every_link_ends_in_index_html(self): - report = _report([_case([], base=_manifest(1), head=_manifest(2))]) + def test_every_link_ends_in_index_html(self, tmp_path): + report = _analysed([_case_change([], base=_manifest(1), head=_manifest(2))], tmp_path) assert _hrefs(render_index(report)) == ["example/diag/case/index.html"] - def test_versions_column(self): - report = _report([_case([], base=_manifest(3), head=_manifest(4))]) + def test_versions_column(self, tmp_path): + report = _analysed([_case_change([], base=_manifest(3), head=_manifest(4))], tmp_path) assert "v3 -> v4" in render_index(report) - def test_an_empty_report_says_so(self): - html = render_index(_report([])) + def test_a_column_header_per_kind(self, tmp_path): + report = _analysed([_case_change([], base=_manifest(1), head=_manifest(2))], tmp_path) + + headers = render_index(report) + + for kind in FileKind: + assert f"" in headers + + def test_counts_appear_per_kind(self, tmp_path): + report = _analysed( + [ + _case_change( + [_change("a.png", None, _entry("1")), _change("b.nc", _entry("2"), None)], + base=_manifest(1), + head=_manifest(2), + ) + ], + tmp_path, + ) + + html = render_index(report) + + assert '+1' in html + assert '-1' in html + + def test_an_empty_report_says_so(self, tmp_path): + html = render_index(_analysed([], tmp_path)) assert "No baseline manifests changed" in html assert "" not in html @@ -147,23 +181,20 @@ def test_an_empty_report_says_so(self): class TestCasePage: def test_a_changed_image_renders_two_images(self, changed_image_case): - report = _report([changed_image_case]) - - images = _tags(render_case(report, changed_image_case), "img") + images = _tags(render_case(changed_image_case, changed_image_case.cases[0]), "img") assert len(images) == 2 assert all(image["src"].startswith(STORE_URL) for image in images) - def test_an_added_image_renders_one_image_and_a_placeholder(self): - case = _case([_analysed_file("plot.png", FileKind.IMAGE, None, _entry("2"))]) - report = _report([case]) + def test_an_added_image_renders_one_image_and_a_placeholder(self, tmp_path): + report = _analysed([_case_change([_change("plot.png", None, _entry("2"))])], tmp_path) - html = render_case(report, case) + html = render_case(report, report.cases[0]) assert len(_tags(html, "img")) == 1 assert 'class="absent"' in html - def test_a_text_diff_renders_one_span_per_line(self): + def test_a_text_diff_renders_one_span_per_line(self, tmp_path): diff = TextDiff( lines=( DiffLine(kind="header", text="--- old"), @@ -174,10 +205,13 @@ def test_a_text_diff_renders_one_span_per_line(self): note=None, elided=0, ) - case = _case([_analysed_file("series.csv", FileKind.TEXT, _entry("1"), _entry("2"), text=diff)]) - report = _report([case]) + report = _analysed( + [_case_change([_change("series.csv", _entry("1"), _entry("2"))])], + tmp_path, + diffs={"series.csv": diff}, + ) - html = render_case(report, case) + html = render_case(report, report.cases[0]) spans = [ attrs["class"] for attrs in _tags(html, "span") @@ -186,65 +220,65 @@ def test_a_text_diff_renders_one_span_per_line(self): assert spans == ["header", "hunk", "remove", "add"] - def test_a_note_replaces_the_diff(self): - diff = TextDiff(lines=(), note="fetching disabled", elided=0) - case = _case([_analysed_file("series.csv", FileKind.TEXT, None, _entry("2"), text=diff)]) - report = _report([case]) + def test_a_note_replaces_the_diff(self, tmp_path): + report = _analysed([_case_change([_change("series.csv", None, _entry("2"))])], tmp_path) - html = render_case(report, case) + html = render_case(report, report.cases[0]) assert "fetching disabled" in html assert '
' not in html
 
-    def test_elided_lines_are_reported(self):
+    def test_elided_lines_are_reported(self, tmp_path):
         diff = TextDiff(lines=(DiffLine(kind="add", text="+a"),), note=None, elided=7)
-        case = _case([_analysed_file("series.csv", FileKind.TEXT, None, _entry("2"), text=diff)])
-        report = _report([case])
+        report = _analysed(
+            [_case_change([_change("series.csv", None, _entry("2"))])],
+            tmp_path,
+            diffs={"series.csv": diff},
+        )
 
-        assert "7 further diff line(s) elided" in render_case(report, case)
+        assert "7 further diff line(s) elided" in render_case(report, report.cases[0])
 
-    def test_netcdf_renders_as_a_row(self):
-        case = _case([_analysed_file("out.nc", FileKind.NETCDF, _entry("1"), None)])
-        report = _report([case])
+    def test_netcdf_renders_as_a_row(self, tmp_path):
+        report = _analysed([_case_change([_change("out.nc", _entry("1"), None)])], tmp_path)
 
-        html = render_case(report, case)
+        html = render_case(report, report.cases[0])
 
         assert "out.nc" in html
         assert "was 10 B" in html
         assert not _tags(html, "img")
 
-    def test_the_back_link_matches_the_slug_depth(self):
-        case = _case([], label="pmp/diag/one")
-        report = _report([case])
+    def test_the_back_link_matches_the_label_depth(self, tmp_path):
+        report = _analysed([_case_change([], label="pmp/diag/one")], tmp_path)
 
-        assert "../../../index.html" in _hrefs(render_case(report, case))
+        assert "../../../index.html" in _hrefs(render_case(report, report.cases[0]))
 
-    def test_a_shallow_slug_gets_a_shallow_back_link(self):
-        case = _case([], label="pmp")
-        report = _report([case])
+    def test_a_shallow_label_gets_a_shallow_back_link(self, tmp_path):
+        report = _analysed([_case_change([], label="pmp")], tmp_path)
 
-        assert "../index.html" in _hrefs(render_case(report, case))
+        assert "../index.html" in _hrefs(render_case(report, report.cases[0]))
 
     def test_a_changed_file_shows_its_signed_size_delta(self, changed_image_case):
-        assert "(+10)" in render_case(_report([changed_image_case]), changed_image_case)
+        assert "(+10)" in render_case(changed_image_case, changed_image_case.cases[0])
 
-    def test_a_text_diff_names_both_digests(self):
+    def test_a_text_diff_names_both_digests(self, tmp_path):
         diff = TextDiff(lines=(DiffLine(kind="add", text="+a"),), note=None, elided=0)
-        case = _case([_analysed_file("series.csv", FileKind.TEXT, _entry("1"), _entry("2"), text=diff)])
+        report = _analysed(
+            [_case_change([_change("series.csv", _entry("1"), _entry("2"))])],
+            tmp_path,
+            diffs={"series.csv": diff},
+        )
 
-        html = render_case(_report([case]), case)
+        html = render_case(report, report.cases[0])
 
         assert "1" * 12 in html
         assert "2" * 12 in html
 
     def test_links_are_internal_index_pages_or_store_blobs(self, changed_image_case):
-        report = _report([changed_image_case])
-
-        for href in _hrefs(render_case(report, changed_image_case)):
+        for href in _hrefs(render_case(changed_image_case, changed_image_case.cases[0])):
             assert href.endswith("index.html") or href.startswith(STORE_URL)
 
     def test_metadata_and_committed_are_listed(self, changed_image_case):
-        html = render_case(_report([changed_image_case]), changed_image_case)
+        html = render_case(changed_image_case, changed_image_case.cases[0])
 
         assert "test_case_version: 3 -> 4" in html
         assert "series.json" in html
@@ -252,15 +286,13 @@ def test_metadata_and_committed_are_listed(self, changed_image_case):
 
 class TestWriteSite:
     def test_writes_an_index_and_a_page_per_case(self, tmp_path, changed_image_case):
-        report = _report([changed_image_case])
-
-        index = write_site(report, tmp_path / "out")
+        index = write_site(changed_image_case, tmp_path / "out")
 
         assert index == tmp_path / "out" / "index.html"
         assert index.exists()
         assert (tmp_path / "out" / "example" / "diag" / "case" / "index.html").exists()
 
     def test_an_empty_report_still_writes_an_index(self, tmp_path):
-        index = write_site(_report([]), tmp_path / "out")
+        index = write_site(_analysed([], tmp_path), tmp_path / "out")
 
         assert index.exists()

From 6a55a709cb1f31accf7ae78489ff294004419acf Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 11:47:32 +1000
Subject: [PATCH 44/64] fix: point blob links at the right place for a local
 native store

`blob_url` built `/`, but a local store fans its blobs out as `//`. Every link and image on a report generated against a local store therefore pointed at nothing.

It now takes the store rather than its URL, and returns a `file://` URI through the real layout when the store is local. A remote store is unchanged.
---
 .../climate_ref/baseline_report/analyse.py    | 26 +++++++++----------
 .../unit/baseline_report/test_analyse.py      | 23 ++++++++++++++--
 .../tests/unit/baseline_report/test_render.py |  1 +
 3 files changed, 35 insertions(+), 15 deletions(-)

diff --git a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
index 2193a2ad1..e0afc2edc 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
@@ -137,23 +137,28 @@ class AnalysedReport:
     """The count column headers, matching the order of every case's ``counts``."""
 
 
-def blob_url(store_url: str, digest: str) -> str:
+def blob_url(store: NativeStore, digest: str) -> str:
     """
     Build the URL a blob is served from.
 
+    A local store fans its blobs out by the first two digest characters, so a flat URL under
+    its root would point at nothing.
+
     Parameters
     ----------
-    store_url
-        Base URL of the native store.
+    store
+        The store the blob lives in.
     digest
         The blob's sha256 hex digest.
 
     Returns
     -------
     :
-        The URL.
+        An absolute URL a browser can open.
     """
-    return f"{store_url.rstrip('/')}/{digest}"
+    if store.root is not None:
+        return (store.root / digest[:2] / digest).absolute().as_uri()
+    return f"{store.url.rstrip('/')}/{digest}"
 
 
 def _as_lines(path: Path | None, name: str) -> list[str]:
@@ -325,7 +330,6 @@ def _diff_for(
 def _analyse_file(
     change: FileChange,
     store: NativeStore,
-    store_url: str,
     *,
     fetch: bool,
     workdir: Path,
@@ -339,8 +343,6 @@ def _analyse_file(
         The file that moved.
     store
         The store to read blobs from.
-    store_url
-        Base URL of the store, used to build links.
     fetch
         Whether blobs may be downloaded.
     workdir
@@ -353,8 +355,8 @@ def _analyse_file(
     """
     return AnalysedFile(
         change=change,
-        old_url=blob_url(store_url, change.old.sha256) if change.old else None,
-        new_url=blob_url(store_url, change.new.sha256) if change.new else None,
+        old_url=blob_url(store, change.old.sha256) if change.old else None,
+        new_url=blob_url(store, change.new.sha256) if change.new else None,
         text=_diff_for(change, store, fetch=fetch, workdir=workdir),
         size_delta=change.new.size - change.old.size if change.old and change.new else None,
     )
@@ -435,9 +437,7 @@ def analyse(report: Report, store: NativeStore, *, fetch: bool, workdir: Path) -
     store_url = store.url.rstrip("/")
     cases = []
     for case in report.cases:
-        files = tuple(
-            _analyse_file(change, store, store_url, fetch=fetch, workdir=workdir) for change in case.files
-        )
+        files = tuple(_analyse_file(change, store, fetch=fetch, workdir=workdir) for change in case.files)
         depth = len(PurePosixPath(case.label).parts)
         cases.append(
             AnalysedCase(
diff --git a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py
index beb040deb..2c82433dc 100644
--- a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py
+++ b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py
@@ -43,8 +43,19 @@ def _report(files):
 
 
 class TestBlobUrl:
-    def test_joins_the_digest_onto_the_store(self):
-        assert blob_url("https://store/", "a" * 64) == f"https://store/{'a' * 64}"
+    def test_a_remote_store_serves_blobs_flat(self):
+        store = NativeStore(url="https://store/")
+
+        assert blob_url(store, "a" * 64) == f"https://store/{'a' * 64}"
+
+    def test_a_local_store_keeps_its_two_level_fan_out(self, tmp_path):
+        store = NativeStore(url=str(tmp_path / "store"))
+        digest = "a" * 64
+
+        url = blob_url(store, digest)
+
+        assert url.startswith("file://")
+        assert url.endswith(f"/aa/{digest}")
 
 
 class TestTextDiff:
@@ -90,6 +101,7 @@ class TestAnalyse:
     def test_no_fetch_notes_every_text_file(self, tmp_path):
         store = MagicMock(spec=NativeStore)
         store.url = "https://store"
+        store.root = None
         report = _report(
             [
                 _file_change("plot.png", None, NativeEntry(sha256="1" * 64, size=10)),
@@ -107,6 +119,7 @@ def test_no_fetch_notes_every_text_file(self, tmp_path):
     def test_counts_are_tallied_per_kind(self, tmp_path):
         store = MagicMock(spec=NativeStore)
         store.url = "https://store"
+        store.root = None
         entry = NativeEntry(sha256="1" * 64, size=10)
         other = NativeEntry(sha256="2" * 64, size=10)
         report = _report(
@@ -131,6 +144,7 @@ def test_counts_are_tallied_per_kind(self, tmp_path):
     def test_partitions_and_back_link(self, tmp_path):
         store = MagicMock(spec=NativeStore)
         store.url = "https://store"
+        store.root = None
         entry = NativeEntry(sha256="1" * 64, size=10)
         report = _report(
             [
@@ -152,6 +166,7 @@ def test_partitions_and_back_link(self, tmp_path):
     def test_the_back_link_follows_the_label_depth(self, tmp_path):
         store = MagicMock(spec=NativeStore)
         store.url = "https://store"
+        store.root = None
         report = _report([])
         shallow = evolve(report.cases[0], label="pmp")
         report = evolve(report, cases=(shallow,))
@@ -163,6 +178,7 @@ def test_the_back_link_follows_the_label_depth(self, tmp_path):
     def test_size_delta_is_signed_and_absent_on_one_sided_files(self, tmp_path):
         store = MagicMock(spec=NativeStore)
         store.url = "https://store"
+        store.root = None
         report = _report(
             [
                 _file_change(
@@ -208,6 +224,7 @@ def test_local_store_produces_a_real_diff(self, tmp_path):
     def test_oversized_blob_is_noted_not_fetched(self, tmp_path):
         store = MagicMock(spec=NativeStore)
         store.url = "https://store"
+        store.root = None
         report = _report(
             [
                 _file_change(
@@ -226,6 +243,7 @@ def test_oversized_blob_is_noted_not_fetched(self, tmp_path):
     def test_a_failed_fetch_becomes_a_note(self, tmp_path):
         store = MagicMock(spec=NativeStore)
         store.url = "https://store"
+        store.root = None
         store.fetch.side_effect = FileNotFoundError("gone")
         report = _report([_file_change("series.json", None, NativeEntry(sha256="1" * 64, size=10))])
 
@@ -237,6 +255,7 @@ def test_a_failed_fetch_becomes_a_note(self, tmp_path):
     def test_urls_follow_the_entries_that_exist(self, tmp_path, fetch):
         store = MagicMock(spec=NativeStore)
         store.url = "https://store/"
+        store.root = None
         report = _report([_file_change("plot.png", NativeEntry(sha256="1" * 64, size=10), None)])
 
         analysed = analyse(report, store, fetch=fetch, workdir=tmp_path)
diff --git a/packages/climate-ref/tests/unit/baseline_report/test_render.py b/packages/climate-ref/tests/unit/baseline_report/test_render.py
index c99dbc99e..359146c61 100644
--- a/packages/climate-ref/tests/unit/baseline_report/test_render.py
+++ b/packages/climate-ref/tests/unit/baseline_report/test_render.py
@@ -87,6 +87,7 @@ def _analysed(cases, tmp_path, diffs=None) -> AnalysedReport:
     """
     store = MagicMock(spec=NativeStore)
     store.url = STORE_URL
+    store.root = None
     report = analyse(
         Report(base_ref="origin/main", head_sha="a" * 40, cases=tuple(cases)),
         store,

From 483e76b8a3e5c1b17e3fcb0ed49c53443d5c0bbe Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 11:59:04 +1000
Subject: [PATCH 45/64] chore(deps): raise the mypy requirement to match the
 lockfile

The lockfile moved to mypy 2.3.1, but the requirement floor still allowed
mypy 1. This pins the floor to 2.3.1 so a fresh resolve cannot disagree
with CI.
---
 pyproject.toml | 2 +-
 uv.lock        | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 242d07e62..aba4f1538 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,7 +29,7 @@ dev = [
     "pytest-mock >= 3.12",
     "pytest-regressions>=2.5.0",
     "coverage>=7.2.0",
-    "mypy>=1.20.1",
+    "mypy>=2.3.1",
     "ruff>=0.15.11",
     "pre-commit>=3.3.1",
     "towncrier>=25.8.0",
diff --git a/uv.lock b/uv.lock
index 7f8e43990..adaa71ac7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1075,7 +1075,7 @@ dev = [
     { name = "mkdocs-section-index", specifier = ">=0.3.12" },
     { name = "mkdocs-typer2", specifier = ">=0.3.0" },
     { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.4" },
-    { name = "mypy", specifier = ">=1.20.1" },
+    { name = "mypy", specifier = ">=2.3.1" },
     { name = "notebook", specifier = ">=7.5.6" },
     { name = "pandas-indexing", specifier = ">=0.6.3" },
     { name = "pandas-stubs", specifier = ">=2.2.3" },

From 19282305891293a5d47ef034ec6dc134c482639c Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 12:01:31 +1000
Subject: [PATCH 46/64] docs: correct the stale references left by the
 refactors

- The back link is derived from the label, not the removed `slug` field.
- The report header now names the same base that `blob_url` builds from, so a local store's `file://` links and the header agree.
- Drops the filter count from a docstring, which had already drifted once.
---
 changelog/910.feature.md                                    | 5 ++++-
 .../climate-ref/src/climate_ref/baseline_report/analyse.py  | 6 +++---
 .../climate-ref/src/climate_ref/baseline_report/render.py   | 2 +-
 3 files changed, 8 insertions(+), 5 deletions(-)

diff --git a/changelog/910.feature.md b/changelog/910.feature.md
index 4b82e7f45..52e0ddb75 100644
--- a/changelog/910.feature.md
+++ b/changelog/910.feature.md
@@ -1,3 +1,6 @@
 `ref test-cases diff` renders the regression baselines changed on a branch as a local HTML report.
 Images appear old and new side by side, with a button that overlays them.
-Text outputs get a coloured diff, and NetCDF and other binaries are listed with a size delta and a link to each blob.
+Text outputs get a coloured diff,
+and NetCDF and other binaries are listed with a size delta and a link to each blob.
+`--html-dir` chooses where the report is written,
+and `--no-fetch` skips blob downloads and reports sizes only.
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
index e0afc2edc..845b2370f 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
@@ -117,7 +117,7 @@ class AnalysedCase:
     """The NetCDF and other files, which render as a table row."""
 
     back_link: str
-    """Relative link from this case's page back to the index, one ``..`` per slug segment."""
+    """Relative link from this case's page back to the index, one ``..`` per label segment."""
 
 
 @frozen
@@ -128,7 +128,7 @@ class AnalysedReport:
     """The underlying report."""
 
     store_url: str
-    """Base URL of the native store, without a trailing slash."""
+    """Base location blobs are served from, matching what :func:`blob_url` builds."""
 
     cases: tuple[AnalysedCase, ...]
     """The analysed cases, in the order collection produced them."""
@@ -434,7 +434,7 @@ def analyse(report: Report, store: NativeStore, *, fetch: bool, workdir: Path) -
     :
         The analysed report.
     """
-    store_url = store.url.rstrip("/")
+    store_url = store.root.absolute().as_uri() if store.root is not None else store.url.rstrip("/")
     cases = []
     for case in report.cases:
         files = tuple(_analyse_file(change, store, fetch=fetch, workdir=workdir) for change in case.files)
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/render.py b/packages/climate-ref/src/climate_ref/baseline_report/render.py
index 39717951c..2036ba8f6 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/render.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/render.py
@@ -82,7 +82,7 @@ def _build_env() -> Environment:
     Returns
     -------
     :
-        The environment, with the three permitted filters registered.
+        The environment, with the formatting filters the templates use.
     """
     env = Environment(
         loader=PackageLoader("climate_ref.baseline_report", "templates"),

From 36cd4b50ad60d8660a14c2b7574eae2d1474a4d0 Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 12:17:47 +1000
Subject: [PATCH 47/64] chore: add a changelog fragment for the mypy 2 bump

---
 changelog/877.trivial.md | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 changelog/877.trivial.md

diff --git a/changelog/877.trivial.md b/changelog/877.trivial.md
new file mode 100644
index 000000000..34de019b4
--- /dev/null
+++ b/changelog/877.trivial.md
@@ -0,0 +1,2 @@
+Updated the development type checker to mypy 2.
+The declared requirement now matches the lockfile, so a fresh resolve cannot pick a mypy 1 release that disagrees with CI.

From 38ecec76647697fd3de6a25cad4dfaeb4819bb04 Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 12:29:01 +1000
Subject: [PATCH 48/64] feat: add netcdf stats to the baseline diff report

A changed `.nc` file previously showed only a size delta and two download links, so a
reviewer could not tell a renamed attribute from a numerical regression without opening
both blobs by hand.

- Adds `netcdf_diff`, which opens both sides with xarray and returns a diff of the
  ncdump-style header plus one `StatRow` per data variable.
- Each row carries min, max, mean and NaN count on both sides, the largest absolute and
  relative difference, and the number of cells that differ.
- NaN counts as equal to NaN, so a masked cell staying masked is not a change.
- Rows where something moved are shaded.
- Splits the old combined table into a NetCDF section and an "Other files" table.
- Declares `xarray`, which the workspace previously only had by accident.
- Wraps both tables so a wide one scrolls inside its card, and lets a long file name wrap.

Decoding is turned off when opening, so a non-standard calendar or unit cannot fail the
report. Stats are whole-array. Per-time or per-level breakdowns are deliberately not here.
---
 changelog/912.feature.md                      |   7 +
 packages/climate-ref/pyproject.toml           |   1 +
 .../climate_ref/baseline_report/analyse.py    | 418 +++++++++++++++++-
 .../src/climate_ref/baseline_report/render.py |  22 +
 .../baseline_report/templates/case.html.j2    |  17 +-
 .../baseline_report/templates/macros.html.j2  |  70 ++-
 .../baseline_report/templates/report.css      |  32 ++
 .../unit/baseline_report/test_analyse.py      | 187 +++++++-
 .../tests/unit/baseline_report/test_render.py | 135 +++++-
 uv.lock                                       |   2 +
 10 files changed, 861 insertions(+), 30 deletions(-)
 create mode 100644 changelog/912.feature.md

diff --git a/changelog/912.feature.md b/changelog/912.feature.md
new file mode 100644
index 000000000..ea6965975
--- /dev/null
+++ b/changelog/912.feature.md
@@ -0,0 +1,7 @@
+A changed `.nc` file in the `ref test-cases diff` report now shows what moved inside it.
+Each file gets a collapsible diff of its ncdump-style header
+and a table with one row per data variable carrying min, max, mean, NaN count,
+the largest absolute and relative difference, and the number of cells that differ.
+NaN counts as equal to NaN, so a masked cell staying masked is not a change.
+Rows where something moved are shaded, so the eye lands on them.
+Wide tables and long file names now stay inside their card.
diff --git a/packages/climate-ref/pyproject.toml b/packages/climate-ref/pyproject.toml
index 2ba683baa..b694ecfdf 100644
--- a/packages/climate-ref/pyproject.toml
+++ b/packages/climate-ref/pyproject.toml
@@ -43,6 +43,7 @@ dependencies = [
     "tqdm>=4.67.1",
     "gitpython>=3.1.58",
     "jinja2>=3.1",
+    "xarray>=2025.1",
     # parsl doesn't support Windows yet
     # We don't target Windows either, but this __might__ allow Windows users to install the package
     'parsl>=2025.5.19; sys_platform != "win32"'
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
index 845b2370f..80ff1094b 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
@@ -7,12 +7,17 @@
 from __future__ import annotations
 
 import difflib
+import io
 import json
+import warnings
 from collections import Counter
+from contextlib import ExitStack
 from itertools import islice
 from pathlib import Path, PurePosixPath
 from typing import TYPE_CHECKING
 
+import numpy as np
+import xarray as xr
 from attrs import frozen
 
 from climate_ref.baseline_report.collect import CaseChange, FileChange, FileKind, Report
@@ -28,6 +33,10 @@
 # Digest prefix shown wherever a blob is named. Long enough to identify it, short enough to read.
 SHORT_DIGEST = 12
 
+# A NetCDF blob larger than this is left unopened. Well above the largest baseline file,
+# because opening one is cheap next to downloading it.
+NETCDF_FETCH_BYTES = 100_000_000
+
 # Unified-diff lines kept per file before the rest is elided.
 MAX_DIFF_LINES = 5000
 
@@ -57,6 +66,70 @@ class TextDiff:
     """Lines dropped past :data:`MAX_DIFF_LINES`."""
 
 
+@frozen
+class StatRow:
+    """Whole-array statistics for one data variable, on each side of the change."""
+
+    name: str
+    """The variable's name."""
+
+    shape_old: str | None
+    """Dimensions on the base ref, as ``180x360``, or ``None`` when the variable is absent."""
+
+    shape_new: str | None
+    """Dimensions on HEAD, as ``180x360``, or ``None`` when the variable is absent."""
+
+    min_old: float | None
+    """Minimum on the base ref, ignoring NaN. ``None`` when unavailable."""
+
+    min_new: float | None
+    """Minimum on HEAD, ignoring NaN. ``None`` when unavailable."""
+
+    max_old: float | None
+    """Maximum on the base ref, ignoring NaN. ``None`` when unavailable."""
+
+    max_new: float | None
+    """Maximum on HEAD, ignoring NaN. ``None`` when unavailable."""
+
+    mean_old: float | None
+    """Mean on the base ref, ignoring NaN. ``None`` when unavailable."""
+
+    mean_new: float | None
+    """Mean on HEAD, ignoring NaN. ``None`` when unavailable."""
+
+    nan_old: int | None
+    """NaN cells on the base ref, or ``None`` when the variable is absent or not numeric."""
+
+    nan_new: int | None
+    """NaN cells on HEAD, or ``None`` when the variable is absent or not numeric."""
+
+    max_abs_diff: float | None
+    """Largest absolute change, or ``None`` when the shapes differ or a side is absent."""
+
+    max_rel_diff: float | None
+    """:attr:`max_abs_diff` over the largest magnitude on the base ref."""
+
+    cells_differ: int | None
+    """Cells that changed, counting NaN as equal to NaN."""
+
+    moved: bool
+    """Whether anything about this variable changed, which is what shades its row."""
+
+
+@frozen
+class NetcdfDiff:
+    """What changed inside one NetCDF file, or the reason nothing could be read."""
+
+    header: tuple[DiffLine, ...]
+    """Unified diff of the two ncdump-style headers, empty when they match."""
+
+    rows: tuple[StatRow, ...]
+    """One row per data variable, in name order."""
+
+    note: str | None
+    """Why the file could not be analysed, or ``None`` when it was."""
+
+
 @frozen
 class AnalysedFile:
     """One native file, with its blob URLs and its diff where it has one."""
@@ -76,6 +149,10 @@ class AnalysedFile:
     size_delta: int | None
     """Signed byte change, or ``None`` when the file exists on only one side."""
 
+    netcdf: NetcdfDiff | None = None
+    """The header and stats diff, set only for
+    :attr:`~climate_ref.baseline_report.collect.FileKind.NETCDF` files."""
+
 
 @frozen
 class KindCounts:
@@ -113,8 +190,11 @@ class AnalysedCase:
     texts: tuple[AnalysedFile, ...]
     """The text files, which render as a diff."""
 
-    binaries: tuple[AnalysedFile, ...]
-    """The NetCDF and other files, which render as a table row."""
+    netcdfs: tuple[AnalysedFile, ...]
+    """The NetCDF files, which render as a header diff and a table of variable statistics."""
+
+    others: tuple[AnalysedFile, ...]
+    """Every remaining file, which renders as a table row."""
 
     back_link: str
     """Relative link from this case's page back to the index, one ``..`` per label segment."""
@@ -216,6 +296,37 @@ def _classify_line(line: str) -> str:
     return "context"
 
 
+def _diff_lines(
+    old_lines: list[str],
+    new_lines: list[str],
+    *,
+    fromfile: str,
+    tofile: str,
+) -> tuple[tuple[DiffLine, ...], int]:
+    """
+    Build the tagged unified diff of two line lists.
+
+    Parameters
+    ----------
+    old_lines
+        The base side, empty when it is absent.
+    new_lines
+        The head side, empty when it is absent.
+    fromfile
+        Label for the base side in the diff header.
+    tofile
+        Label for the head side in the diff header.
+
+    Returns
+    -------
+    :
+        The kept lines and the number dropped past :data:`MAX_DIFF_LINES`.
+    """
+    raw = difflib.unified_diff(old_lines, new_lines, fromfile=fromfile, tofile=tofile, lineterm="", n=3)
+    kept = list(islice(raw, MAX_DIFF_LINES))
+    return tuple(DiffLine(kind=_classify_line(line), text=line) for line in kept), sum(1 for _ in raw)
+
+
 def text_diff(old: Path | None, new: Path | None, name: str) -> TextDiff:
     """
     Build the unified diff between two fetched blobs.
@@ -234,29 +345,255 @@ def text_diff(old: Path | None, new: Path | None, name: str) -> TextDiff:
     :
         The diff, or a note explaining why there is not one.
     """
-    raw = difflib.unified_diff(
+    lines, elided = _diff_lines(
         _as_lines(old, name),
         _as_lines(new, name),
         fromfile="old" if old is not None else "(absent)",
         tofile="new" if new is not None else "(absent)",
-        lineterm="",
-        n=3,
     )
-    kept = list(islice(raw, MAX_DIFF_LINES))
-    if not kept:
+    if not lines:
         return TextDiff(lines=(), note="identical after decoding", elided=0)
-    return TextDiff(
-        lines=tuple(DiffLine(kind=_classify_line(line), text=line) for line in kept),
-        note=None,
-        elided=sum(1 for _ in raw),
+    return TextDiff(lines=lines, note=None, elided=elided)
+
+
+def _header(dataset: xr.Dataset | None) -> list[str]:
+    """
+    Render a dataset's ncdump-style header as lines.
+
+    Parameters
+    ----------
+    dataset
+        The open dataset, or ``None`` when the file is absent on that side.
+
+    Returns
+    -------
+    :
+        The header lines, empty when there is no dataset.
+    """
+    if dataset is None:
+        return []
+    buf = io.StringIO()
+    dataset.info(buf)
+    return buf.getvalue().splitlines()
+
+
+def _values(dataset: xr.Dataset | None, name: str) -> np.ndarray | None:
+    """
+    Read one data variable as a float array.
+
+    Parameters
+    ----------
+    dataset
+        The open dataset, or ``None`` when the file is absent on that side.
+    name
+        The variable's name.
+
+    Returns
+    -------
+    :
+        The values as float64, or ``None`` when the variable is absent or not numeric.
+    """
+    if dataset is None or name not in dataset.data_vars:
+        return None
+    values = dataset[name].values
+    if not np.issubdtype(values.dtype, np.number):
+        return None
+    return np.asarray(values, dtype=float)
+
+
+def _shape(dataset: xr.Dataset | None, name: str) -> str | None:
+    """
+    Render one variable's shape.
+
+    Parameters
+    ----------
+    dataset
+        The open dataset, or ``None`` when the file is absent on that side.
+    name
+        The variable's name.
+
+    Returns
+    -------
+    :
+        For example ``180x360``, ``scalar`` for a zero-dimensional variable, or ``None``
+        when the variable is absent.
+    """
+    if dataset is None or name not in dataset.data_vars:
+        return None
+    shape = dataset[name].shape
+    return "x".join(str(size) for size in shape) if shape else "scalar"
+
+
+def _summarise(values: np.ndarray | None) -> tuple[float | None, float | None, float | None, int | None]:
+    """
+    Reduce one side's values to min, max, mean and NaN count.
+
+    Parameters
+    ----------
+    values
+        The float array, or ``None`` when the variable is absent or not numeric.
+
+    Returns
+    -------
+    :
+        The four statistics. The first three are ``None`` for an empty or all-NaN array,
+        which is what numpy would otherwise report as NaN with a warning.
+    """
+    if values is None:
+        return None, None, None, None
+    nan_count = int(np.isnan(values).sum())
+    if values.size in (0, nan_count):
+        return None, None, None, nan_count
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore", RuntimeWarning)
+        return (
+            float(np.nanmin(values)),
+            float(np.nanmax(values)),
+            float(np.nanmean(values)),
+            nan_count,
+        )
+
+
+def _compare(old: np.ndarray | None, new: np.ndarray | None) -> tuple[float | None, float | None, int | None]:
+    """
+    Compare two sides of the same variable cell by cell.
+
+    NaN counts as equal to NaN, because a masked cell staying masked is not a change.
+
+    Parameters
+    ----------
+    old
+        The base side, or ``None`` when absent or not numeric.
+    new
+        The head side, or ``None`` when absent or not numeric.
+
+    Returns
+    -------
+    :
+        The largest absolute difference, the same relative to the base side's largest
+        magnitude, and the number of cells that differ. All ``None`` when the sides cannot
+        be compared.
+    """
+    if old is None or new is None or old.shape != new.shape:
+        return None, None, None
+    same = (old == new) | (np.isnan(old) & np.isnan(new))
+    cells_differ = int(np.sum(~same))
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore", RuntimeWarning)
+        diff = np.abs(new - old)
+        max_abs = float(np.nanmax(diff)) if diff.size else 0.0
+        scale = float(np.nanmax(np.abs(old))) if old.size else 0.0
+    if np.isnan(max_abs):
+        max_abs = 0.0
+    if np.isnan(scale):
+        scale = 0.0
+    return max_abs, max_abs / max(scale, float(np.finfo(float).tiny)), cells_differ
+
+
+def _stat_row(old: xr.Dataset | None, new: xr.Dataset | None, name: str) -> StatRow:
+    """
+    Build one variable's row.
+
+    Parameters
+    ----------
+    old
+        The base dataset, or ``None`` when the file is absent on that side.
+    new
+        The head dataset, or ``None`` when the file is absent on that side.
+    name
+        The variable's name.
+
+    Returns
+    -------
+    :
+        The row, with ``moved`` set when the shape or any cell changed.
+    """
+    old_values = _values(old, name)
+    new_values = _values(new, name)
+    shape_old = _shape(old, name)
+    shape_new = _shape(new, name)
+    min_old, max_old, mean_old, nan_old = _summarise(old_values)
+    min_new, max_new, mean_new, nan_new = _summarise(new_values)
+    max_abs_diff, max_rel_diff, cells_differ = _compare(old_values, new_values)
+    return StatRow(
+        name=name,
+        shape_old=shape_old,
+        shape_new=shape_new,
+        min_old=min_old,
+        min_new=min_new,
+        max_old=max_old,
+        max_new=max_new,
+        mean_old=mean_old,
+        mean_new=mean_new,
+        nan_old=nan_old,
+        nan_new=nan_new,
+        max_abs_diff=max_abs_diff,
+        max_rel_diff=max_rel_diff,
+        cells_differ=cells_differ,
+        moved=(cells_differ or 0) > 0 or shape_old != shape_new,
     )
 
 
+def netcdf_diff(old: Path | None, new: Path | None) -> NetcdfDiff:
+    """
+    Diff the headers and whole-array statistics of two NetCDF blobs.
+
+    Decoding is turned off so a non-standard calendar or unit cannot fail the report.
+
+    Parameters
+    ----------
+    old
+        The base blob, or ``None`` when the file is new.
+    new
+        The head blob, or ``None`` when the file was removed.
+
+    Returns
+    -------
+    :
+        The header diff and one row per data variable, or a note when a file could not be opened.
+    """
+    try:
+        with ExitStack() as stack:
+            old_ds = (
+                stack.enter_context(xr.open_dataset(old, decode_times=False, decode_cf=False))
+                if old is not None
+                else None
+            )
+            new_ds = (
+                stack.enter_context(xr.open_dataset(new, decode_times=False, decode_cf=False))
+                if new is not None
+                else None
+            )
+            header, _ = _diff_lines(
+                _header(old_ds),
+                _header(new_ds),
+                fromfile="old" if old_ds is not None else "(absent)",
+                tofile="new" if new_ds is not None else "(absent)",
+            )
+            names = sorted(
+                {
+                    str(name)
+                    for dataset in (old_ds, new_ds)
+                    if dataset is not None
+                    for name in dataset.data_vars
+                }
+            )
+            rows = tuple(_stat_row(old_ds, new_ds, name) for name in names)
+    except (OSError, ValueError, KeyError) as exc:
+        return NetcdfDiff(header=(), rows=(), note=f"could not open: {exc}")
+    return NetcdfDiff(header=header, rows=rows, note=None)
+
+
 def _fetch_side(
-    store: NativeStore, entry: NativeEntry | None, workdir: Path
+    store: NativeStore,
+    entry: NativeEntry | None,
+    workdir: Path,
+    *,
+    limit: int = MAX_FETCH_BYTES,
+    oversize: str = "too large to diff",
 ) -> tuple[Path | None, str | None]:
     """
-    Fetch one side of a text file.
+    Fetch one side of a file.
 
     Parameters
     ----------
@@ -266,6 +603,10 @@ def _fetch_side(
         The manifest entry, or ``None`` when the file is absent on that side.
     workdir
         Directory the blob is written into.
+    limit
+        Largest blob worth downloading.
+    oversize
+        Note to return when the blob is past ``limit``.
 
     Returns
     -------
@@ -275,8 +616,8 @@ def _fetch_side(
     """
     if entry is None:
         return None, None
-    if entry.size > MAX_FETCH_BYTES:
-        return None, f"too large to diff ({entry.size:,} B)"
+    if entry.size > limit:
+        return None, f"{oversize} ({entry.size:,} B)"
     digest = entry.sha256
     dest = workdir / digest
     if dest.exists():
@@ -327,6 +668,49 @@ def _diff_for(
     return text_diff(old_path, new_path, change.name)
 
 
+def _netcdf_for(
+    change: FileChange,
+    store: NativeStore,
+    *,
+    fetch: bool,
+    workdir: Path,
+) -> NetcdfDiff | None:
+    """
+    Build the NetCDF analysis for one file, or ``None`` when its kind is not NetCDF.
+
+    Parameters
+    ----------
+    change
+        The file that moved.
+    store
+        The store to read blobs from.
+    fetch
+        Whether blobs may be downloaded.
+    workdir
+        Directory fetched blobs are written into.
+
+    Returns
+    -------
+    :
+        The analysis, a note explaining why there is not one, or ``None`` for another kind.
+    """
+    if change.kind is not FileKind.NETCDF:
+        return None
+    if not fetch:
+        return NetcdfDiff(header=(), rows=(), note="fetching disabled")
+
+    old_path, old_note = _fetch_side(
+        store, change.old, workdir, limit=NETCDF_FETCH_BYTES, oversize="too large to analyse"
+    )
+    new_path, new_note = _fetch_side(
+        store, change.new, workdir, limit=NETCDF_FETCH_BYTES, oversize="too large to analyse"
+    )
+    note = old_note or new_note
+    if note is not None:
+        return NetcdfDiff(header=(), rows=(), note=note)
+    return netcdf_diff(old_path, new_path)
+
+
 def _analyse_file(
     change: FileChange,
     store: NativeStore,
@@ -359,6 +743,7 @@ def _analyse_file(
         new_url=blob_url(store, change.new.sha256) if change.new else None,
         text=_diff_for(change, store, fetch=fetch, workdir=workdir),
         size_delta=change.new.size - change.old.size if change.old and change.new else None,
+        netcdf=_netcdf_for(change, store, fetch=fetch, workdir=workdir),
     )
 
 
@@ -446,7 +831,8 @@ def analyse(report: Report, store: NativeStore, *, fetch: bool, workdir: Path) -
                 counts=_counts(files),
                 images=_of_kind(files, FileKind.IMAGE),
                 texts=_of_kind(files, FileKind.TEXT),
-                binaries=_of_kind(files, FileKind.NETCDF, FileKind.OTHER),
+                netcdfs=_of_kind(files, FileKind.NETCDF),
+                others=_of_kind(files, FileKind.OTHER),
                 back_link="/".join([*[".."] * depth, "index.html"]),
             )
         )
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/render.py b/packages/climate-ref/src/climate_ref/baseline_report/render.py
index 2036ba8f6..645068d9a 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/render.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/render.py
@@ -56,6 +56,27 @@ def _format_signed(delta: int | None) -> str:
     return f"{delta:+,}"
 
 
+def _format_num(value: float | None) -> str:
+    """
+    Render a statistic.
+
+    Parameters
+    ----------
+    value
+        The number, or ``None`` when it could not be computed.
+
+    Returns
+    -------
+    :
+        An integer with thousands separators, a float to six significant figures, or ``-``.
+    """
+    if value is None:
+        return "-"
+    if isinstance(value, int):
+        return f"{value:,}"
+    return f"{value:.6g}"
+
+
 def _format_short(digest: str | None) -> str:
     """
     Render the readable prefix of a digest.
@@ -92,6 +113,7 @@ def _build_env() -> Environment:
     )
     env.filters["bytes"] = _format_bytes
     env.filters["signed"] = _format_signed
+    env.filters["num"] = _format_num
     env.filters["short"] = _format_short
     return env
 
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2
index cd5646a00..fbebdc176 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2
+++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/case.html.j2
@@ -38,19 +38,30 @@
 
 {% endif %}
 
-{% if case.binaries %}
+{% if case.netcdfs %}
 
-NetCDF and other +NetCDF +{% for file in case.netcdfs %} +{{ m.netcdf_block(file) }} +{% endfor %} +
+{% endif %} + +{% if case.others %} +
+Other files +
filestatussizeblobs
case versionsimagestextnetcdfother{{ kind }}
{{ case.change.label }}{{ case.change.label }} {% if case.change.is_removed %} removed @@ -25,11 +24,11 @@ new v{{ case.change.base.test_case_version }} -> v{{ case.change.head.test_case_version }} {% endif %} -{% if case.counts[kind].added %}+{{ case.counts[kind].added }}{% endif %} -{% if case.counts[kind].changed %}~{{ case.counts[kind].changed }}{% endif %} -{% if case.counts[kind].removed %}-{{ case.counts[kind].removed }}{% endif %} +{% if count.added %}+{{ count.added }}{% endif %} +{% if count.changed %}~{{ count.changed }}{% endif %} +{% if count.removed %}-{{ count.removed }}{% endif %}
{kind.value}
-{% for file in case.binaries %} +{% for file in case.others %} {{ m.binary_row(file) }} {% endfor %}
filestatussizeblobs
+
{% endif %} {% endblock %} diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 index 47fd0159a..5d747afdb 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 @@ -42,6 +42,14 @@ was {{ file.change.old.size | bytes -}} {% endmacro %} +{% macro diff_lines(lines) -%} +
+{%- for line in lines %}
+{{ line.text }}
+{%- endfor %}
+
+{%- endmacro %} + {% macro text_block(file) %}

@@ -57,11 +65,7 @@ was {{ file.change.old.size | bytes -}} {% if file.text.note %}

{{ file.text.note }}

{% else %} -
-{%- for line in file.text.lines %}
-{{ line.text }}
-{%- endfor %}
-
+{{ diff_lines(file.text.lines) }} {% if file.text.elided %}

{{ file.text.elided }} further diff line(s) elided.

{% endif %} @@ -77,3 +81,59 @@ was {{ file.change.old.size | bytes -}} {{ blob_links(file) }} {% endmacro %} + +{% macro stat_pair(old, new) -%} +{{ old | num }} -> {{ new | num }} +{%- endmacro %} + +{% macro netcdf_block(file) %} +
+

+{{ file.change.name }} +{{ file.change.status }} +{{ size_delta(file) }} +{{ blob_links(file) }} +

+{% if file.netcdf.note %} +

{{ file.netcdf.note }}

+{% else %} +
+Header +{% if file.netcdf.header %} +{{ diff_lines(file.netcdf.header) }} +{% else %} +

The headers match.

+{% endif %} +
+{% if file.netcdf.rows %} +
+ + + + + + + + +{% for row in file.netcdf.rows %} + + + + + + + + + + + +{% endfor %} + +
variableshapeminmaxmeannanmax abs diffmax rel diffcells differ
{{ row.name }}{{ row.shape_old or "-" }} -> {{ row.shape_new or "-" }}{{ stat_pair(row.min_old, row.min_new) }}{{ stat_pair(row.max_old, row.max_new) }}{{ stat_pair(row.mean_old, row.mean_new) }}{{ stat_pair(row.nan_old, row.nan_new) }}{{ row.max_abs_diff | num }}{{ row.max_rel_diff | num }}{{ row.cells_differ | num }}
+
+{% else %} +

No data variables.

+{% endif %} +{% endif %} +
+{% endmacro %} diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css index 62eb1ee0c..c56950b2d 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css @@ -9,6 +9,7 @@ --add-fg: #14532d; --remove-bg: #fbe4e4; --remove-fg: #7f1d1d; + --moved-bg: #fdeeee; } @media (prefers-color-scheme: dark) { @@ -22,6 +23,7 @@ --add-fg: #86e29b; --remove-bg: #3a1c1c; --remove-fg: #f2a1a1; + --moved-bg: #2c1717; } } @@ -154,3 +156,33 @@ summary { color: var(--muted); font-style: italic; } + +/* Table wrapper. A wide stats table scrolls inside its card rather than pushing the card wider. */ +.scroll-x { + max-width: 100%; + overflow-x: auto; +} + +.stats { + font-variant-numeric: tabular-nums; + min-width: 100%; + white-space: nowrap; + width: auto; +} + +.stats th, .stats td { + font-size: 0.9em; +} + +tr.moved { + background: var(--moved-bg); +} + +/* A baseline file name can be long and has no spaces to break on. */ +.file h3 code, .binaries code { + overflow-wrap: anywhere; +} + +.binaries td:first-child { + max-width: 40rem; +} diff --git a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py index 2c82433dc..4db2aa96c 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py @@ -3,10 +3,19 @@ import json from unittest.mock import MagicMock +import numpy as np import pytest +import xarray as xr from attrs import evolve -from climate_ref.baseline_report.analyse import MAX_FETCH_BYTES, analyse, blob_url, text_diff +from climate_ref.baseline_report.analyse import ( + MAX_FETCH_BYTES, + NETCDF_FETCH_BYTES, + analyse, + blob_url, + netcdf_diff, + text_diff, +) from climate_ref.baseline_report.collect import ( CaseChange, FileChange, @@ -159,7 +168,8 @@ def test_partitions_and_back_link(self, tmp_path): assert [f.change.name for f in case.images] == ["a.png"] assert [f.change.name for f in case.texts] == ["b.json"] - assert [f.change.name for f in case.binaries] == ["c.nc", "d.bin"] + assert [f.change.name for f in case.netcdfs] == ["c.nc"] + assert [f.change.name for f in case.others] == ["d.bin"] # The label has three segments, so the index sits three levels up. assert case.back_link == "../../../index.html" @@ -214,7 +224,7 @@ def test_local_store_produces_a_real_diff(self, tmp_path): ] ) - analysed = analyse(report, store, fetch=True, workdir=tmp_path / "work") + analysed = analyse(report, store, fetch=True, workdir=tmp_path) diff = analysed.cases[0].files[0].text assert diff.note is None @@ -264,3 +274,174 @@ def test_urls_follow_the_entries_that_exist(self, tmp_path, fetch): assert file.old_url == f"https://store/{'1' * 64}" assert file.new_url is None assert analysed.store_url == "https://store" + + +def _write_nc(path, data, attrs=None, name="tas"): + """Write a single-variable dataset to a NetCDF file and return its path.""" + values = np.asarray(data) + dataset = xr.Dataset( + {name: (("lat", "lon"), values)}, + coords={"lat": np.arange(values.shape[0], dtype=float), "lon": np.arange(values.shape[1])}, + attrs=attrs or {"title": "a"}, + ) + dataset.to_netcdf(path) + return path + + +@pytest.fixture +def base_nc(tmp_path): + """A two by two dataset both sides of every pair start from.""" + return _write_nc(tmp_path / "old.nc", [[1.0, 2.0], [3.0, 4.0]]) + + +class TestNetcdfDiff: + def test_identical_files_have_no_header_diff_and_no_moved_rows(self, tmp_path, base_nc): + new = _write_nc(tmp_path / "new.nc", [[1.0, 2.0], [3.0, 4.0]]) + + diff = netcdf_diff(base_nc, new) + + assert diff.note is None + assert diff.header == () + assert diff.rows[0].moved is False + assert diff.rows[0].cells_differ == 0 + assert diff.rows[0].max_abs_diff == 0.0 + + def test_a_changed_attribute_shows_in_the_header_only(self, tmp_path, base_nc): + new = _write_nc(tmp_path / "new.nc", [[1.0, 2.0], [3.0, 4.0]], attrs={"title": "b"}) + + diff = netcdf_diff(base_nc, new) + + assert sum(1 for line in diff.header if line.kind == "add") == 1 + assert sum(1 for line in diff.header if line.kind == "remove") == 1 + assert all(row.moved is False for row in diff.rows) + + def test_one_changed_value_is_counted_and_measured(self, tmp_path, base_nc): + new = _write_nc(tmp_path / "new.nc", [[1.0, 2.0], [3.0, 4.5]]) + + row = netcdf_diff(base_nc, new).rows[0] + + assert row.cells_differ == 1 + assert row.max_abs_diff == pytest.approx(0.5) + assert row.max_rel_diff == pytest.approx(0.125) + assert row.moved is True + + def test_a_changed_shape_cannot_be_compared_cell_by_cell(self, tmp_path, base_nc): + new = _write_nc(tmp_path / "new.nc", [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) + + row = netcdf_diff(base_nc, new).rows[0] + + assert row.max_abs_diff is None + assert row.cells_differ is None + assert row.shape_old == "2x2" + assert row.shape_new == "2x3" + assert row.moved is True + + def test_a_nan_in_the_same_cell_on_both_sides_is_not_a_change(self, tmp_path): + old = _write_nc(tmp_path / "old.nc", [[1.0, np.nan], [3.0, 4.0]]) + new = _write_nc(tmp_path / "new.nc", [[1.0, np.nan], [3.0, 4.0]]) + + row = netcdf_diff(old, new).rows[0] + + assert row.cells_differ == 0 + assert row.moved is False + assert row.nan_old == 1 + assert row.nan_new == 1 + + def test_an_all_nan_array_reports_no_statistics(self, tmp_path): + old = _write_nc(tmp_path / "old.nc", [[np.nan, np.nan], [np.nan, np.nan]]) + new = _write_nc(tmp_path / "new.nc", [[np.nan, np.nan], [np.nan, np.nan]]) + + row = netcdf_diff(old, new).rows[0] + + assert (row.min_old, row.max_old, row.mean_old) == (None, None, None) + assert row.nan_old == 4 + assert row.moved is False + + def test_an_absent_old_side_leaves_every_old_statistic_unset(self, tmp_path, base_nc): + row = netcdf_diff(None, base_nc).rows[0] + + assert (row.shape_old, row.min_old, row.max_old, row.mean_old, row.nan_old) == ( + None, + None, + None, + None, + None, + ) + assert row.shape_new == "2x2" + assert row.moved is True + + def test_a_file_that_is_not_netcdf_becomes_a_note(self, tmp_path, base_nc): + broken = tmp_path / "broken.nc" + broken.write_text("not a netcdf file at all") + + diff = netcdf_diff(base_nc, broken) + + assert diff.note.startswith("could not open") + assert diff.rows == () + + def test_a_string_variable_gets_shapes_but_no_statistics(self, tmp_path): + for path in (tmp_path / "old.nc", tmp_path / "new.nc"): + xr.Dataset({"label": (("i",), np.array(["a", "b"], dtype=object))}).to_netcdf(path) + + row = netcdf_diff(tmp_path / "old.nc", tmp_path / "new.nc").rows[0] + + assert row.shape_old == "2" + assert row.shape_new == "2" + assert (row.min_old, row.max_old, row.nan_old, row.cells_differ) == (None, None, None, None) + assert row.moved is False + + def test_a_variable_added_on_one_side_only_moves(self, tmp_path, base_nc): + new = tmp_path / "new.nc" + xr.Dataset( + { + "tas": (("lat", "lon"), np.array([[1.0, 2.0], [3.0, 4.0]])), + "pr": (("lat", "lon"), np.array([[0.0, 0.0], [0.0, 0.0]])), + }, + coords={"lat": np.arange(2, dtype=float), "lon": np.arange(2)}, + attrs={"title": "a"}, + ).to_netcdf(new) + + rows = {row.name: row for row in netcdf_diff(base_nc, new).rows} + + assert sorted(rows) == ["pr", "tas"] + assert rows["pr"].moved is True + assert rows["pr"].shape_old is None + assert rows["tas"].moved is False + + +class TestAnalyseNetcdf: + def test_a_netcdf_file_is_fetched_and_analysed(self, tmp_path, base_nc): + store = MagicMock(spec=NativeStore) + store.url = "https://store" + store.root = None + store.fetch.side_effect = lambda digest, dest: dest.write_bytes(base_nc.read_bytes()) + entry = NativeEntry(sha256="1" * 64, size=base_nc.stat().st_size) + report = _report([_file_change("out.nc", entry, evolve(entry, sha256="2" * 64))]) + + file = analyse(report, store, fetch=True, workdir=tmp_path).cases[0].files[0] + + assert file.netcdf.note is None + assert [row.name for row in file.netcdf.rows] == ["tas"] + assert file.text is None + + def test_an_oversized_netcdf_file_is_not_fetched(self, tmp_path): + store = MagicMock(spec=NativeStore) + store.url = "https://store" + store.root = None + entry = NativeEntry(sha256="1" * 64, size=NETCDF_FETCH_BYTES + 1) + report = _report([_file_change("out.nc", None, entry)]) + + file = analyse(report, store, fetch=True, workdir=tmp_path).cases[0].files[0] + + assert "too large to analyse" in file.netcdf.note + store.fetch.assert_not_called() + + def test_fetching_disabled_leaves_a_note(self, tmp_path): + store = MagicMock(spec=NativeStore) + store.url = "https://store" + store.root = None + report = _report([_file_change("out.nc", None, NativeEntry(sha256="1" * 64, size=10))]) + + file = analyse(report, store, fetch=False, workdir=tmp_path).cases[0].files[0] + + assert file.netcdf.note == "fetching disabled" diff --git a/packages/climate-ref/tests/unit/baseline_report/test_render.py b/packages/climate-ref/tests/unit/baseline_report/test_render.py index 359146c61..75969ff91 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_render.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_render.py @@ -6,7 +6,14 @@ import pytest from attrs import evolve -from climate_ref.baseline_report.analyse import AnalysedReport, DiffLine, TextDiff, analyse +from climate_ref.baseline_report.analyse import ( + AnalysedReport, + DiffLine, + NetcdfDiff, + StatRow, + TextDiff, + analyse, +) from climate_ref.baseline_report.collect import CaseChange, FileChange, FileKind, Report, classify from climate_ref.baseline_report.render import render_case, render_index, write_site from climate_ref_core.regression.manifest import SCHEMA_VERSION, Manifest, NativeEntry @@ -96,13 +103,24 @@ def _analysed(cases, tmp_path, diffs=None) -> AnalysedReport: ) if not diffs: return report + + def _replace(file): + """Swap in the fixture diff for a named file, leaving the rest as analysed.""" + found = diffs.get(file.change.name) + if found is None: + return file + if isinstance(found, NetcdfDiff): + return evolve(file, netcdf=found) + return evolve(file, text=found) + return evolve( report, cases=tuple( evolve( case, - files=tuple(evolve(file, text=diffs.get(file.change.name, file.text)) for file in case.files), - texts=tuple(evolve(file, text=diffs.get(file.change.name, file.text)) for file in case.texts), + files=tuple(_replace(file) for file in case.files), + texts=tuple(_replace(file) for file in case.texts), + netcdfs=tuple(_replace(file) for file in case.netcdfs), ) for case in report.cases ), @@ -297,3 +315,114 @@ def test_an_empty_report_still_writes_an_index(self, tmp_path): index = write_site(_analysed([], tmp_path), tmp_path / "out") assert index.exists() + + +def _stat_row(name, *, moved, **overrides): + """Build a stats row with every field set, so a template cannot pass on a missing one.""" + fields = dict( + shape_old="2x2", + shape_new="2x2", + min_old=1.0, + min_new=1.0, + max_old=4.0, + max_new=4.0, + mean_old=2.5, + mean_new=2.5, + nan_old=0, + nan_new=0, + max_abs_diff=0.0, + max_rel_diff=0.0, + cells_differ=0, + ) + fields.update(overrides) + return StatRow(name=name, moved=moved, **fields) + + +def _netcdf_case(tmp_path, diff, name="out.nc"): + """A report whose single case has one changed NetCDF file carrying ``diff``.""" + return _analysed( + [_case_change([_change(name, _entry("1"), _entry("2", 20))], base=_manifest(3), head=_manifest(4))], + tmp_path, + diffs={name: diff}, + ) + + +class TestNetcdfBlock: + def test_only_the_moved_row_is_shaded(self, tmp_path): + diff = NetcdfDiff( + header=(DiffLine(kind="add", text="+title: b"),), + rows=( + _stat_row("tas", moved=True, max_abs_diff=0.5, max_rel_diff=0.125, cells_differ=1), + _stat_row("pr", moved=False), + ), + note=None, + ) + report = _netcdf_case(tmp_path, diff) + + html = render_case(report, report.cases[0]) + + assert html.count('') == 1 + assert [attrs.get("class") for attrs in _tags(html, "table")] == ["stats"] + assert "0.5" in html + assert "0.125" in html + + def test_a_note_replaces_the_table(self, tmp_path): + report = _netcdf_case(tmp_path, NetcdfDiff(header=(), rows=(), note="could not open: boom")) + + html = render_case(report, report.cases[0]) + + assert "could not open: boom" in html + assert " 2x2" in html + assert "-" in html + + def test_the_page_carries_no_dash_that_is_not_ascii(self, tmp_path): + diff = NetcdfDiff(header=(), rows=(_stat_row("tas", moved=False),), note=None) + report = _netcdf_case(tmp_path, diff) + + html = render_case(report, report.cases[0]) + + assert "\u2013" not in html + assert "\u2014" not in html + + def test_a_wide_table_is_wrapped_so_it_can_scroll(self, tmp_path): + diff = NetcdfDiff(header=(), rows=(_stat_row("tas", moved=False),), note=None) + report = _netcdf_case(tmp_path, diff, name="a" * 120 + ".nc") + + html = render_case(report, report.cases[0]) + + assert '
' in html + assert "a" * 120 in html + + def test_matching_headers_say_so_rather_than_showing_an_empty_diff(self, tmp_path): + diff = NetcdfDiff(header=(), rows=(_stat_row("tas", moved=False),), note=None) + report = _netcdf_case(tmp_path, diff) + + html = render_case(report, report.cases[0]) + + assert "The headers match." in html + assert '
' not in html
diff --git a/uv.lock b/uv.lock
index 9cb558d48..203c3db09 100644
--- a/uv.lock
+++ b/uv.lock
@@ -665,6 +665,7 @@ dependencies = [
     { name = "tomlkit" },
     { name = "tqdm" },
     { name = "typer" },
+    { name = "xarray" },
 ]
 
 [package.optional-dependencies]
@@ -719,6 +720,7 @@ requires-dist = [
     { name = "tomlkit", specifier = ">=0.13.2" },
     { name = "tqdm", specifier = ">=4.67.1" },
     { name = "typer", specifier = ">=0.12.5" },
+    { name = "xarray", specifier = ">=2025.1" },
 ]
 provides-extras = ["postgres", "celery", "aft-providers", "providers", "test"]
 

From 175d734629444d5844813485a4d2cf63214689c6 Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 12:36:46 +1000
Subject: [PATCH 49/64] refactor: tighten the netcdf analysis after the cleanup
 reviews

- Adds `_variable`, so each side of a data variable is looked up once instead of
  once for its shape and again for its values.
- Extracts `_fetch_pair`, which held the same four lines in `_diff_for` and
  `_netcdf_for` including the note precedence rule.
- Drops the text-flavoured defaults on `_fetch_side`, because `limit` and `oversize`
  are a pair and a caller that passes one and forgets the other gets a wrong note.
- Passes the base side's scale into `_compare` from the reduction `_summarise` already
  ran, so a large variable is not scanned a third time.
- Replaces the three overlapping all-NaN guards in `_compare` with one, and drops the
  warning filter in `_summarise` that its size check had already made unreachable.
- Narrows `_of_kind` to one kind, which is all any call site passes now.
- Gives `AnalysedFile.netcdf` the same standing as `text`, with no default.
- Adds a `dash` filter so the template no longer decides how an absent shape reads.

No behaviour change. The `num` filter is annotated to admit the counts it already formats.
---
 .../climate_ref/baseline_report/analyse.py    | 163 +++++++++++-------
 .../src/climate_ref/baseline_report/render.py |  23 ++-
 .../baseline_report/templates/macros.html.j2  |   2 +-
 3 files changed, 121 insertions(+), 67 deletions(-)

diff --git a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
index 80ff1094b..25c42c086 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
@@ -9,7 +9,6 @@
 import difflib
 import io
 import json
-import warnings
 from collections import Counter
 from contextlib import ExitStack
 from itertools import islice
@@ -132,7 +131,7 @@ class NetcdfDiff:
 
 @frozen
 class AnalysedFile:
-    """One native file, with its blob URLs and its diff where it has one."""
+    """One native file, with its blob URLs and whatever its kind is analysed into."""
 
     change: FileChange
     """The underlying change."""
@@ -146,13 +145,13 @@ class AnalysedFile:
     text: TextDiff | None
     """The diff, set only for :attr:`~climate_ref.baseline_report.collect.FileKind.TEXT` files."""
 
-    size_delta: int | None
-    """Signed byte change, or ``None`` when the file exists on only one side."""
-
-    netcdf: NetcdfDiff | None = None
+    netcdf: NetcdfDiff | None
     """The header and stats diff, set only for
     :attr:`~climate_ref.baseline_report.collect.FileKind.NETCDF` files."""
 
+    size_delta: int | None
+    """Signed byte change, or ``None`` when the file exists on only one side."""
+
 
 @frozen
 class KindCounts:
@@ -377,9 +376,9 @@ def _header(dataset: xr.Dataset | None) -> list[str]:
     return buf.getvalue().splitlines()
 
 
-def _values(dataset: xr.Dataset | None, name: str) -> np.ndarray | None:
+def _variable(dataset: xr.Dataset | None, name: str) -> xr.DataArray | None:
     """
-    Read one data variable as a float array.
+    Look one data variable up on one side.
 
     Parameters
     ----------
@@ -391,26 +390,40 @@ def _values(dataset: xr.Dataset | None, name: str) -> np.ndarray | None:
     Returns
     -------
     :
-        The values as float64, or ``None`` when the variable is absent or not numeric.
+        The variable, or ``None`` when this side does not carry it.
     """
     if dataset is None or name not in dataset.data_vars:
         return None
-    values = dataset[name].values
-    if not np.issubdtype(values.dtype, np.number):
+    return dataset[name]
+
+
+def _values(variable: xr.DataArray | None) -> np.ndarray | None:
+    """
+    Read one variable as a float array.
+
+    Parameters
+    ----------
+    variable
+        The variable, or ``None`` when it is absent on that side.
+
+    Returns
+    -------
+    :
+        The values as float64, or ``None`` when the variable is absent or not numeric.
+    """
+    if variable is None or not np.issubdtype(variable.dtype, np.number):
         return None
-    return np.asarray(values, dtype=float)
+    return np.asarray(variable.values, dtype=float)
 
 
-def _shape(dataset: xr.Dataset | None, name: str) -> str | None:
+def _shape(variable: xr.DataArray | None) -> str | None:
     """
     Render one variable's shape.
 
     Parameters
     ----------
-    dataset
-        The open dataset, or ``None`` when the file is absent on that side.
-    name
-        The variable's name.
+    variable
+        The variable, or ``None`` when it is absent on that side.
 
     Returns
     -------
@@ -418,10 +431,9 @@ def _shape(dataset: xr.Dataset | None, name: str) -> str | None:
         For example ``180x360``, ``scalar`` for a zero-dimensional variable, or ``None``
         when the variable is absent.
     """
-    if dataset is None or name not in dataset.data_vars:
+    if variable is None:
         return None
-    shape = dataset[name].shape
-    return "x".join(str(size) for size in shape) if shape else "scalar"
+    return "x".join(str(size) for size in variable.shape) if variable.shape else "scalar"
 
 
 def _summarise(values: np.ndarray | None) -> tuple[float | None, float | None, float | None, int | None]:
@@ -444,17 +456,12 @@ def _summarise(values: np.ndarray | None) -> tuple[float | None, float | None, f
     nan_count = int(np.isnan(values).sum())
     if values.size in (0, nan_count):
         return None, None, None, nan_count
-    with warnings.catch_warnings():
-        warnings.simplefilter("ignore", RuntimeWarning)
-        return (
-            float(np.nanmin(values)),
-            float(np.nanmax(values)),
-            float(np.nanmean(values)),
-            nan_count,
-        )
+    return float(np.nanmin(values)), float(np.nanmax(values)), float(np.nanmean(values)), nan_count
 
 
-def _compare(old: np.ndarray | None, new: np.ndarray | None) -> tuple[float | None, float | None, int | None]:
+def _compare(
+    old: np.ndarray | None, new: np.ndarray | None, scale: float
+) -> tuple[float | None, float | None, int | None]:
     """
     Compare two sides of the same variable cell by cell.
 
@@ -466,27 +473,21 @@ def _compare(old: np.ndarray | None, new: np.ndarray | None) -> tuple[float | No
         The base side, or ``None`` when absent or not numeric.
     new
         The head side, or ``None`` when absent or not numeric.
+    scale
+        The base side's largest magnitude, which the relative difference is measured against.
 
     Returns
     -------
     :
-        The largest absolute difference, the same relative to the base side's largest
-        magnitude, and the number of cells that differ. All ``None`` when the sides cannot
-        be compared.
+        The largest absolute difference, the same relative to ``scale``, and the number of
+        cells that differ. All ``None`` when the sides cannot be compared.
     """
     if old is None or new is None or old.shape != new.shape:
         return None, None, None
     same = (old == new) | (np.isnan(old) & np.isnan(new))
     cells_differ = int(np.sum(~same))
-    with warnings.catch_warnings():
-        warnings.simplefilter("ignore", RuntimeWarning)
-        diff = np.abs(new - old)
-        max_abs = float(np.nanmax(diff)) if diff.size else 0.0
-        scale = float(np.nanmax(np.abs(old))) if old.size else 0.0
-    if np.isnan(max_abs):
-        max_abs = 0.0
-    if np.isnan(scale):
-        scale = 0.0
+    diff = np.abs(new - old)
+    max_abs = 0.0 if np.isnan(diff).all() else float(np.nanmax(diff))
     return max_abs, max_abs / max(scale, float(np.finfo(float).tiny)), cells_differ
 
 
@@ -508,13 +509,16 @@ def _stat_row(old: xr.Dataset | None, new: xr.Dataset | None, name: str) -> Stat
     :
         The row, with ``moved`` set when the shape or any cell changed.
     """
-    old_values = _values(old, name)
-    new_values = _values(new, name)
-    shape_old = _shape(old, name)
-    shape_new = _shape(new, name)
+    old_variable = _variable(old, name)
+    new_variable = _variable(new, name)
+    shape_old = _shape(old_variable)
+    shape_new = _shape(new_variable)
+    old_values = _values(old_variable)
+    new_values = _values(new_variable)
     min_old, max_old, mean_old, nan_old = _summarise(old_values)
     min_new, max_new, mean_new, nan_new = _summarise(new_values)
-    max_abs_diff, max_rel_diff, cells_differ = _compare(old_values, new_values)
+    scale = max(abs(min_old), abs(max_old)) if min_old is not None and max_old is not None else 0.0
+    max_abs_diff, max_rel_diff, cells_differ = _compare(old_values, new_values, scale)
     return StatRow(
         name=name,
         shape_old=shape_old,
@@ -589,8 +593,8 @@ def _fetch_side(
     entry: NativeEntry | None,
     workdir: Path,
     *,
-    limit: int = MAX_FETCH_BYTES,
-    oversize: str = "too large to diff",
+    limit: int,
+    oversize: str,
 ) -> tuple[Path | None, str | None]:
     """
     Fetch one side of a file.
@@ -629,6 +633,41 @@ def _fetch_side(
     return dest, None
 
 
+def _fetch_pair(
+    change: FileChange,
+    store: NativeStore,
+    workdir: Path,
+    *,
+    limit: int,
+    oversize: str,
+) -> tuple[Path | None, Path | None, str | None]:
+    """
+    Fetch both sides of a file.
+
+    Parameters
+    ----------
+    change
+        The file that moved.
+    store
+        The store to read blobs from.
+    workdir
+        Directory fetched blobs are written into.
+    limit
+        Largest blob worth downloading.
+    oversize
+        Note to return when a blob is past ``limit``.
+
+    Returns
+    -------
+    :
+        An ``(old, new, note)`` triple. ``note`` is the first failure of the two sides, and
+        the paths should be ignored once it is set.
+    """
+    old_path, old_note = _fetch_side(store, change.old, workdir, limit=limit, oversize=oversize)
+    new_path, new_note = _fetch_side(store, change.new, workdir, limit=limit, oversize=oversize)
+    return old_path, new_path, old_note or new_note
+
+
 def _diff_for(
     change: FileChange,
     store: NativeStore,
@@ -660,9 +699,9 @@ def _diff_for(
     if not fetch:
         return TextDiff(lines=(), note="fetching disabled", elided=0)
 
-    old_path, old_note = _fetch_side(store, change.old, workdir)
-    new_path, new_note = _fetch_side(store, change.new, workdir)
-    note = old_note or new_note
+    old_path, new_path, note = _fetch_pair(
+        change, store, workdir, limit=MAX_FETCH_BYTES, oversize="too large to diff"
+    )
     if note is not None:
         return TextDiff(lines=(), note=note, elided=0)
     return text_diff(old_path, new_path, change.name)
@@ -699,13 +738,9 @@ def _netcdf_for(
     if not fetch:
         return NetcdfDiff(header=(), rows=(), note="fetching disabled")
 
-    old_path, old_note = _fetch_side(
-        store, change.old, workdir, limit=NETCDF_FETCH_BYTES, oversize="too large to analyse"
+    old_path, new_path, note = _fetch_pair(
+        change, store, workdir, limit=NETCDF_FETCH_BYTES, oversize="too large to analyse"
     )
-    new_path, new_note = _fetch_side(
-        store, change.new, workdir, limit=NETCDF_FETCH_BYTES, oversize="too large to analyse"
-    )
-    note = old_note or new_note
     if note is not None:
         return NetcdfDiff(header=(), rows=(), note=note)
     return netcdf_diff(old_path, new_path)
@@ -719,7 +754,7 @@ def _analyse_file(
     workdir: Path,
 ) -> AnalysedFile:
     """
-    Build the URLs and, for text, the diff of one native file.
+    Build the URLs and, for text and NetCDF, the analysis of one native file.
 
     Parameters
     ----------
@@ -742,28 +777,28 @@ def _analyse_file(
         old_url=blob_url(store, change.old.sha256) if change.old else None,
         new_url=blob_url(store, change.new.sha256) if change.new else None,
         text=_diff_for(change, store, fetch=fetch, workdir=workdir),
-        size_delta=change.new.size - change.old.size if change.old and change.new else None,
         netcdf=_netcdf_for(change, store, fetch=fetch, workdir=workdir),
+        size_delta=change.new.size - change.old.size if change.old and change.new else None,
     )
 
 
-def _of_kind(files: tuple[AnalysedFile, ...], *kinds: FileKind) -> tuple[AnalysedFile, ...]:
+def _of_kind(files: tuple[AnalysedFile, ...], kind: FileKind) -> tuple[AnalysedFile, ...]:
     """
-    Select the files of the given kinds, keeping their order.
+    Select the files of one kind, keeping their order.
 
     Parameters
     ----------
     files
         The analysed files.
-    kinds
-        The kinds to keep.
+    kind
+        The kind to keep.
 
     Returns
     -------
     :
         The matching files.
     """
-    return tuple(file for file in files if file.change.kind in kinds)
+    return tuple(file for file in files if file.change.kind is kind)
 
 
 def _counts(files: tuple[AnalysedFile, ...]) -> tuple[KindCounts, ...]:
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/render.py b/packages/climate-ref/src/climate_ref/baseline_report/render.py
index 645068d9a..d67414962 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/render.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/render.py
@@ -56,14 +56,15 @@ def _format_signed(delta: int | None) -> str:
     return f"{delta:+,}"
 
 
-def _format_num(value: float | None) -> str:
+def _format_num(value: float | int | None) -> str:
     """
     Render a statistic.
 
     Parameters
     ----------
     value
-        The number, or ``None`` when it could not be computed.
+        The number, or ``None`` when it could not be computed. Counts arrive as ``int`` and
+        measurements as ``float``, and each reads better in its own format.
 
     Returns
     -------
@@ -77,6 +78,23 @@ def _format_num(value: float | None) -> str:
     return f"{value:.6g}"
 
 
+def _format_dash(value: str | None) -> str:
+    """
+    Render a string that may be absent.
+
+    Parameters
+    ----------
+    value
+        The string, or ``None``.
+
+    Returns
+    -------
+    :
+        The string, or ``-`` when there is not one.
+    """
+    return "-" if value is None else value
+
+
 def _format_short(digest: str | None) -> str:
     """
     Render the readable prefix of a digest.
@@ -114,6 +132,7 @@ def _build_env() -> Environment:
     env.filters["bytes"] = _format_bytes
     env.filters["signed"] = _format_signed
     env.filters["num"] = _format_num
+    env.filters["dash"] = _format_dash
     env.filters["short"] = _format_short
     return env
 
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2
index 5d747afdb..09d4e1962 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2
+++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2
@@ -118,7 +118,7 @@ was {{ file.change.old.size | bytes -}}
 {% for row in file.netcdf.rows %}
 
 {{ row.name }}
-{{ row.shape_old or "-" }} -> {{ row.shape_new or "-" }}
+{{ row.shape_old | dash }} -> {{ row.shape_new | dash }}
 {{ stat_pair(row.min_old, row.min_new) }}
 {{ stat_pair(row.max_old, row.max_new) }}
 {{ stat_pair(row.mean_old, row.mean_new) }}

From f2c9f88a05f966d153a2b9a8facac381ab097cd0 Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 12:38:53 +1000
Subject: [PATCH 50/64] chore: rename the changelog fragment to the PR number

---
 changelog/{912.feature.md => 913.feature.md} | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename changelog/{912.feature.md => 913.feature.md} (100%)

diff --git a/changelog/912.feature.md b/changelog/913.feature.md
similarity index 100%
rename from changelog/912.feature.md
rename to changelog/913.feature.md

From 9efb9fae2a782c4c8fee028b0570128b29c9e1a3 Mon Sep 17 00:00:00 2001
From: Jared Lewis 
Date: Fri, 4 Sep 2026 12:52:43 +1000
Subject: [PATCH 51/64] feat: show the whole netcdf header, with a side by side
 view

The header was a unified diff, so a reader saw three lines of context around each hunk and
nothing else. That names what moved but does not say what the file holds, which is the thing
you want when reviewing a baseline you have not seen before. A file whose header did not move
showed no header at all.

- The header listing now keeps every line and tags the ones that moved, so it doubles as the
  file's description. The `---`, `+++` and `@@` markers are gone with it.
- Adds a Diff and Side by side toggle, so the two headers can be read one next to the other.
- `NetcdfDiff` carries `header_old` and `header_new` for that second view, and
  `header_changed` says whether anything moved.
- An unchanged header is marked as such on the summary rather than replaced by a note.
---
 changelog/913.feature.md                      |  8 +-
 .../climate_ref/baseline_report/analyse.py    | 76 ++++++++++++++++---
 .../baseline_report/templates/macros.html.j2  | 33 ++++++--
 .../baseline_report/templates/report.css      | 43 +++++++++++
 .../baseline_report/templates/report.js       | 22 ++++++
 .../unit/baseline_report/test_analyse.py      | 15 +++-
 .../tests/unit/baseline_report/test_render.py | 62 +++++++++++----
 7 files changed, 224 insertions(+), 35 deletions(-)

diff --git a/changelog/913.feature.md b/changelog/913.feature.md
index ea6965975..5226a38f4 100644
--- a/changelog/913.feature.md
+++ b/changelog/913.feature.md
@@ -1,7 +1,9 @@
 A changed `.nc` file in the `ref test-cases diff` report now shows what moved inside it.
-Each file gets a collapsible diff of its ncdump-style header
-and a table with one row per data variable carrying min, max, mean, NaN count,
-the largest absolute and relative difference, and the number of cells that differ.
+Each file gets a collapsible listing of its ncdump-style header and a table with one row per
+data variable carrying min, max, mean, NaN count, the largest absolute and relative difference,
+and the number of cells that differ.
+The header listing keeps every line and marks the ones that moved, so it doubles as a
+description of the file, and a toggle reads the two sides next to each other instead.
 NaN counts as equal to NaN, so a masked cell staying masked is not a change.
 Rows where something moved are shaded, so the eye lands on them.
 Wide tables and long file names now stay inside their card.
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
index 25c42c086..1e7513d8b 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
+++ b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py
@@ -120,7 +120,18 @@ class NetcdfDiff:
     """What changed inside one NetCDF file, or the reason nothing could be read."""
 
     header: tuple[DiffLine, ...]
-    """Unified diff of the two ncdump-style headers, empty when they match."""
+    """
+    The two ncdump-style headers merged into one tagged listing.
+
+    Every line is kept rather than only the changed hunks, because the header is what tells a
+    reader what the file holds.
+    """
+
+    header_old: tuple[str, ...]
+    """The base ref's header, for reading one side on its own."""
+
+    header_new: tuple[str, ...]
+    """HEAD's header, for reading one side on its own."""
 
     rows: tuple[StatRow, ...]
     """One row per data variable, in name order."""
@@ -128,6 +139,18 @@ class NetcdfDiff:
     note: str | None
     """Why the file could not be analysed, or ``None`` when it was."""
 
+    @property
+    def header_changed(self) -> bool:
+        """
+        Whether the headers differ.
+
+        Returns
+        -------
+        :
+            ``True`` when any header line was added or removed.
+        """
+        return any(line.kind != "context" for line in self.header)
+
 
 @frozen
 class AnalysedFile:
@@ -376,6 +399,34 @@ def _header(dataset: xr.Dataset | None) -> list[str]:
     return buf.getvalue().splitlines()
 
 
+def _header_diff(old_lines: list[str], new_lines: list[str]) -> tuple[DiffLine, ...]:
+    """
+    Merge two headers into one listing, tagging each line.
+
+    Unlike a unified diff this keeps every line, so the listing doubles as the file's
+    description rather than only naming what moved.
+
+    Parameters
+    ----------
+    old_lines
+        The base side's header, empty when that side is absent.
+    new_lines
+        The head side's header, empty when that side is absent.
+
+    Returns
+    -------
+    :
+        Every line, in reading order, tagged ``context``, ``add`` or ``remove``. The two
+        character marker is kept on the text so the tags survive without colour.
+    """
+    kinds = {" ": "context", "-": "remove", "+": "add"}
+    return tuple(
+        DiffLine(kind=kinds[line[0]], text=line)
+        for line in difflib.Differ().compare(old_lines, new_lines)
+        if line[0] in kinds
+    )
+
+
 def _variable(dataset: xr.Dataset | None, name: str) -> xr.DataArray | None:
     """
     Look one data variable up on one side.
@@ -568,12 +619,9 @@ def netcdf_diff(old: Path | None, new: Path | None) -> NetcdfDiff:
                 if new is not None
                 else None
             )
-            header, _ = _diff_lines(
-                _header(old_ds),
-                _header(new_ds),
-                fromfile="old" if old_ds is not None else "(absent)",
-                tofile="new" if new_ds is not None else "(absent)",
-            )
+            header_old = _header(old_ds)
+            header_new = _header(new_ds)
+            header = _header_diff(header_old, header_new)
             names = sorted(
                 {
                     str(name)
@@ -584,8 +632,14 @@ def netcdf_diff(old: Path | None, new: Path | None) -> NetcdfDiff:
             )
             rows = tuple(_stat_row(old_ds, new_ds, name) for name in names)
     except (OSError, ValueError, KeyError) as exc:
-        return NetcdfDiff(header=(), rows=(), note=f"could not open: {exc}")
-    return NetcdfDiff(header=header, rows=rows, note=None)
+        return NetcdfDiff(header=(), header_old=(), header_new=(), rows=(), note=f"could not open: {exc}")
+    return NetcdfDiff(
+        header=header,
+        header_old=tuple(header_old),
+        header_new=tuple(header_new),
+        rows=rows,
+        note=None,
+    )
 
 
 def _fetch_side(
@@ -736,13 +790,13 @@ def _netcdf_for(
     if change.kind is not FileKind.NETCDF:
         return None
     if not fetch:
-        return NetcdfDiff(header=(), rows=(), note="fetching disabled")
+        return NetcdfDiff(header=(), header_old=(), header_new=(), rows=(), note="fetching disabled")
 
     old_path, new_path, note = _fetch_pair(
         change, store, workdir, limit=NETCDF_FETCH_BYTES, oversize="too large to analyse"
     )
     if note is not None:
-        return NetcdfDiff(header=(), rows=(), note=note)
+        return NetcdfDiff(header=(), header_old=(), header_new=(), rows=(), note=note)
     return netcdf_diff(old_path, new_path)
 
 
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2
index 09d4e1962..65c16388d 100644
--- a/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2
+++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2
@@ -82,6 +82,21 @@ was {{ file.change.old.size | bytes -}}
 
 {% endmacro %}
 
+{% macro header_side(label, lines) %}
+
+{{ label }} +{% if lines %} +
+{%- for line in lines %}
+{{ line }}
+{%- endfor %}
+
+{% else %} +
absent
+{% endif %} +
+{% endmacro %} + {% macro stat_pair(old, new) -%} {{ old | num }} -> {{ new | num }} {%- endmacro %} @@ -98,12 +113,20 @@ was {{ file.change.old.size | bytes -}}

{{ file.netcdf.note }}

{% else %}
-Header -{% if file.netcdf.header %} +Header{% if not file.netcdf.header_changed %} (unchanged){% endif %} +
+
+ + +
+
{{ diff_lines(file.netcdf.header) }} -{% else %} -

The headers match.

-{% endif %} +
+ +
{% if file.netcdf.rows %}
diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css index c56950b2d..d3f436499 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css @@ -148,6 +148,11 @@ summary { white-space: pre-wrap; } +/* A blank header line has nothing to give the block height, so keep the gap. */ +.diff span:empty::after { + content: "\00a0"; +} + .diff .add { background: var(--add-bg); color: var(--add-fg); } .diff .remove { background: var(--remove-bg); color: var(--remove-fg); } .diff .hunk, .diff .header { color: var(--muted); } @@ -186,3 +191,41 @@ tr.moved { .binaries td:first-child { max-width: 40rem; } + +.header .views { + display: flex; + gap: 0.25rem; + margin-bottom: 0.5rem; +} + +.header .views button { + background: var(--bg); + border: 1px solid var(--rule); + border-radius: 4px; + color: var(--muted); + cursor: pointer; + font-size: 0.85em; + padding: 0.15rem 0.6rem; +} + +.header .views button[aria-pressed="true"] { + background: var(--panel); + color: var(--fg); + font-weight: 600; +} + +.header .split:not([hidden]) { + display: grid; + gap: 0.75rem; + grid-template-columns: 1fr 1fr; +} + +/* Each side scrolls on its own so a long line cannot widen the other column. */ +.header .split .diff { + max-height: 30rem; + overflow: auto; +} + +.header .split .diff span { + white-space: pre; +} diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.js b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.js index a78703aca..864d78bed 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.js +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.js @@ -13,3 +13,25 @@ document.addEventListener("click", function (event) { pair.classList.toggle("flipped"); } }); + +document.addEventListener("click", function (event) { + var target = event.target; + if (!(target instanceof Element)) { + return; + } + var button = target.closest("[data-header-view]"); + if (button === null) { + return; + } + var wanted = button.getAttribute("data-header-view"); + var header = button.closest("[data-header]"); + if (header === null) { + return; + } + header.querySelectorAll("[data-view]").forEach(function (view) { + view.hidden = view.getAttribute("data-view") !== wanted; + }); + header.querySelectorAll("[data-header-view]").forEach(function (other) { + other.setAttribute("aria-pressed", String(other.getAttribute("data-header-view") === wanted)); + }); +}); diff --git a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py index 4db2aa96c..7bbe43a5d 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py @@ -295,13 +295,15 @@ def base_nc(tmp_path): class TestNetcdfDiff: - def test_identical_files_have_no_header_diff_and_no_moved_rows(self, tmp_path, base_nc): + def test_identical_files_show_the_whole_header_as_context(self, tmp_path, base_nc): new = _write_nc(tmp_path / "new.nc", [[1.0, 2.0], [3.0, 4.0]]) diff = netcdf_diff(base_nc, new) assert diff.note is None - assert diff.header == () + assert diff.header_changed is False + assert {line.kind for line in diff.header} == {"context"} + assert len(diff.header) == len(diff.header_old) == len(diff.header_new) assert diff.rows[0].moved is False assert diff.rows[0].cells_differ == 0 assert diff.rows[0].max_abs_diff == 0.0 @@ -313,6 +315,8 @@ def test_a_changed_attribute_shows_in_the_header_only(self, tmp_path, base_nc): assert sum(1 for line in diff.header if line.kind == "add") == 1 assert sum(1 for line in diff.header if line.kind == "remove") == 1 + assert diff.header_changed is True + assert any(line.kind == "context" for line in diff.header) # the rest is still shown assert all(row.moved is False for row in diff.rows) def test_one_changed_value_is_counted_and_measured(self, tmp_path, base_nc): @@ -358,7 +362,11 @@ def test_an_all_nan_array_reports_no_statistics(self, tmp_path): assert row.moved is False def test_an_absent_old_side_leaves_every_old_statistic_unset(self, tmp_path, base_nc): - row = netcdf_diff(None, base_nc).rows[0] + diff = netcdf_diff(None, base_nc) + row = diff.rows[0] + + assert diff.header_old == () + assert diff.header_new assert (row.shape_old, row.min_old, row.max_old, row.mean_old, row.nan_old) == ( None, @@ -378,6 +386,7 @@ def test_a_file_that_is_not_netcdf_becomes_a_note(self, tmp_path, base_nc): assert diff.note.startswith("could not open") assert diff.rows == () + assert diff.header_old == () def test_a_string_variable_gets_shapes_but_no_statistics(self, tmp_path): for path in (tmp_path / "old.nc", tmp_path / "new.nc"): diff --git a/packages/climate-ref/tests/unit/baseline_report/test_render.py b/packages/climate-ref/tests/unit/baseline_report/test_render.py index 75969ff91..da269dd3a 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_render.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_render.py @@ -338,6 +338,13 @@ def _stat_row(name, *, moved, **overrides): return StatRow(name=name, moved=moved, **fields) +def _netcdf_diff( + rows, header=(), header_old=("xarray.Dataset {",), header_new=("xarray.Dataset {",), note=None +): + """Build a NetCDF analysis with the header fields filled in.""" + return NetcdfDiff(header=header, header_old=header_old, header_new=header_new, rows=rows, note=note) + + def _netcdf_case(tmp_path, diff, name="out.nc"): """A report whose single case has one changed NetCDF file carrying ``diff``.""" return _analysed( @@ -349,13 +356,12 @@ def _netcdf_case(tmp_path, diff, name="out.nc"): class TestNetcdfBlock: def test_only_the_moved_row_is_shaded(self, tmp_path): - diff = NetcdfDiff( - header=(DiffLine(kind="add", text="+title: b"),), + diff = _netcdf_diff( + header=(DiffLine(kind="add", text="+ title: b"),), rows=( _stat_row("tas", moved=True, max_abs_diff=0.5, max_rel_diff=0.125, cells_differ=1), _stat_row("pr", moved=False), ), - note=None, ) report = _netcdf_case(tmp_path, diff) @@ -367,7 +373,7 @@ def test_only_the_moved_row_is_shaded(self, tmp_path): assert "0.125" in html def test_a_note_replaces_the_table(self, tmp_path): - report = _netcdf_case(tmp_path, NetcdfDiff(header=(), rows=(), note="could not open: boom")) + report = _netcdf_case(tmp_path, _netcdf_diff(rows=(), note="could not open: boom")) html = render_case(report, report.cases[0]) @@ -375,8 +381,7 @@ def test_a_note_replaces_the_table(self, tmp_path): assert "-" in html def test_the_page_carries_no_dash_that_is_not_ascii(self, tmp_path): - diff = NetcdfDiff(header=(), rows=(_stat_row("tas", moved=False),), note=None) + diff = _netcdf_diff(rows=(_stat_row("tas", moved=False),)) report = _netcdf_case(tmp_path, diff) html = render_case(report, report.cases[0]) @@ -410,7 +414,7 @@ def test_the_page_carries_no_dash_that_is_not_ascii(self, tmp_path): assert "\u2014" not in html def test_a_wide_table_is_wrapped_so_it_can_scroll(self, tmp_path): - diff = NetcdfDiff(header=(), rows=(_stat_row("tas", moved=False),), note=None) + diff = _netcdf_diff(rows=(_stat_row("tas", moved=False),)) report = _netcdf_case(tmp_path, diff, name="a" * 120 + ".nc") html = render_case(report, report.cases[0]) @@ -418,11 +422,43 @@ def test_a_wide_table_is_wrapped_so_it_can_scroll(self, tmp_path): assert '
' in html assert "a" * 120 in html - def test_matching_headers_say_so_rather_than_showing_an_empty_diff(self, tmp_path): - diff = NetcdfDiff(header=(), rows=(_stat_row("tas", moved=False),), note=None) + def test_matching_headers_are_marked_unchanged_but_still_shown(self, tmp_path): + diff = _netcdf_diff( + header=(DiffLine(kind="context", text=" xarray.Dataset {"),), + rows=(_stat_row("tas", moved=False),), + ) report = _netcdf_case(tmp_path, diff) html = render_case(report, report.cases[0]) - assert "The headers match." in html - assert '
' not in html
+        assert "(unchanged)" in html
+        assert "xarray.Dataset {" in html
+
+    def test_both_headers_are_available_side_by_side(self, tmp_path):
+        diff = _netcdf_diff(
+            header=(DiffLine(kind="remove", text="-   lat = 90 ;"),),
+            header_old=("dimensions:", "    lat = 90 ;"),
+            header_new=("dimensions:", "    lat = 45 ;"),
+            rows=(_stat_row("tas", moved=True, cells_differ=1),),
+        )
+        report = _netcdf_case(tmp_path, diff)
+
+        html = render_case(report, report.cases[0])
+
+        assert 'data-header-view="split"' in html
+        assert "lat = 90 ;" in html
+        assert "lat = 45 ;" in html
+        assert html.count('data-view="diff"') == 1
+
+    def test_an_absent_side_says_so_in_the_split_view(self, tmp_path):
+        diff = _netcdf_diff(
+            header=(DiffLine(kind="add", text="+ dimensions:"),),
+            header_old=(),
+            header_new=("dimensions:",),
+            rows=(_stat_row("tas", moved=True, shape_old=None),),
+        )
+        report = _netcdf_case(tmp_path, diff)
+
+        html = render_case(report, report.cases[0])
+
+        assert '
absent
' in html From 6be0634cad60bdcb67fa566d8eb20f2e2b2e7a45 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 13:06:16 +1000 Subject: [PATCH 52/64] feat: emphasise the netcdf values that actually moved A shaded row said something in the variable changed but not what. Scanning a row of nine old to new pairs to find the one that shifted is work the report can do for the reader. - Adds `Pair`, which holds one statistic on each side and answers whether it moved. - `StatRow` now carries `shape`, `minimum`, `maximum`, `mean` and `nan` as pairs, which drops it from fifteen fields to ten. - The new half of a pair is bold when it differs, and the three diff columns are bold when any cell moved. - A value can be bold while reading the same as its neighbour, because the display rounds to six significant figures and the underlying floats still differ. Also carries an edit to the changelog fragment made outside these changes. --- changelog/913.feature.md | 7 +- .../climate_ref/baseline_report/analyse.py | 89 +++++++++++-------- .../baseline_report/templates/macros.html.j2 | 24 ++--- .../baseline_report/templates/report.css | 5 ++ .../unit/baseline_report/test_analyse.py | 35 +++++--- .../tests/unit/baseline_report/test_render.py | 59 ++++++++---- 6 files changed, 137 insertions(+), 82 deletions(-) diff --git a/changelog/913.feature.md b/changelog/913.feature.md index 5226a38f4..26ba72784 100644 --- a/changelog/913.feature.md +++ b/changelog/913.feature.md @@ -1,9 +1,4 @@ -A changed `.nc` file in the `ref test-cases diff` report now shows what moved inside it. +Summarise the changes to `.nc` files in the `ref test-cases diff` report. Each file gets a collapsible listing of its ncdump-style header and a table with one row per data variable carrying min, max, mean, NaN count, the largest absolute and relative difference, and the number of cells that differ. -The header listing keeps every line and marks the ones that moved, so it doubles as a -description of the file, and a toggle reads the two sides next to each other instead. -NaN counts as equal to NaN, so a masked cell staying masked is not a change. -Rows where something moved are shaded, so the eye lands on them. -Wide tables and long file names now stay inside their card. diff --git a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py index 1e7513d8b..7fa9a7b59 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py +++ b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py @@ -32,8 +32,7 @@ # Digest prefix shown wherever a blob is named. Long enough to identify it, short enough to read. SHORT_DIGEST = 12 -# A NetCDF blob larger than this is left unopened. Well above the largest baseline file, -# because opening one is cheap next to downloading it. +# A NetCDF blob larger than this is left unopened. NETCDF_FETCH_BYTES = 100_000_000 # Unified-diff lines kept per file before the rest is elided. @@ -66,41 +65,49 @@ class TextDiff: @frozen -class StatRow: - """Whole-array statistics for one data variable, on each side of the change.""" +class Pair[T]: + """One statistic on each side of the change.""" - name: str - """The variable's name.""" + old: T | None + """The value on the base ref, or ``None`` when it could not be computed.""" + + new: T | None + """The value on HEAD, or ``None`` when it could not be computed.""" - shape_old: str | None - """Dimensions on the base ref, as ``180x360``, or ``None`` when the variable is absent.""" + @property + def changed(self) -> bool: + """ + Whether the two sides differ. - shape_new: str | None - """Dimensions on HEAD, as ``180x360``, or ``None`` when the variable is absent.""" + Returns + ------- + : + ``True`` when the value moved, which is what emphasises it in the table. + """ + return self.old != self.new - min_old: float | None - """Minimum on the base ref, ignoring NaN. ``None`` when unavailable.""" - min_new: float | None - """Minimum on HEAD, ignoring NaN. ``None`` when unavailable.""" +@frozen +class StatRow: + """Whole-array statistics for one data variable, on each side of the change.""" - max_old: float | None - """Maximum on the base ref, ignoring NaN. ``None`` when unavailable.""" + name: str + """The variable's name.""" - max_new: float | None - """Maximum on HEAD, ignoring NaN. ``None`` when unavailable.""" + shape: Pair[str] + """Dimensions on each side, as ``180x360``, or ``scalar``.""" - mean_old: float | None - """Mean on the base ref, ignoring NaN. ``None`` when unavailable.""" + minimum: Pair[float] + """Minimum on each side, ignoring NaN.""" - mean_new: float | None - """Mean on HEAD, ignoring NaN. ``None`` when unavailable.""" + maximum: Pair[float] + """Maximum on each side, ignoring NaN.""" - nan_old: int | None - """NaN cells on the base ref, or ``None`` when the variable is absent or not numeric.""" + mean: Pair[float] + """Mean on each side, ignoring NaN.""" - nan_new: int | None - """NaN cells on HEAD, or ``None`` when the variable is absent or not numeric.""" + nan: Pair[int] + """NaN cells on each side, ``None`` when the variable is absent or not numeric.""" max_abs_diff: float | None """Largest absolute change, or ``None`` when the shapes differ or a side is absent.""" @@ -114,6 +121,18 @@ class StatRow: moved: bool """Whether anything about this variable changed, which is what shades its row.""" + @property + def differs(self) -> bool: + """ + Whether the cell by cell comparison found a change. + + Returns + ------- + : + ``True`` when at least one cell moved, which is what emphasises the diff columns. + """ + return bool(self.cells_differ) + @frozen class NetcdfDiff: @@ -570,22 +589,18 @@ def _stat_row(old: xr.Dataset | None, new: xr.Dataset | None, name: str) -> Stat min_new, max_new, mean_new, nan_new = _summarise(new_values) scale = max(abs(min_old), abs(max_old)) if min_old is not None and max_old is not None else 0.0 max_abs_diff, max_rel_diff, cells_differ = _compare(old_values, new_values, scale) + shape = Pair(old=shape_old, new=shape_new) return StatRow( name=name, - shape_old=shape_old, - shape_new=shape_new, - min_old=min_old, - min_new=min_new, - max_old=max_old, - max_new=max_new, - mean_old=mean_old, - mean_new=mean_new, - nan_old=nan_old, - nan_new=nan_new, + shape=shape, + minimum=Pair(old=min_old, new=min_new), + maximum=Pair(old=max_old, new=max_new), + mean=Pair(old=mean_old, new=mean_new), + nan=Pair(old=nan_old, new=nan_new), max_abs_diff=max_abs_diff, max_rel_diff=max_rel_diff, cells_differ=cells_differ, - moved=(cells_differ or 0) > 0 or shape_old != shape_new, + moved=(cells_differ or 0) > 0 or shape.changed, ) diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 index 65c16388d..a962df093 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/macros.html.j2 @@ -97,8 +97,12 @@ was {{ file.change.old.size | bytes -}}
{% endmacro %} -{% macro stat_pair(old, new) -%} -{{ old | num }} -> {{ new | num }} +{% macro emphasise(value, changed) -%} +{% if changed %}{{ value }}{% else %}{{ value }}{% endif %} +{%- endmacro %} + +{% macro pair_cell(old, new, changed) -%} +{{ old }} -> {{ emphasise(new, changed) }} {%- endmacro %} {% macro netcdf_block(file) %} @@ -141,14 +145,14 @@ was {{ file.change.old.size | bytes -}} {% for row in file.netcdf.rows %} {{ row.name }} -{{ row.shape_old | dash }} -> {{ row.shape_new | dash }} -{{ stat_pair(row.min_old, row.min_new) }} -{{ stat_pair(row.max_old, row.max_new) }} -{{ stat_pair(row.mean_old, row.mean_new) }} -{{ stat_pair(row.nan_old, row.nan_new) }} -{{ row.max_abs_diff | num }} -{{ row.max_rel_diff | num }} -{{ row.cells_differ | num }} +{{ pair_cell(row.shape.old | dash, row.shape.new | dash, row.shape.changed) }} +{{ pair_cell(row.minimum.old | num, row.minimum.new | num, row.minimum.changed) }} +{{ pair_cell(row.maximum.old | num, row.maximum.new | num, row.maximum.changed) }} +{{ pair_cell(row.mean.old | num, row.mean.new | num, row.mean.changed) }} +{{ pair_cell(row.nan.old | num, row.nan.new | num, row.nan.changed) }} +{{ emphasise(row.max_abs_diff | num, row.differs) }} +{{ emphasise(row.max_rel_diff | num, row.differs) }} +{{ emphasise(row.cells_differ | num, row.differs) }} {% endfor %} diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css index d3f436499..929d37658 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/report.css @@ -229,3 +229,8 @@ tr.moved { .header .split .diff span { white-space: pre; } + +/* The moved half of a pair, so a column can be scanned for what actually shifted. */ +.stats strong { + font-weight: 600; +} diff --git a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py index 7bbe43a5d..aa0edd5c3 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py @@ -328,6 +328,9 @@ def test_one_changed_value_is_counted_and_measured(self, tmp_path, base_nc): assert row.max_abs_diff == pytest.approx(0.5) assert row.max_rel_diff == pytest.approx(0.125) assert row.moved is True + assert row.differs is True + assert row.maximum.changed is True # 4.0 -> 4.5 + assert row.minimum.changed is False def test_a_changed_shape_cannot_be_compared_cell_by_cell(self, tmp_path, base_nc): new = _write_nc(tmp_path / "new.nc", [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) @@ -336,8 +339,9 @@ def test_a_changed_shape_cannot_be_compared_cell_by_cell(self, tmp_path, base_nc assert row.max_abs_diff is None assert row.cells_differ is None - assert row.shape_old == "2x2" - assert row.shape_new == "2x3" + assert row.shape.old == "2x2" + assert row.shape.new == "2x3" + assert row.shape.changed is True assert row.moved is True def test_a_nan_in_the_same_cell_on_both_sides_is_not_a_change(self, tmp_path): @@ -348,8 +352,9 @@ def test_a_nan_in_the_same_cell_on_both_sides_is_not_a_change(self, tmp_path): assert row.cells_differ == 0 assert row.moved is False - assert row.nan_old == 1 - assert row.nan_new == 1 + assert row.nan.old == 1 + assert row.nan.new == 1 + assert row.nan.changed is False def test_an_all_nan_array_reports_no_statistics(self, tmp_path): old = _write_nc(tmp_path / "old.nc", [[np.nan, np.nan], [np.nan, np.nan]]) @@ -357,8 +362,8 @@ def test_an_all_nan_array_reports_no_statistics(self, tmp_path): row = netcdf_diff(old, new).rows[0] - assert (row.min_old, row.max_old, row.mean_old) == (None, None, None) - assert row.nan_old == 4 + assert (row.minimum.old, row.maximum.old, row.mean.old) == (None, None, None) + assert row.nan.old == 4 assert row.moved is False def test_an_absent_old_side_leaves_every_old_statistic_unset(self, tmp_path, base_nc): @@ -368,14 +373,15 @@ def test_an_absent_old_side_leaves_every_old_statistic_unset(self, tmp_path, bas assert diff.header_old == () assert diff.header_new - assert (row.shape_old, row.min_old, row.max_old, row.mean_old, row.nan_old) == ( + assert (row.shape.old, row.minimum.old, row.maximum.old, row.mean.old, row.nan.old) == ( None, None, None, None, None, ) - assert row.shape_new == "2x2" + assert row.shape.new == "2x2" + assert row.shape.changed is True assert row.moved is True def test_a_file_that_is_not_netcdf_becomes_a_note(self, tmp_path, base_nc): @@ -394,9 +400,14 @@ def test_a_string_variable_gets_shapes_but_no_statistics(self, tmp_path): row = netcdf_diff(tmp_path / "old.nc", tmp_path / "new.nc").rows[0] - assert row.shape_old == "2" - assert row.shape_new == "2" - assert (row.min_old, row.max_old, row.nan_old, row.cells_differ) == (None, None, None, None) + assert row.shape.old == "2" + assert row.shape.new == "2" + assert (row.minimum.old, row.maximum.old, row.nan.old, row.cells_differ) == ( + None, + None, + None, + None, + ) assert row.moved is False def test_a_variable_added_on_one_side_only_moves(self, tmp_path, base_nc): @@ -414,7 +425,7 @@ def test_a_variable_added_on_one_side_only_moves(self, tmp_path, base_nc): assert sorted(rows) == ["pr", "tas"] assert rows["pr"].moved is True - assert rows["pr"].shape_old is None + assert rows["pr"].shape.old is None assert rows["tas"].moved is False diff --git a/packages/climate-ref/tests/unit/baseline_report/test_render.py b/packages/climate-ref/tests/unit/baseline_report/test_render.py index da269dd3a..847ac5858 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_render.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_render.py @@ -10,6 +10,7 @@ AnalysedReport, DiffLine, NetcdfDiff, + Pair, StatRow, TextDiff, analyse, @@ -320,16 +321,11 @@ def test_an_empty_report_still_writes_an_index(self, tmp_path): def _stat_row(name, *, moved, **overrides): """Build a stats row with every field set, so a template cannot pass on a missing one.""" fields = dict( - shape_old="2x2", - shape_new="2x2", - min_old=1.0, - min_new=1.0, - max_old=4.0, - max_new=4.0, - mean_old=2.5, - mean_new=2.5, - nan_old=0, - nan_new=0, + shape=Pair("2x2", "2x2"), + minimum=Pair(1.0, 1.0), + maximum=Pair(4.0, 4.0), + mean=Pair(2.5, 2.5), + nan=Pair(0, 0), max_abs_diff=0.0, max_rel_diff=0.0, cells_differ=0, @@ -372,6 +368,35 @@ def test_only_the_moved_row_is_shaded(self, tmp_path): assert "0.5" in html assert "0.125" in html + def test_only_the_values_that_moved_are_emphasised(self, tmp_path): + diff = _netcdf_diff( + rows=( + _stat_row( + "tas", + moved=True, + maximum=Pair(4.0, 4.5), + max_abs_diff=0.5, + max_rel_diff=0.125, + cells_differ=1, + ), + ), + ) + report = _netcdf_case(tmp_path, diff) + + html = render_case(report, report.cases[0]) + + assert "4 -> 4.5" in html # the max moved + assert "1 -> 1" in html # the min did not, so it stays plain + assert "0.5" in html # and so do the three diff columns + assert html.count("") == 4 + + def test_an_unchanged_row_emphasises_nothing(self, tmp_path): + report = _netcdf_case(tmp_path, _netcdf_diff(rows=(_stat_row("tas", moved=False),))) + + html = render_case(report, report.cases[0]) + + assert "" not in html + def test_a_note_replaces_the_table(self, tmp_path): report = _netcdf_case(tmp_path, _netcdf_diff(rows=(), note="could not open: boom")) @@ -386,11 +411,11 @@ def test_an_absent_statistic_renders_as_an_ascii_hyphen(self, tmp_path): _stat_row( "tas", moved=True, - shape_old=None, - min_old=None, - max_old=None, - mean_old=None, - nan_old=None, + shape=Pair(None, "2x2"), + minimum=Pair(None, 1.0), + maximum=Pair(None, 4.0), + mean=Pair(None, 2.5), + nan=Pair(None, 0), max_abs_diff=None, max_rel_diff=None, cells_differ=None, @@ -401,7 +426,7 @@ def test_an_absent_statistic_renders_as_an_ascii_hyphen(self, tmp_path): html = render_case(report, report.cases[0]) - assert "- -> 2x2" in html + assert "- -> 2x2" in html assert "-" in html def test_the_page_carries_no_dash_that_is_not_ascii(self, tmp_path): @@ -455,7 +480,7 @@ def test_an_absent_side_says_so_in_the_split_view(self, tmp_path): header=(DiffLine(kind="add", text="+ dimensions:"),), header_old=(), header_new=("dimensions:",), - rows=(_stat_row("tas", moved=True, shape_old=None),), + rows=(_stat_row("tas", moved=True, shape=Pair(None, "2x2")),), ) report = _netcdf_case(tmp_path, diff) From 799754903d88bac6d0e46fb0584df5d5fde1e9f3 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 13:11:48 +1000 Subject: [PATCH 53/64] fix: measure netcdf differences without losing precision or missingness Three findings from the review, all in the statistics. - Keeps the stored dtype when reading a variable. Casting to float first meant an `int64` past 2**53 lost its last digit, so two adjacent values compared equal and a changed cell was reported as unchanged. The mean still accumulates in float64, so a float32 variable reads the same as before. - Reports no maximum difference when a cell moved between NaN and a number. The subtraction gives NaN there, so the finite maximum could read as 0 next to a non-zero cell count. - Bounds what one side may decode to. The fetch cap covers the stored blob, and a compressed file under it can still expand past what the job can hold. --- .../climate_ref/baseline_report/analyse.py | 39 ++++++++++++++---- .../unit/baseline_report/test_analyse.py | 41 +++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py index 7fa9a7b59..f66ce58ac 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/analyse.py +++ b/packages/climate-ref/src/climate_ref/baseline_report/analyse.py @@ -35,6 +35,10 @@ # A NetCDF blob larger than this is left unopened. NETCDF_FETCH_BYTES = 100_000_000 +# Compression means a blob under the fetch cap can still decode to far more, and each variable +# is held on both sides plus a difference. This bounds what one side may expand to. +MAX_DECODED_BYTES = 500_000_000 + # Unified-diff lines kept per file before the rest is elided. MAX_DIFF_LINES = 5000 @@ -479,11 +483,13 @@ def _values(variable: xr.DataArray | None) -> np.ndarray | None: Returns ------- : - The values as float64, or ``None`` when the variable is absent or not numeric. + The values in their stored dtype, or ``None`` when the variable is absent or not + numeric. The dtype is kept because an ``int64`` past 2**53 does not survive a cast to + float, so two adjacent values would compare equal. """ if variable is None or not np.issubdtype(variable.dtype, np.number): return None - return np.asarray(variable.values, dtype=float) + return np.asarray(variable.values) def _shape(variable: xr.DataArray | None) -> str | None: @@ -526,7 +532,12 @@ def _summarise(values: np.ndarray | None) -> tuple[float | None, float | None, f nan_count = int(np.isnan(values).sum()) if values.size in (0, nan_count): return None, None, None, nan_count - return float(np.nanmin(values)), float(np.nanmax(values)), float(np.nanmean(values)), nan_count + return ( + float(np.nanmin(values)), + float(np.nanmax(values)), + float(np.nanmean(values, dtype=float)), + nan_count, + ) def _compare( @@ -550,13 +561,18 @@ def _compare( ------- : The largest absolute difference, the same relative to ``scale``, and the number of - cells that differ. All ``None`` when the sides cannot be compared. + cells that differ. The two differences are ``None`` when a cell moved between NaN and + a number, because that gap has no magnitude and the finite maximum would read as zero. + All three are ``None`` when the sides cannot be compared. """ if old is None or new is None or old.shape != new.shape: return None, None, None - same = (old == new) | (np.isnan(old) & np.isnan(new)) - cells_differ = int(np.sum(~same)) - diff = np.abs(new - old) + old_nan = np.isnan(old) + new_nan = np.isnan(new) + cells_differ = int(np.sum(~((old == new) | (old_nan & new_nan)))) + if np.any(old_nan != new_nan): + return None, None, cells_differ + diff = np.abs(new.astype(float) - old.astype(float)) max_abs = 0.0 if np.isnan(diff).all() else float(np.nanmax(diff)) return max_abs, max_abs / max(scale, float(np.finfo(float).tiny)), cells_differ @@ -634,6 +650,15 @@ def netcdf_diff(old: Path | None, new: Path | None) -> NetcdfDiff: if new is not None else None ) + for dataset in (old_ds, new_ds): + if dataset is not None and dataset.nbytes > MAX_DECODED_BYTES: + return NetcdfDiff( + header=(), + header_old=(), + header_new=(), + rows=(), + note=f"decodes to too much to analyse ({dataset.nbytes:,} B)", + ) header_old = _header(old_ds) header_new = _header(new_ds) header = _header_diff(header_old, header_new) diff --git a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py index aa0edd5c3..3b23bcc21 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_analyse.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_analyse.py @@ -384,6 +384,47 @@ def test_an_absent_old_side_leaves_every_old_statistic_unset(self, tmp_path, bas assert row.shape.changed is True assert row.moved is True + def test_adjacent_integers_past_the_float_mantissa_still_compare(self, tmp_path): + # 2**53 and the next integer collide once cast to float64. + old_values = np.array([[9007199254740992, 1], [2, 3]], dtype=np.int64) + new_values = np.array([[9007199254740993, 1], [2, 3]], dtype=np.int64) + paths = [] + for name, values in (("old.nc", old_values), ("new.nc", new_values)): + path = tmp_path / name + xr.Dataset({"count": (("lat", "lon"), values)}).to_netcdf(path) + paths.append(path) + + row = netcdf_diff(*paths).rows[0] + + assert row.cells_differ == 1 + assert row.moved is True + + @pytest.mark.parametrize( + ("old_cell", "new_cell"), + [(np.nan, 5.0), (5.0, np.nan)], + ids=["nan_to_number", "number_to_nan"], + ) + def test_a_cell_moving_between_nan_and_a_number_has_no_measurable_difference( + self, tmp_path, old_cell, new_cell + ): + old = _write_nc(tmp_path / "old.nc", [[old_cell, 1.0], [2.0, 3.0]]) + new = _write_nc(tmp_path / "new.nc", [[new_cell, 1.0], [2.0, 3.0]]) + + row = netcdf_diff(old, new).rows[0] + + assert row.cells_differ == 1 + assert row.max_abs_diff is None # never 0.0, which would read as no change + assert row.max_rel_diff is None + assert row.moved is True + + def test_a_file_that_decodes_to_too_much_is_not_reduced(self, tmp_path, base_nc, monkeypatch): + monkeypatch.setattr("climate_ref.baseline_report.analyse.MAX_DECODED_BYTES", 8) + + diff = netcdf_diff(base_nc, base_nc) + + assert diff.note.startswith("decodes to too much to analyse") + assert diff.rows == () + def test_a_file_that_is_not_netcdf_becomes_a_note(self, tmp_path, base_nc): broken = tmp_path / "broken.nc" broken.write_text("not a netcdf file at all") From 129f894b8db66f90a15cdeccccc7e92da69d0211 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 13:16:14 +1000 Subject: [PATCH 54/64] fix: take the newest version when obs4MIPs and obs4REF both hold a dataset The fold used to take the obs4MIPs copy whatever the versions. A stale published copy would then beat a newer registry one and rerun every diagnostic on the wrong data. The newest version now wins, and obs4MIPs only wins a tie. The superseded-obs4ref doctor check reuses the same merge so the two cannot disagree. Also picks up the review findings: - collect_required_reference_data reads the declared fallback source types when locating a registry. - A primary catalog without an instance_id column is left untouched instead of raising. - The unsolvable-diagnostics finding joins filters with "and" rather than a semicolon. - Fixes a typo and the long lines added to the docs. --- changelog/898.feature.md | 7 +- docs/getting-started/02-download-datasets.md | 9 +- docs/getting-started/03-ingest.md | 6 +- docs/getting-started/quickstart.md | 3 +- docs/how-to-guides/diagnose-a-deployment.md | 3 +- .../src/climate_ref_core/reference_data.py | 15 ++- .../tests/unit/test_reference_data.py | 16 +++ .../src/climate_ref/doctor/checks/data.py | 16 +-- .../climate-ref/src/climate_ref/solver.py | 99 +++++++++++++------ .../climate-ref/tests/unit/test_doctor.py | 29 ++++++ .../test_diagnose_snapshot_seeded.yml | 4 +- .../climate-ref/tests/unit/test_solver.py | 31 +++++- 12 files changed, 184 insertions(+), 54 deletions(-) diff --git a/changelog/898.feature.md b/changelog/898.feature.md index 6ae8dea5d..a5fcef16a 100644 --- a/changelog/898.feature.md +++ b/changelog/898.feature.md @@ -1,8 +1,9 @@ Ingests the obs4REF collection under its own `obs4ref` source type. -The instance_id for `obs4ref` datasets now starts with `obs4ref`. +The `instance_id` for `obs4ref` datasets now starts with `obs4REF`. The diagnostics keep asking for obs4MIPs data. -Where a dataset is ingested from both, the obs4MIPs copy is used and obs4REF fills in the rest, -so publishing a dataset to obs4MIPs takes over from the registry copy without any change to the REF. +Where a dataset is ingested from both, the newest version is used, and the obs4MIPs copy wins a tie. +Publishing a dataset to obs4MIPs therefore takes over from the registry copy without any change to the REF. +A newer registry version is still preferred over a stale published one, so nothing reruns for no reason. `ref doctor` gains three checks: `misfiled-obs4ref`, `superseded-obs4ref` and `unsolvable-diagnostics`. Data ingested with `--source-type obs4mips` in earlier releases still solves, diff --git a/docs/getting-started/02-download-datasets.md b/docs/getting-started/02-download-datasets.md index fab002057..c65a3ab10 100644 --- a/docs/getting-started/02-download-datasets.md +++ b/docs/getting-started/02-download-datasets.md @@ -104,7 +104,7 @@ so only use it if you are **not** using that registry — see the warning below. Files land in the [intake-esgf `local_cache`](https://intake-esgf.readthedocs.io/en/latest/configure.html), and are ingested with the `obs4mips` source type. -The obs4REF collection is ingested with the `obs4ref` source type instead +The obs4REF collection is ingested with the `obs4ref` source type instead. /// admonition | Fetching these twice type: note @@ -113,8 +113,11 @@ The script also fetches `CERES-EBAF-4-2`, `GPCP-Monthly-3-2`, `HadISST-1-1` and These are the ESGF-published copies of datasets that were curated for the REF before publication, so the obs4REF registry ships them as well. -If you fetch the same dataset from ESGF/obs4MIPs as well as from the obs4REF registry, the ESGF copy is the one used. -obs4MIPs is the preferred home of the reference data, and the registry only fills in what is not published yet. +If you fetch the same dataset from ESGF/obs4MIPs as well as from the obs4REF registry, +the newest version is used. +The ESGF copy wins when the versions are the same. +obs4MIPs is the preferred home of the reference data. +The registry only fills in what is not published yet. /// ### Future work diff --git a/docs/getting-started/03-ingest.md b/docs/getting-started/03-ingest.md index e44908801..6554b9d67 100644 --- a/docs/getting-started/03-ingest.md +++ b/docs/getting-started/03-ingest.md @@ -13,8 +13,10 @@ Before you begin, ensure you have: The `obs4REF` collection we downloaded in the previous step is ingested under the `obs4ref` source type. The files follow the obs4MIPs conventions, but are not yet published on ESGF. -Where a dataset is ingested from both, the obs4MIPs copy is used, and then it falls back to obs4REF. -This command will extract metadata from the files and store it in the Climate-REF catalog, and print a summary of the ingested datasets. +Where a dataset is ingested from both, the newest version is used. +The obs4MIPs copy wins when the versions are the same. +This command extracts metadata from the files and stores it in the Climate-REF catalog. +It then prints a summary of the ingested datasets. ```bash ref datasets ingest --source-type obs4ref $REF_CONFIGURATION/datasets/obs4ref diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 8a510588a..c30a529ac 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -105,7 +105,8 @@ ref datasets fetch-data --registry quickstart --output-directory $REF_CONFIGURAT ## 4. Ingest (~20 s) Extract metadata from the downloaded files into the local catalog. -The model data is CMIP6. The observation comes from the obs4REF registry, so it is ingested under the `obs4ref` source type. +The model data is CMIP6. +The observation comes from the obs4REF registry, so it is ingested under the `obs4ref` source type. ```bash ref datasets ingest --source-type cmip6 $REF_CONFIGURATION/datasets/sample-data/CMIP6 diff --git a/docs/how-to-guides/diagnose-a-deployment.md b/docs/how-to-guides/diagnose-a-deployment.md index 15ce6610e..928025faa 100644 --- a/docs/how-to-guides/diagnose-a-deployment.md +++ b/docs/how-to-guides/diagnose-a-deployment.md @@ -7,7 +7,8 @@ - obs4REF data is ingested as `obs4mips`, so the catalog cannot say where it came from. - An obs4REF dataset has since been published to obs4MIPs, so the registry copy is no longer used. - A dataset's files cover the same period twice, so a diagnostic reads that period more than once. -- A diagnostic cannot be solved at all by the ingested data, and the finding names the requirement that goes unmet. +- A diagnostic cannot be solved at all by the ingested data. + The finding names the requirement that goes unmet. ```bash ref doctor diff --git a/packages/climate-ref-core/src/climate_ref_core/reference_data.py b/packages/climate-ref-core/src/climate_ref_core/reference_data.py index c690922d6..ee4970182 100644 --- a/packages/climate-ref-core/src/climate_ref_core/reference_data.py +++ b/packages/climate-ref-core/src/climate_ref_core/reference_data.py @@ -182,6 +182,8 @@ def collect_required_reference_data( variables: dict[tuple[str, str], set[str]] = defaultdict(set) diagnostics: dict[tuple[str, str], set[DiagnosticReference]] = defaultdict(set) + # Source types a requirement may be met from, its own first + suppliers: dict[tuple[str, str], list[str]] = defaultdict(list) for provider in providers: for diagnostic in summarize_provider(provider).diagnostics: @@ -198,11 +200,20 @@ def collect_required_reference_data( key = (requirement.source_type, source_id) variables[key].update(requirement.variables) diagnostics[key].add(reference) + for source_type in (requirement.source_type, *requirement.fallback_source_types): + if source_type not in suppliers[key]: + suppliers[key].append(source_type) datasets = [] for (source_type, source_id), variable_ids in variables.items(): - registry_names = registry_of.get((source_type, source_id), []) - registry_name = registry_names[0] if registry_names else None + registry_name = next( + ( + registry_of[(supplier, source_id)][0] + for supplier in suppliers[(source_type, source_id)] + if registry_of.get((supplier, source_id)) + ), + None, + ) datasets.append( ReferenceDataset( source_type=source_type, diff --git a/packages/climate-ref-core/tests/unit/test_reference_data.py b/packages/climate-ref-core/tests/unit/test_reference_data.py index 110b631fc..9103a8181 100644 --- a/packages/climate-ref-core/tests/unit/test_reference_data.py +++ b/packages/climate-ref-core/tests/unit/test_reference_data.py @@ -163,6 +163,22 @@ def test_model_data_requirements_are_ignored(self): assert collect_required_reference_data([provider], _FakeManager({})) == [] + def test_declared_fallback_locates_the_registry(self): + manager = _FakeManager( + {"obs4ref": _FakeEntry([_obs4ref_key("WECANN-1-0", "gpp")], SourceDatasetType.obs4REF)} + ) + requirement = DataRequirement( + source_type=SourceDatasetType.PMPClimatology, + filters=(FacetFilter(facets={"source_id": "WECANN-1-0", "variable_id": "gpp"}),), + group_by=None, + fallback_source_types=(SourceDatasetType.obs4REF,), + ) + + (dataset,) = collect_required_reference_data([_provider([requirement])], manager) + + assert dataset.source_type == SourceDatasetType.PMPClimatology.value + assert dataset.registry_name == "obs4ref" + def test_variables_are_unioned_across_diagnostics(self): manager = _FakeManager({}) one = _provider([_requirement(SourceDatasetType.obs4MIPs, "ERA-5", "ta")], slug="one") diff --git a/packages/climate-ref/src/climate_ref/doctor/checks/data.py b/packages/climate-ref/src/climate_ref/doctor/checks/data.py index baa30df73..472ba277e 100644 --- a/packages/climate-ref/src/climate_ref/doctor/checks/data.py +++ b/packages/climate-ref/src/climate_ref/doctor/checks/data.py @@ -25,8 +25,8 @@ as_frame, catalog_for_requirement, extract_covered_datasets, - obs_dataset_key, solve_executions, + union_with_fallbacks, ) from climate_ref.text import pluralise from climate_ref_core.diagnostics import Diagnostic @@ -336,9 +336,9 @@ def check_misfiled_obs4ref(context: DoctorContext) -> list[Finding]: ) def check_superseded_obs4ref(context: DoctorContext) -> list[Finding]: """ - Find obs4REF datasets for which an obs4MIPs copy is also ingested. + Find obs4REF datasets that lose to an ingested obs4MIPs copy. - The solver takes the obs4MIPs copy, so the obs4REF one is no longer used. + The solver takes the newest version, and the obs4MIPs copy on a tie, so these rows are never used. This is the signal that a dataset can be dropped from the obs4REF registry. Parameters @@ -362,14 +362,14 @@ def check_superseded_obs4ref(context: DoctorContext) -> list[Finding]: if not len(genuine): return [] - published = set(obs_dataset_key(genuine["instance_id"])) - superseded = obs4ref[obs_dataset_key(obs4ref["instance_id"]).isin(published)] + used = set(as_frame(union_with_fallbacks(genuine, [obs4ref], SourceDatasetType.obs4MIPs))["instance_id"]) + superseded = obs4ref[~obs4ref["instance_id"].isin(used)] return [ Finding( severity=Severity.INFO, summary=f"{instance_id} is superseded by the obs4MIPs copy", remedy=( - "The obs4MIPs copy is used instead. " + "The obs4MIPs copy is the same or a newer version, so it is used instead. " "These can be retracted, and dropped from the obs4REF registry." ), ) @@ -437,7 +437,7 @@ def _why_unsolvable( Explain which requirement the ingested data fails to meet. Each requirement is checked on its own, so the first one with no matching group names the data to fetch. - When every requirement matches something, the failure lies in howthey combine, which is reported as such. + When every requirement matches something, the failure lies in how they combine, which is reported as such. """ reasons = [] for requirements in normalize_requirement_sets(diagnostic.data_requirements): @@ -447,7 +447,7 @@ def _why_unsolvable( reasons.append(f"nothing is ingested as {requirement.source_type.value}") break if not extract_covered_datasets(catalog, requirement): - facets = "; ".join( + facets = " and ".join( ", ".join(f"{k}={'|'.join(v)}" for k, v in sorted(f.facets.items())) for f in requirement.filters ) diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py index 3ca6809a7..76cf2e22b 100644 --- a/packages/climate-ref/src/climate_ref/solver.py +++ b/packages/climate-ref/src/climate_ref/solver.py @@ -40,6 +40,7 @@ ExecutionDatasetCollection, Selector, SourceDatasetType, + version_sort_key, ) from climate_ref_core.diagnostics import DataRequirement, Diagnostic, ExecutionDefinition from climate_ref_core.exceptions import InvalidDiagnosticException @@ -271,16 +272,17 @@ def union_with_fallbacks( primary_type: SourceDatasetType, ) -> pd.DataFrame | DataCatalog: """ - Fill a reference catalog with the datasets its fallback collections hold and it lacks. + Fill a reference catalog from its fallback collections. - A dataset the primary holds is taken from the primary, whichever version each collection holds. - So for obs4MIPs standing in front of obs4REF, publishing a dataset to the archive - takes over from the registry copy without any re-ingest. + A dataset held by more than one collection is taken at its newest version, + and from the primary when the versions tie. + So publishing a dataset to obs4MIPs takes over from the obs4REF copy without any re-ingest, + but a newer registry version is not lost to a stale published one. Parameters ---------- primary - The catalog the requirement asks for, which wins on duplicates. + The catalog the requirement asks for, which wins a version tie. fallbacks Catalogs that may stand in for it, tried in order. primary_type @@ -289,14 +291,9 @@ def union_with_fallbacks( Returns ------- : - The primary catalog, extended with the fallback datasets it does not hold. - - This includes the case of nothing being ingested as the primary type at all - which is an ordinary deployment that fetched only the registry. - The original catalog is returned untouched when there is nothing to add. - A merge of two catalogs carries no adapter, so it cannot reload itself and lose the added rows. - The added rows carry ``activity_id`` of the primary, because that is the collection they - stand in for. + The primary catalog, extended with the fallback rows that win. + The original catalog is returned untouched when nothing is added. + The added rows carry ``activity_id`` of the primary, because that is the collection they stand in for. Their ``instance_id`` still names the collection they came from, so the provenance is not lost. """ usable = [ @@ -307,34 +304,78 @@ def union_with_fallbacks( if not usable: return primary - primary_df = as_frame(primary) - held = set(obs_dataset_key(primary_df["instance_id"])) if len(primary_df) else set() - - additions = [] + merged = as_frame(primary) + if len(merged) and "instance_id" not in merged.columns: + return primary + changed = False for fallback_df in usable: - extra = fallback_df[~obs_dataset_key(fallback_df["instance_id"]).isin(held)] - if extra.empty: + taken = newer_rows(fallback_df, merged) + if taken.empty: continue - held |= set(obs_dataset_key(extra["instance_id"])) # The rows are served as the primary's data, so they must group as the primary's data too. # A requirement grouping by activity_id would otherwise split its reference data in two. - if "activity_id" in extra.columns: - extra = extra.assign(activity_id=primary_type.name) - additions.append(extra) + if "activity_id" in taken.columns: + taken = taken.assign(activity_id=primary_type.name) + if len(merged): + beaten = obs_dataset_key(merged["instance_id"]).isin(obs_dataset_key(taken["instance_id"])) + merged = pd.concat([merged[~beaten], taken], ignore_index=True) + else: + merged = taken.reset_index(drop=True) + changed = True - if not additions: + if not changed: return primary - - merged = pd.concat( - [primary_df, *additions] if len(primary_df) else additions, - ignore_index=True, - ) if isinstance(primary, DataCatalog) or any(isinstance(f, DataCatalog) for f in fallbacks): # No adapter can reload the merge, so the result carries none and never reloads. return DataCatalog.from_frame(merged) return merged +def newer_rows(candidate: pd.DataFrame, held: pd.DataFrame) -> pd.DataFrame: + """ + Select the rows of ``candidate`` that ``held`` lacks, or holds at an older version. + + Parameters + ---------- + candidate + Rows offered by a fallback collection. + held + Rows already taken, which win a version tie. + + Returns + ------- + : + The subset of ``candidate`` that should replace or extend ``held``. + """ + if not len(held): + return candidate + held_version = pd.Series( + obs_dataset_version(held["instance_id"]).to_numpy(), + index=obs_dataset_key(held["instance_id"]).to_numpy(), + ) + held_version = held_version.groupby(level=0).max() + current = obs_dataset_key(candidate["instance_id"]).map(held_version) + wins = current.isna() | (obs_dataset_version(candidate["instance_id"]) > current) + return candidate[wins] + + +def obs_dataset_version(instance_id: pd.Series) -> pd.Series: + """ + Read the numeric version key off the end of an obs4MIPs or obs4REF ``instance_id``. + + Parameters + ---------- + instance_id + Instance ids from either collection. + + Returns + ------- + : + The version compared as :func:`version_sort_key` does. + """ + return instance_id.astype(str).str.rsplit(".", n=1).str[1].map(version_sort_key) + + def obs_dataset_key(instance_id: pd.Series) -> pd.Series: """ Reduce an obs4MIPs or obs4REF ``instance_id`` to what identifies the dataset across the two. diff --git a/packages/climate-ref/tests/unit/test_doctor.py b/packages/climate-ref/tests/unit/test_doctor.py index 50cdd89f3..bb48663e2 100644 --- a/packages/climate-ref/tests/unit/test_doctor.py +++ b/packages/climate-ref/tests/unit/test_doctor.py @@ -248,6 +248,35 @@ def test_dataset_in_both_is_reported(self): assert findings[0].severity == Severity.INFO assert findings[0].summary.startswith("obs4REF.obs4REF.C.WECANN-1-0.mon.gpp.gn.v1") + def test_a_newer_obs4ref_version_is_not_superseded(self): + obs4mips = _catalog( + [ + ( + "obs4MIPs.obs4MIPs.C.WECANN-1-0.mon.gpp.gn.v1", + "WECANN-1-0", + "gpp", + "2007-01-01", + "2015-12-01", + "/a", + ) + ] + ) + obs4ref = _catalog( + [ + ( + "obs4REF.obs4REF.C.WECANN-1-0.mon.gpp.gn.v2", + "WECANN-1-0", + "gpp", + "2007-01-01", + "2015-12-01", + "/b", + ) + ] + ) + context = _context({SourceDatasetType.obs4MIPs: obs4mips, SourceDatasetType.obs4REF: obs4ref}) + + assert check_superseded_obs4ref(context) == [] + def test_a_misfiled_row_does_not_supersede(self): # Mid-upgrade both rows exist. Calling this superseded contradicts misfiled-obs4ref. obs4mips = _catalog( diff --git a/packages/climate-ref/tests/unit/test_doctor/test_diagnose_snapshot_seeded.yml b/packages/climate-ref/tests/unit/test_doctor/test_diagnose_snapshot_seeded.yml index a4b7b5775..224b82152 100644 --- a/packages/climate-ref/tests/unit/test_doctor/test_diagnose_snapshot_seeded.yml +++ b/packages/climate-ref/tests/unit/test_doctor/test_diagnose_snapshot_seeded.yml @@ -108,8 +108,8 @@ - check: unsolvable-diagnostics command: '' detail: 'Unmet: no cmip6 datasets match experiment_id=historical, table_id=Amon, - variable_id=hurs|pr|tas|tasmax; experiment_id=historical, table_id=Lmon, variable_id=cVeg|treeFrac; - experiment_id=historical, table_id=Emon, variable_id=vegFrac. nothing is ingested + variable_id=hurs|pr|tas|tasmax and experiment_id=historical, table_id=Lmon, variable_id=cVeg|treeFrac + and experiment_id=historical, table_id=Emon, variable_id=vegFrac. nothing is ingested as cmip7.' remedy: Ingest the data the unmet requirement names, then run the solver again. severity: warning diff --git a/packages/climate-ref/tests/unit/test_solver.py b/packages/climate-ref/tests/unit/test_solver.py index 364c856c2..5226420a9 100644 --- a/packages/climate-ref/tests/unit/test_solver.py +++ b/packages/climate-ref/tests/unit/test_solver.py @@ -1985,7 +1985,7 @@ def test_drs_and_complete_parsers_produce_same_executions(config, sample_data_di class TestObs4REFFallback: - """obs4MIPs requirements are filled from obs4REF, and the obs4MIPs copy wins.""" + """obs4MIPs requirements are filled from obs4REF, taking the newest version of each dataset.""" @staticmethod def _frame(prefix, source_ids, version="v1", start=0): @@ -2032,9 +2032,34 @@ def test_added_rows_group_as_obs4mips(self): assert merged["activity_id"].tolist() == ["obs4MIPs", "obs4MIPs"] assert merged["instance_id"].iloc[1].startswith("obs4REF.") - def test_obs4mips_wins_whatever_the_versions(self): + def test_obs4mips_wins_a_version_tie(self): obs4mips = self._frame("obs4MIPs", ["A"], version="v1") - obs4ref = self._frame("obs4REF", ["A"], version="v2", start=10) + obs4ref = self._frame("obs4REF", ["A"], version="v1", start=10) + + assert union_with_fallbacks(obs4mips, [obs4ref], SourceDatasetType.obs4MIPs) is obs4mips + + def test_a_newer_obs4ref_version_replaces_the_obs4mips_copy(self): + # A stale published copy must not force every diagnostic to rerun on the wrong data. + obs4mips = self._frame("obs4MIPs", ["A", "B"], version="v20240101") + obs4ref = self._frame("obs4REF", ["A"], version="v20250101", start=10) + + merged = union_with_fallbacks(obs4mips, [obs4ref], SourceDatasetType.obs4MIPs) + + assert merged["source_id"].tolist() == ["B", "A"] + assert merged["instance_id"].tolist() == [ + "obs4MIPs.obs4MIPs.INST.B.mon.ts.gn.v20240101", + "obs4REF.obs4REF.INST.A.mon.ts.gn.v20250101", + ] + + def test_an_older_obs4ref_version_is_ignored(self): + obs4mips = self._frame("obs4MIPs", ["A"], version="v10") + obs4ref = self._frame("obs4REF", ["A"], version="v9", start=10) + + assert union_with_fallbacks(obs4mips, [obs4ref], SourceDatasetType.obs4MIPs) is obs4mips + + def test_a_primary_without_instance_id_is_left_alone(self): + obs4mips = pd.DataFrame({"source_id": ["A"], "variable_id": "ts"}) + obs4ref = self._frame("obs4REF", ["B"]) assert union_with_fallbacks(obs4mips, [obs4ref], SourceDatasetType.obs4MIPs) is obs4mips From 9e423f06ec0ddaa5cc5bbfd30eb33ca8575b0007 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 12:37:56 +1000 Subject: [PATCH 55/64] ci: post baseline diff reports from a dedicated workflow Adds `regression-diff-report.yaml`, which builds the HTML diff of the baselines changed on a branch, uploads it to the reports bucket, and edits one sticky comment on the branch's open pull request. The mint workflow now calls it instead of posting a capped markdown comment itself. This removes `scripts/ci/mint_diff.py`. --- .github/workflows/regression-diff-report.yaml | 95 ++++ .github/workflows/regression-mint.yaml | 50 +- changelog/915.feature.md | 1 + docs/background/regression-baselines.md | 45 +- scripts/ci/mint_diff.py | 495 ------------------ 5 files changed, 148 insertions(+), 538 deletions(-) create mode 100644 .github/workflows/regression-diff-report.yaml create mode 100644 changelog/915.feature.md delete mode 100755 scripts/ci/mint_diff.py diff --git a/.github/workflows/regression-diff-report.yaml b/.github/workflows/regression-diff-report.yaml new file mode 100644 index 000000000..84fbc330c --- /dev/null +++ b/.github/workflows/regression-diff-report.yaml @@ -0,0 +1,95 @@ +# Build and host the HTML diff of the regression baselines changed on a branch. +# +# Called by the mint workflow after it pushes regenerated manifests, and dispatched by hand +# after a manual mint. Reads native blobs from the public store and writes only to the +# reports bucket, so it needs the reports token and nothing else. +name: Regression baselines (diff report) + +on: + workflow_call: + inputs: + base: + description: "Branch to diff against" + type: string + default: "main" + workflow_dispatch: + inputs: + base: + description: "Branch to diff against" + required: false + default: "main" + +permissions: + contents: read + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + report: + if: github.repository == 'Climate-REF/climate-ref' + runs-on: ubuntu-latest + environment: baseline-reports + defaults: + run: + shell: bash + steps: + - name: Check out the branch head + uses: actions/checkout@v7 + with: + # A called workflow sees the dispatching commit, not the one the mint job pushed. + ref: ${{ github.ref_name }} + fetch-depth: 0 + - name: Fetch the base branch + env: + BASE: ${{ inputs.base }} + run: git fetch --no-tags origin "+refs/heads/${BASE}:refs/remotes/origin/${BASE}" + - uses: ./.github/actions/setup + with: + python-version: "3.13" + - name: Find the pull request + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pr="$(gh pr list --head "${GITHUB_REF_NAME}" --state open --json number --jq '.[0].number')" + sha="$(git rev-parse --short=12 HEAD)" + if [ -n "${pr}" ]; then + echo "prefix=${pr}/${sha}" >> "${GITHUB_OUTPUT}" + else + echo "No open pull request for ${GITHUB_REF_NAME}. Uploading under branch/ and skipping the comment." + echo "prefix=branch/${GITHUB_REF_NAME}/${sha}" >> "${GITHUB_OUTPUT}" + fi + echo "number=${pr}" >> "${GITHUB_OUTPUT}" + - name: Build and upload the report + env: + REF_REPORT_STORE_ACCESS_KEY_ID: ${{ secrets.R2_REPORTS_ACCESS_KEY_ID }} + REF_REPORT_STORE_SECRET_ACCESS_KEY: ${{ secrets.R2_REPORTS_SECRET_ACCESS_KEY }} + BASE: ${{ inputs.base }} + PREFIX: ${{ steps.pr.outputs.prefix }} + run: | + uv run ref test-cases diff \ + --base "origin/${BASE}" \ + --html-dir report \ + --upload "${PREFIX}" \ + --comment-output comment.md + cat comment.md >> "${GITHUB_STEP_SUMMARY}" + - name: Post or update the sticky comment + # One comment per pull request: the marker identifies the one to edit on a later run. + if: steps.pr.outputs.number != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ steps.pr.outputs.number }} + run: | + marker='' + existing="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate \ + --jq "map(select(.body | contains(\"${marker}\"))) | .[0].id // empty")" + if [ -n "${existing}" ]; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${existing}" \ + -F body=@comment.md + else + gh api --method POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \ + -F body=@comment.md + fi diff --git a/.github/workflows/regression-mint.yaml b/.github/workflows/regression-mint.yaml index dd245ba1b..725e272fe 100644 --- a/.github/workflows/regression-mint.yaml +++ b/.github/workflows/regression-mint.yaml @@ -36,7 +36,7 @@ on: default: false # `contents` to commit the regenerated manifest/bundle back to the dispatched branch, -# `pull-requests` to post the baseline diff on the branch's open pull request. +# `pull-requests` so the called diff-report workflow can post on the branch's open pull request. permissions: contents: write pull-requests: write @@ -70,7 +70,7 @@ jobs: - name: Check out repository uses: actions/checkout@v7 with: - # Full history so the baseline diff can find the merge-base with the default branch. + # Full history so the mint can resolve the committed baselines against the default branch. fetch-depth: 0 - uses: ./.github/actions/setup with: @@ -114,13 +114,6 @@ jobs: if [ "${BUMP_VERSION}" = "true" ]; then args+=(--bump-version); fi if [ "${DRY_RUN}" = "true" ]; then args+=(--dry-run); fi uv run ref test-cases mint "${args[@]}" - - name: Fetch the default branch for the baseline diff - if: ${{ !inputs.dry_run }} - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - git fetch --no-tags origin \ - "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" - name: Commit regenerated baselines if: ${{ !inputs.dry_run }} env: @@ -160,37 +153,10 @@ jobs: done echo "::error::failed to push regenerated baselines after retries" exit 1 - - name: Report the baseline diff - # Runs after the commit so HEAD carries the regenerated manifests. - # Reporting only, so a failure here must not fail an otherwise successful mint. - if: ${{ !inputs.dry_run }} - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - # Two reports: the full one is small enough to keep as an artefact whatever the mint's - # size, while the capped one fits the comment and job-summary limits. - uv run python scripts/ci/mint_diff.py \ - --base "origin/${DEFAULT_BRANCH}" \ - --output mint-diff.md \ - --comment-output mint-diff-comment.md - # The job summary always gets a report, whether or not a pull request exists. - cat mint-diff-comment.md >> "${GITHUB_STEP_SUMMARY}" - - pr="$(gh pr list --head "${GITHUB_REF_NAME}" --state open --json number --jq '.[0].number')" - if [ -z "${pr}" ]; then - echo "No open pull request for ${GITHUB_REF_NAME}; the report is in the job summary only." - exit 0 - fi - gh pr comment "${pr}" --body-file mint-diff-comment.md - - name: Upload the full baseline diff - # The comment is capped at GitHub's 65 KB limit, so keep the untruncated report too. - if: ${{ !inputs.dry_run }} - continue-on-error: true - uses: actions/upload-artifact@v7 - with: - name: mint-diff - path: mint-diff.md - if-no-files-found: ignore + report: + needs: mint + if: ${{ !inputs.dry_run }} + uses: ./.github/workflows/regression-diff-report.yaml + with: + base: ${{ github.event.repository.default_branch }} diff --git a/changelog/915.feature.md b/changelog/915.feature.md new file mode 100644 index 000000000..daf11ead5 --- /dev/null +++ b/changelog/915.feature.md @@ -0,0 +1 @@ +Regression baseline diffs are now published as an HTML report linked from a short pull request comment, with images shown side by side and NetCDF outputs summarised per variable. diff --git a/docs/background/regression-baselines.md b/docs/background/regression-baselines.md index 7b3c742da..f85b4ea2a 100644 --- a/docs/background/regression-baselines.md +++ b/docs/background/regression-baselines.md @@ -250,7 +250,7 @@ the `--json` output drives CI's dispatch of the `replay` jobs. ## Continuous integration -The lifecycle commands are wired into three GitHub Action workflows. +The lifecycle commands are wired into four GitHub Action workflows. The minting process requires credentials to upload data. Since this is a public project we have to be careful about when this is run to not leak these credentials. @@ -258,6 +258,7 @@ Since this is a public project we have to be careful about when this is run to n | --- | --- | --- | --- | | `regression-pr-gate.yaml` | every pull request | none | Runs the coupling gate, then `replay`s every case it routes to `replay`. | | `regression-mint.yaml` | manual dispatch | R2 write | `mint`s native baselines and commits the regenerated manifest back to the branch. | +| `regression-diff-report.yaml` | called by the mint + manual | R2 write (reports bucket) | Builds the HTML diff of the changed baselines and links it from a sticky pull-request comment. | | `regression-drift.yaml` | nightly + manual | none | `replay`s every baseline to catch silent drift. | ### PR gate (`regression-pr-gate.yaml`) @@ -301,6 +302,7 @@ Dispatch it on the feature branch that should receive the new baseline: the job runs `mint`, and commits the regenerated `manifest.json` (and committed bundle) back to that branch, so the change is reviewed through its pull request and no developer ever needs write credentials. A `dry_run` input previews without uploading or committing, and the job refuses to run on the default branch. +Once the commit is pushed, the mint calls `regression-diff-report.yaml` to publish the diff of what it changed. !!! warning "The mint commit does not re-trigger the PR gate" The mint job pushes with the default `GITHUB_TOKEN`, and GitHub deliberately does not start new workflow runs for such pushes. @@ -317,6 +319,47 @@ A `dry_run` input previews without uploading or committing, and the job refuses The endpoint and bucket default to the production R2 account (`REF_NATIVE_STORE_S3_ENDPOINT_URL` / `REF_NATIVE_STORE_BUCKET` override them). +### Diff report (`regression-diff-report.yaml`) + +Reviewing a minted baseline means looking at what actually moved, which a JSON manifest does not show. +So this workflow builds an HTML report of every test case whose baseline changed against the base branch: + +- Images are shown two-up, the old beside the new. +- Text outputs get a coloured line diff. +- A changed NetCDF gets a header diff plus one row of statistics per variable, shaded only where the values moved. + +The report is uploaded to the public reports bucket and served at +`https://reports.baselines.climate-ref.org///index.html`. +The workflow then posts a short comment on the branch's open pull request linking to it. +The comment carries a hidden marker, so a later run edits that same comment rather than stacking a new one. +A run on a branch with no open pull request uploads under `branch///` and writes the summary to the job log only. + +The mint workflow calls this automatically. +After minting on a workstation and pushing the result by hand, dispatch it yourself: + +```bash +gh workflow run regression-diff-report.yaml --ref +``` + +The same report can be built locally, without any credentials: + +```bash +uv run ref test-cases diff --base origin/main --html-dir out/ +``` + +!!! note "Required repository configuration" + Create a `baseline-reports` Environment (Settings -> Environments) with **no required reviewers**, + so a mint run does not stop for a second approval, and add two secrets to it + holding an R2 token scoped to the `ref-baselines-reports` bucket: + + - `R2_REPORTS_ACCESS_KEY_ID` -> `REF_REPORT_STORE_ACCESS_KEY_ID` + - `R2_REPORTS_SECRET_ACCESS_KEY` -> `REF_REPORT_STORE_SECRET_ACCESS_KEY` + + The reports live in their own bucket because an R2 token cannot be scoped write-only or to a prefix. + A token that could write reports into the baselines bucket could also overwrite a baseline. + The endpoint, bucket and public URL default to the production R2 account + (`REF_REPORT_STORE_S3_ENDPOINT_URL` / `REF_REPORT_STORE_BUCKET` / `REF_REPORT_STORE_URL` override them). + ### Nightly drift (`regression-drift.yaml`) A scheduled (and manually dispatchable) job `replay`s every referenced native blob against the committed bundle. diff --git a/scripts/ci/mint_diff.py b/scripts/ci/mint_diff.py deleted file mode 100755 index e68cf431f..000000000 --- a/scripts/ci/mint_diff.py +++ /dev/null @@ -1,495 +0,0 @@ -#!/usr/bin/env python -""" -Render a human-readable diff of the regression baselines changed on a branch. - -A mint rewrites each test case's ``manifest.json`` and uploads the curated native outputs to the -content-addressed object store. -The manifest diff therefore names every native file that changed, and both the old and the new blob -remain fetchable by digest, so the change can be reviewed without checking anything out locally. - -Text outputs (JSON, CSV, YAML, HTML) are fetched from the store and rendered as a unified diff. -Binary outputs (NetCDF, PNG) are reported by size delta and linked, because a byte diff would be noise. - -Usage: - uv run python scripts/ci/mint_diff.py [--base origin/main] [--output summary.md] - - --base git ref to compare against. Defaults to origin/${GITHUB_BASE_REF:-main}. - --output write the full, uncapped report here as well as to stdout. - --comment-output write a copy capped to GitHub's comment size limit here. - --store-url base URL of the native store. Defaults to $REF_NATIVE_STORE_URL. - --no-fetch skip all network access and report size deltas only. - -Exits 0 whether or not anything changed. This reports, it does not gate. -""" - -import argparse -import difflib -import json -import os -import sys -import urllib.error -import urllib.request -from dataclasses import dataclass, field -from pathlib import Path - -from git import GitCommandError, Repo - -from climate_ref_core.regression.manifest import Manifest, NativeEntry - -DEFAULT_STORE_URL = "https://baselines.climate-ref.org" - -# Cloudflare serves a 403 to the stdlib default agent, so identify the script instead. -USER_AGENT = "climate-ref-mint-diff" - -# A manifest path needs a diagnostic and test-case directory before a label can be built from it. -_MIN_LABEL_PARTS = 3 - -# Extensions whose blobs are worth fetching and diffing line by line. -TEXT_SUFFIXES = frozenset({".json", ".csv", ".yml", ".yaml", ".html", ".txt", ".md", ".log"}) - -# A blob larger than this is summarised rather than diffed. Keeps a runaway HTML report -# from stalling the job on the download alone. -MAX_FETCH_BYTES = 2_000_000 - -# Unified-diff lines kept per file before the rest is elided. -MAX_DIFF_LINES = 200 - -# Binary rows listed per case before the rest is elided. A large ILAMB or PMP case emits -# hundreds of plots, and the per-file size delta stops being informative long before then. -MAX_BINARY_ROWS = 30 - -# GitHub rejects a comment body over 65536 bytes, so leave headroom for the truncation notice. -MAX_COMMENT_BYTES = 60_000 - - -def open_repo() -> Repo: - """Return the repository containing the current directory.""" - return Repo(Path.cwd(), search_parent_directories=True) - - -def changed_manifests(repo: Repo, base: str) -> list[str]: - """ - Return the repo-relative paths of every test-case manifest that differs from ``base``. - - Uses the merge-base (``base...HEAD``) so commits landing on the base branch after the - feature branch forked are not misreported as baseline changes. - """ - pathspec = ":(glob)packages/**/test-data/**/manifest.json" - try: - out = repo.git.diff("--name-only", f"{base}...HEAD", "--", pathspec) - except GitCommandError: - # A shallow clone may have no merge-base with the base ref. A two-dot diff over-reports - # (it also shows base-branch commits), which is the safe direction for a report. - out = repo.git.diff("--name-only", base, "HEAD", "--", pathspec) - return sorted(line for line in out.splitlines() if line.strip()) - - -def load_at_ref(repo: Repo, ref: str, rel_path: str) -> Manifest | None: - """Load a manifest as it exists at ``ref``, or ``None`` when absent there.""" - try: - text = repo.git.show(f"{ref}:{rel_path}") - except GitCommandError: - return None - return Manifest.loads(text, source=f"{ref}:{rel_path}") - - -def case_label(rel_path: str) -> str: - """ - Derive a ``provider/diagnostic/test-case`` label from a manifest path. - - ``packages/climate-ref-pmp/tests/test-data/annual-cycle/cmip6-ts/manifest.json`` - becomes ``pmp/annual-cycle/cmip6-ts``. - """ - parts = Path(rel_path).parts - provider = parts[1].removeprefix("climate-ref-") if len(parts) > 1 else "?" - tail = parts[-3:-1] if len(parts) >= _MIN_LABEL_PARTS else () - return "/".join((provider, *tail)) - - -@dataclass -class FileChange: - """One native output file that was added, removed, or changed by the mint.""" - - name: str - old: NativeEntry | None - new: NativeEntry | None - diff: str | None = None - note: str | None = None - - @property - def status(self) -> str: - """``added``, ``removed`` or ``changed``.""" - if self.old is None: - return "added" - if self.new is None: - return "removed" - return "changed" - - @property - def is_text(self) -> bool: - """Whether this file's contents are worth diffing line by line.""" - return Path(self.name).suffix.lower() in TEXT_SUFFIXES - - -@dataclass -class CaseDiff: - """Everything that changed for a single test case.""" - - label: str - rel_path: str - base: Manifest | None - head: Manifest | None - native: list[FileChange] = field(default_factory=list) - committed: list[str] = field(default_factory=list) - metadata: list[str] = field(default_factory=list) - - @property - def is_new(self) -> bool: - """Whether the whole test case is new on this branch.""" - return self.base is None - - @property - def is_removed(self) -> bool: - """Whether the whole test case was deleted on this branch.""" - return self.head is None - - -def _metadata_changes(base: Manifest | None, head: Manifest | None) -> list[str]: - """Describe the scalar manifest fields that moved.""" - if head is None: - return ["test case removed"] - if base is None: - return [f"new test case at `test_case_version` {head.test_case_version}"] - changes = [] - for name in ("test_case_version", "diagnostic_version", "catalog_hash", "schema"): - old, new = getattr(base, name), getattr(head, name) - if old != new: - changes.append(f"`{name}`: `{old}` -> `{new}`") - return changes - - -def _committed_changes(base: Manifest | None, head: Manifest | None) -> list[str]: - """Name the committed regression artefacts whose digest moved.""" - old = base.committed if base else {} - new = head.committed if head else {} - names = sorted(set(old) | set(new)) - out = [] - for name in names: - if old.get(name) != new.get(name): - if name not in old: - out.append(f"`{name}` (added)") - elif name not in new: - out.append(f"`{name}` (removed)") - else: - out.append(f"`{name}`") - return out - - -def _fetch(store_url: str, digest: str) -> tuple[bytes | None, str]: - """ - Fetch a blob by digest. - - Returns a ``(payload, reason)`` pair, where ``payload`` is ``None`` on failure and - ``reason`` describes it. Cloudflare rejects the stdlib default agent, so one is set. - """ - url = f"{store_url.rstrip('/')}/{digest}" - request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310 - try: - with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - return response.read(MAX_FETCH_BYTES + 1), "" - except urllib.error.HTTPError as exc: - return None, f"store returned HTTP {exc.code}" - except (urllib.error.URLError, TimeoutError, OSError) as exc: - return None, f"store unreachable ({exc})" - - -def _as_lines(raw: bytes, name: str) -> list[str]: - """ - Decode a blob into diffable lines. - - JSON is re-serialised with indentation first, because a minified bundle would otherwise - diff as a single unreadable line. - """ - text = raw.decode("utf-8", errors="replace") - if Path(name).suffix.lower() == ".json": - try: - text = json.dumps(json.loads(text), indent=2, sort_keys=True) - except json.JSONDecodeError: - pass - return text.splitlines() - - -def _text_diff(store_url: str, change: FileChange) -> tuple[str | None, str | None]: - """ - Build the unified diff for one text file. - - Returns a ``(diff, note)`` pair. Exactly one is set: ``note`` explains why no diff - could be produced (too large, or the store did not serve a blob). - """ - for entry in (change.old, change.new): - if entry is not None and entry.size > MAX_FETCH_BYTES: - return None, f"too large to diff ({entry.size:,} B)" - - old_raw, old_reason = _fetch(store_url, change.old.sha256) if change.old else (b"", "") - new_raw, new_reason = _fetch(store_url, change.new.sha256) if change.new else (b"", "") - if old_raw is None or new_raw is None: - return None, old_reason or new_reason - - lines = list( - difflib.unified_diff( - _as_lines(old_raw, change.name), - _as_lines(new_raw, change.name), - fromfile=f"old {change.old.sha256[:12] if change.old else '(absent)'}", - tofile=f"new {change.new.sha256[:12] if change.new else '(absent)'}", - lineterm="", - n=3, - ) - ) - if not lines: - return None, "identical after decoding" - if len(lines) > MAX_DIFF_LINES: - elided = len(lines) - MAX_DIFF_LINES - lines = [*lines[:MAX_DIFF_LINES], f"... {elided:,} more diff line(s) elided"] - return "\n".join(lines), None - - -def build_case_diff(repo: Repo, base: str, rel_path: str, store_url: str, fetch: bool) -> CaseDiff | None: - """ - Collect every change to one test case. - - A case deleted on this branch has no head manifest. It is still reported, as a removal, - because dropping a test case is a baseline change a reviewer needs to see. - Returns ``None`` only when the manifest is absent from both sides, which leaves nothing to say. - """ - head_path = Path(repo.working_tree_dir or ".") / rel_path - head = Manifest.load(head_path) if head_path.exists() else None - base_manifest = load_at_ref(repo, base, rel_path) - if head is None and base_manifest is None: - return None - - diff = CaseDiff( - label=case_label(rel_path), - rel_path=rel_path, - base=base_manifest, - head=head, - committed=_committed_changes(base_manifest, head), - metadata=_metadata_changes(base_manifest, head), - ) - - old_native = base_manifest.native if base_manifest else {} - new_native = head.native if head else {} - for name in sorted(set(old_native) | set(new_native)): - old, new = old_native.get(name), new_native.get(name) - if old is not None and new is not None and old.sha256 == new.sha256: - continue - change = FileChange(name=name, old=old, new=new) - if change.is_text: - if fetch: - change.diff, change.note = _text_diff(store_url, change) - else: - change.note = "fetching disabled" - diff.native.append(change) - - return diff - - -def _size_delta(change: FileChange) -> str: - """Render the size column for a native file.""" - if change.old is None: - return f"{change.new.size:,} B" # type: ignore[union-attr] - if change.new is None: - return f"was {change.old.size:,} B" - delta = change.new.size - change.old.size - return f"{change.old.size:,} -> {change.new.size:,} B ({delta:+,})" - - -def _blob_links(store_url: str, change: FileChange) -> str: - """Render download links for whichever blobs exist.""" - root = store_url.rstrip("/") - links = [] - if change.old: - links.append(f"[old]({root}/{change.old.sha256})") - if change.new: - links.append(f"[new]({root}/{change.new.sha256})") - return " ".join(links) - - -def _utf8_len(text: str) -> int: - """Return the UTF-8 byte length of ``text``, which is what GitHub's comment limit measures.""" - return len(text.encode("utf-8")) - - -def _counts(diff: CaseDiff) -> str: - """Summarise a case's native changes as a short ``+a ~c -r`` string.""" - tally = {"added": 0, "changed": 0, "removed": 0} - for change in diff.native: - tally[change.status] += 1 - parts = [ - f"+{tally['added']}" if tally["added"] else "", - f"~{tally['changed']}" if tally["changed"] else "", - f"-{tally['removed']}" if tally["removed"] else "", - ] - return " ".join(p for p in parts if p) or "none" - - -def render_summary(diffs: list[CaseDiff]) -> str: - """ - Render the one-row-per-case overview table. - - Every changed case appears here, whether or not its detail section survives the size cap, - so nothing is silently invisible. - """ - rows = ["| case | versions | native files |\n| --- | --- | --- |\n"] - for diff in diffs: - base_manifest, head_manifest = diff.base, diff.head - if head_manifest is None: - versions = "removed" - elif base_manifest is None: - versions = "new" - else: - versions = f"v{base_manifest.test_case_version} -> v{head_manifest.test_case_version}" - rows.append(f"| `{diff.label}` | {versions} | {_counts(diff)} |\n") - return "".join(rows) - - -def render_case(diff: CaseDiff, store_url: str) -> str: - """Render one test case as a collapsible markdown section.""" - text_changes = [c for c in diff.native if c.is_text] - binary_changes = [c for c in diff.native if not c.is_text] - headline = f"{len(diff.native)} native file(s)" - if diff.is_removed: - headline = f"removed case, {headline}" - elif diff.is_new: - headline = f"new case, {headline}" - - # The blank line after is load-bearing: GitHub parses markdown inside an HTML - # block only after one, so without it the first list renders as raw text with its backticks. - out = [f"
\n{diff.label} -- {headline}\n\n"] - - if diff.metadata: - out.append("".join(f"- {line}\n" for line in diff.metadata)) - if diff.committed: - out.append("\nCommitted artefacts changed:\n\n") - out.append("".join(f"- {name}\n" for name in diff.committed)) - - if binary_changes: - out.append("\n| file | status | size | blobs |\n| --- | --- | --- | --- |\n") - for change in binary_changes[:MAX_BINARY_ROWS]: - out.append( - f"| `{change.name}` | {change.status} | {_size_delta(change)} " - f"| {_blob_links(store_url, change)} |\n" - ) - if len(binary_changes) > MAX_BINARY_ROWS: - elided = len(binary_changes) - MAX_BINARY_ROWS - out.append(f"\n_{elided} further binary file(s) not listed._\n") - - for change in text_changes: - out.append(f"\n**`{change.name}`** ({change.status}, {_size_delta(change)})") - if change.diff: - out.append(f"\n\n```diff\n{change.diff}\n```\n") - else: - out.append(f" -- {change.note}, {_blob_links(store_url, change)}\n") - - out.append("\n
\n") - return "".join(out) - - -def _omission_notice(remaining: list[CaseDiff], budget: int) -> str: - """ - Render the notice naming the cases whose detail did not fit. - - Falls back to a bare count when the labels themselves would not fit ``budget``, because a - notice that overflows the limit costs the entire comment rather than just its own text. - """ - tail = "\n_The full report is attached to the workflow run as the `mint-diff` artefact._\n" - labels = ", ".join(f"`{d.label}`" for d in remaining) - listed = f"\n_Detail omitted to fit the comment size limit: {labels}._\n{tail}" - if _utf8_len(listed) <= budget: - return listed - return f"\n_Detail omitted for {len(remaining)} further case(s) to fit the comment size limit._\n{tail}" - - -def render(diffs: list[CaseDiff], base: str, store_url: str, max_bytes: int | None = None) -> str: - """ - Render the whole report. - - The summary table always covers every case. When ``max_bytes`` is set, detail sections are - appended only while the encoded report stays under it and the remainder is named rather than - expanded. Pass ``None`` for the full report, which is what the workflow artefact carries. - - The budget counts UTF-8 bytes rather than characters, because that is what GitHub's comment - limit measures and a diff may carry non-ASCII content. - """ - if not diffs: - return f"### Regression baseline diff\n\nNo baseline manifests changed against `{base}`.\n" - - diffs = sorted(diffs, key=lambda d: d.label) - total = sum(len(d.native) for d in diffs) - preamble = ( - "### Regression baseline diff\n\n" - f"{len(diffs)} test case(s) and {total} native file(s) changed against `{base}`. " - "Text outputs are diffed inline. NetCDF and PNG outputs are listed with a size delta " - "and a link to each blob.\n\n" - ) - header = f"{preamble}{render_summary(diffs)}\n" - - if max_bytes is not None and _utf8_len(header) > max_bytes: - # Enough cases that even the summary overflows. Keep the preamble and point at the - # artefact, because posting a comment that names nothing beats posting none at all. - return preamble + _omission_notice(diffs, max_bytes - _utf8_len(preamble)) - - # Hold back part of the budget for the omission notice. Without it the notice can push the - # report past the limit, and `gh pr comment` then rejects the whole thing. - notice_budget = max_bytes // 10 if max_bytes is not None else 0 - - used = _utf8_len(header) - body = "" - for index, diff in enumerate(diffs): - section = render_case(diff, store_url) - if max_bytes is not None and used + _utf8_len(section) > max_bytes - notice_budget: - body += _omission_notice(diffs[index:], notice_budget) - break - body += section - used += _utf8_len(section) - return header + body - - -def main(argv: list[str] | None = None) -> int: - """Entry point.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--base", default=f"origin/{os.environ.get('GITHUB_BASE_REF', 'main')}") - parser.add_argument( - "--output", - type=Path, - default=None, - help="write the full, uncapped report here", - ) - parser.add_argument( - "--comment-output", - type=Path, - default=None, - help="write a copy capped to GitHub's comment size limit here", - ) - parser.add_argument("--store-url", default=os.environ.get("REF_NATIVE_STORE_URL", DEFAULT_STORE_URL)) - parser.add_argument("--no-fetch", action="store_true") - args = parser.parse_args(argv) - - repo = open_repo() - diffs = [] - for rel_path in changed_manifests(repo, args.base): - diff = build_case_diff(repo, args.base, rel_path, args.store_url, fetch=not args.no_fetch) - if diff is not None: - diffs.append(diff) - - full = render(diffs, args.base, args.store_url) - sys.stdout.write(full) - if args.output: - args.output.write_text(full, encoding="utf-8") - if args.comment_output: - capped = render(diffs, args.base, args.store_url, max_bytes=MAX_COMMENT_BYTES) - args.comment_output.write_text(capped, encoding="utf-8") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 83323e34569ccc91d3d3f2550a81127c57dd9cdd Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 13:28:42 +1000 Subject: [PATCH 56/64] chore: clean up --- Makefile | 1 - .../src/climate_ref_core/diagnostics.py | 4 +- .../climate-ref/src/climate_ref/solver.py | 57 +++++-------------- 3 files changed, 17 insertions(+), 45 deletions(-) diff --git a/Makefile b/Makefile index b9904261e..d368db040 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,6 @@ TEMP_FILE := $(shell mktemp) # Workers used by the parallel test run. -# `auto` takes every core, which leaves a developer machine unusable while the suite runs. PYTEST_WORKERS ?= 4 # A helper script to get short descriptions of each target in the Makefile diff --git a/packages/climate-ref-core/src/climate_ref_core/diagnostics.py b/packages/climate-ref-core/src/climate_ref_core/diagnostics.py index a61e98857..cd5825e4f 100644 --- a/packages/climate-ref-core/src/climate_ref_core/diagnostics.py +++ b/packages/climate-ref-core/src/climate_ref_core/diagnostics.py @@ -447,8 +447,8 @@ class DataRequirement: """ Source types that may supply this requirement when ``source_type`` does not hold the dataset. - A dataset present under ``source_type`` always wins, and the data is delivered under - ``source_type`` whichever collection it came from. + A dataset present under ``source_type`` always wins, + and the data is delivered under ``source_type`` whichever collection it came from. """ def apply_filters(self, data_catalog: pd.DataFrame) -> pd.DataFrame: diff --git a/packages/climate-ref/src/climate_ref/solver.py b/packages/climate-ref/src/climate_ref/solver.py index 76cf2e22b..ee3fd82a0 100644 --- a/packages/climate-ref/src/climate_ref/solver.py +++ b/packages/climate-ref/src/climate_ref/solver.py @@ -317,7 +317,7 @@ def union_with_fallbacks( if "activity_id" in taken.columns: taken = taken.assign(activity_id=primary_type.name) if len(merged): - beaten = obs_dataset_key(merged["instance_id"]).isin(obs_dataset_key(taken["instance_id"])) + beaten = _obs_dataset_key(merged["instance_id"]).isin(_obs_dataset_key(taken["instance_id"])) merged = pd.concat([merged[~beaten], taken], ignore_index=True) else: merged = taken.reset_index(drop=True) @@ -331,6 +331,16 @@ def union_with_fallbacks( return merged +def _obs_dataset_version(instance_id: pd.Series) -> pd.Series: + """Read the numeric version key off the end of an obs4MIPs or obs4REF ``instance_id``.""" + return instance_id.astype(str).str.rsplit(".", n=1).str[1].map(version_sort_key) + + +def _obs_dataset_key(instance_id: pd.Series) -> pd.Series: + """Strip the collection prefix to be common between obs4MIPs or obs4REF""" + return instance_id.astype(str).str.split(".", n=2).str[2].str.rsplit(".", n=1).str[0] + + def newer_rows(candidate: pd.DataFrame, held: pd.DataFrame) -> pd.DataFrame: """ Select the rows of ``candidate`` that ``held`` lacks, or holds at an older version. @@ -350,52 +360,15 @@ def newer_rows(candidate: pd.DataFrame, held: pd.DataFrame) -> pd.DataFrame: if not len(held): return candidate held_version = pd.Series( - obs_dataset_version(held["instance_id"]).to_numpy(), - index=obs_dataset_key(held["instance_id"]).to_numpy(), + _obs_dataset_version(held["instance_id"]).to_numpy(), + index=_obs_dataset_key(held["instance_id"]).to_numpy(), ) held_version = held_version.groupby(level=0).max() - current = obs_dataset_key(candidate["instance_id"]).map(held_version) - wins = current.isna() | (obs_dataset_version(candidate["instance_id"]) > current) + current = _obs_dataset_key(candidate["instance_id"]).map(held_version) + wins = current.isna() | (_obs_dataset_version(candidate["instance_id"]) > current) return candidate[wins] -def obs_dataset_version(instance_id: pd.Series) -> pd.Series: - """ - Read the numeric version key off the end of an obs4MIPs or obs4REF ``instance_id``. - - Parameters - ---------- - instance_id - Instance ids from either collection. - - Returns - ------- - : - The version compared as :func:`version_sort_key` does. - """ - return instance_id.astype(str).str.rsplit(".", n=1).str[1].map(version_sort_key) - - -def obs_dataset_key(instance_id: pd.Series) -> pd.Series: - """ - Reduce an obs4MIPs or obs4REF ``instance_id`` to what identifies the dataset across the two. - - The two collections build the same id apart from the leading collection components - and the trailing version. - - Parameters - ---------- - instance_id - Instance ids from either collection. - - Returns - ------- - : - The id with the collection prefix and version removed. - """ - return instance_id.astype(str).str.split(".", n=2).str[2].str.rsplit(".", n=1).str[0] - - def apply_obs4ref_fallback( data_catalog: Mapping[SourceDatasetType, pd.DataFrame | DataCatalog], ) -> Mapping[SourceDatasetType, pd.DataFrame | DataCatalog]: From c90fad3966c3b17f65c799b85624a38778fb6232 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 12:28:21 +1000 Subject: [PATCH 57/64] feat: publish the baseline diff report and write the PR comment Adds `--upload ` and `--comment-output ` to `ref test-cases diff`. The report is pushed to a public object store and the comment links into it, so the comment is one table row per changed case rather than the whole diff. - Adds `ReportStore` in core, a named-key sibling of the content-addressed `NativeStore`. - Keys are validated as safe relative paths, and every remote upload sets a content type. - Adds `ReportStoreConfig`, so the store is routed by `REF_REPORT_STORE_*` and its credentials are read from the environment at upload time only. - Reports live in their own bucket because an R2 token cannot be scoped to a prefix. --- changelog/912.feature.md | 8 + .../climate_ref_core/regression/__init__.py | 6 + .../regression/report_store.py | 298 ++++++++++++++++++ .../unit/regression/test_report_store.py | 180 +++++++++++ .../src/climate_ref/baseline_report/render.py | 172 +++++++++- .../baseline_report/templates/comment.md.j2 | 19 ++ .../src/climate_ref/baseline_report/upload.py | 76 +++++ .../src/climate_ref/cli/test_cases/diff.py | 38 ++- .../climate-ref/src/climate_ref/config.py | 49 +++ .../tests/unit/baseline_report/test_render.py | 105 +++++- .../tests/unit/baseline_report/test_upload.py | 86 +++++ .../tests/unit/cli/test_test_cases.py | 95 ++++++ .../climate-ref/tests/unit/test_config.py | 18 ++ 13 files changed, 1145 insertions(+), 5 deletions(-) create mode 100644 changelog/912.feature.md create mode 100644 packages/climate-ref-core/src/climate_ref_core/regression/report_store.py create mode 100644 packages/climate-ref-core/tests/unit/regression/test_report_store.py create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/templates/comment.md.j2 create mode 100644 packages/climate-ref/src/climate_ref/baseline_report/upload.py create mode 100644 packages/climate-ref/tests/unit/baseline_report/test_upload.py diff --git a/changelog/912.feature.md b/changelog/912.feature.md new file mode 100644 index 000000000..87a66be0a --- /dev/null +++ b/changelog/912.feature.md @@ -0,0 +1,8 @@ +`ref test-cases diff --upload ` publishes the report to a public object store, +and `--comment-output ` writes the pull request comment markdown that links into it. +The comment is one table row per changed case, so its size no longer grows with the diff. + +Reports go to their own store, configured by `REF_REPORT_STORE_URL`, `REF_REPORT_STORE_BUCKET` +and `REF_REPORT_STORE_S3_ENDPOINT_URL`, with credentials read from +`REF_REPORT_STORE_ACCESS_KEY_ID` / `REF_REPORT_STORE_SECRET_ACCESS_KEY` at upload time. +It is a separate bucket to the baselines store because an R2 token cannot be scoped to a prefix. diff --git a/packages/climate-ref-core/src/climate_ref_core/regression/__init__.py b/packages/climate-ref-core/src/climate_ref_core/regression/__init__.py index 21c73d877..863b963c1 100644 --- a/packages/climate-ref-core/src/climate_ref_core/regression/__init__.py +++ b/packages/climate-ref-core/src/climate_ref_core/regression/__init__.py @@ -37,6 +37,10 @@ sha256_file, verify_committed_integrity, ) +from climate_ref_core.regression.report_store import ( + ReportStore, + build_report_store, +) from climate_ref_core.regression.store import ( NativeStore, NativeStoreUnavailableError, @@ -53,11 +57,13 @@ "NativeEntry", "NativeStore", "NativeStoreUnavailableError", + "ReportStore", "S3WriteConfig", "Tolerance", "assert_bundle_regression", "build_native_snapshot", "build_native_store", + "build_report_store", "compare_json_content", "compute_committed_digests", "decide_coupling", diff --git a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py new file mode 100644 index 000000000..e2288f102 --- /dev/null +++ b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py @@ -0,0 +1,298 @@ +""" +Named-key store for hosted baseline diff reports. + +A diff report is a small static site, so its objects are addressed by path rather than by digest. +That makes it the opposite of :class:`~climate_ref_core.regression.store.NativeStore`, which is +content-addressed and must stay that way, so this is a sibling class rather than a new method on it. + +Reports live in their own public bucket, served at ``{url}/{key}``. +The bucket is separate from the baselines bucket because an R2 token cannot be scoped to a prefix, +so a token that can write reports must not also be able to overwrite baseline blobs. + +Write credentials are resolved from ``REF_REPORT_STORE_ACCESS_KEY_ID`` / +``REF_REPORT_STORE_SECRET_ACCESS_KEY``, then ``REF_REPORT_STORE_PROFILE``, then boto3's default +chain. They are never read from the persisted config. +""" + +import os +import re +import shutil +from pathlib import Path, PurePosixPath +from typing import Protocol + +from attrs import field, frozen +from loguru import logger + +from .store import ( + _AUTH_REJECTED_STATUSES, + _HTTP_FORBIDDEN, + _HTTP_NOT_FOUND, + _PREFLIGHT_PROBE_KEY, + NativeStoreUnavailableError, + S3WriteConfig, + _http_status, + _local_root, +) + +_KEY_PATTERN = re.compile(r"[A-Za-z0-9._/-]+") + + +def _validate_key(key: str) -> str: + """ + Check a report key is a safe relative path and return it. + + A key becomes a path under the local root, so a key that escapes the root or carries + shell-hostile characters is rejected before it is ever joined. + + Parameters + ---------- + key + The key to check, for example ``912/0c7e1d4abc12/index.html``. + + Returns + ------- + : + The key, unchanged. + + Raises + ------ + ValueError + If the key is empty, absolute, contains a ``..`` segment, or uses characters + outside ``[A-Za-z0-9._/-]``. + """ + if not key or not _KEY_PATTERN.fullmatch(key): + raise ValueError( + f"Invalid report store key {key!r}: keys must be non-empty and may only contain " + "letters, digits, dot, underscore, hyphen and forward slash." + ) + if key.startswith("/"): + raise ValueError(f"Invalid report store key {key!r}: keys must be relative, not absolute.") + if ".." in PurePosixPath(key).parts: + raise ValueError(f"Invalid report store key {key!r}: keys must not contain a '..' segment.") + return key + + +@frozen +class ReportStore: + """ + Named-key store for hosted baseline diff reports. + + A local store (a ``file://`` URL or a bare path) writes under its root and needs no credentials. + A remote store writes through an :class:`S3WriteConfig` and is served at ``{url}/{key}``. + + Parameters + ---------- + url + Where reports are served from: an ``http(s)://`` base URL, a ``file:///absolute/path`` + URL, or a bare filesystem path. + write + The S3 endpoint, bucket and credentials a remote store writes with. + ``None`` leaves a remote store read-only. + A local store ignores it and is always writable. + """ + + url: str + write: S3WriteConfig | None = None + root: Path | None = field(init=False) + """The local store root, or ``None`` when this store is remote.""" + + @root.default + def _resolve_root(self) -> Path | None: + """Resolve the local root once at construction, which also validates the URL.""" + return _local_root(self.url) + + def url_for(self, key: str) -> str: + """ + Return the public URL a key is served at. + + Parameters + ---------- + key + The key, relative and slash-separated. + + Returns + ------- + : + An ``http(s)://`` URL for a remote store, or a ``file://`` URL for a local one. + + Raises + ------ + ValueError + If the key is not a safe relative path. + """ + _validate_key(key) + root = self.root + if root is not None: + return (root / key).absolute().as_uri() + return f"{self.url.rstrip('/')}/{key}" + + def put(self, key: str, path: Path, content_type: str) -> str: + """ + Store ``path`` under ``key``, overwriting whatever was there. + + Reports are rewritten whenever a pull request is re-minted, so a put always replaces. + + Parameters + ---------- + key + The key to store under, relative and slash-separated. + path + The local file to store. + content_type + The MIME type the object is served with. A browser will not render an + uploaded page without it, since R2 defaults to a binary type. + + Returns + ------- + : + The URL the key is served at. + + Raises + ------ + ValueError + If the key is not a safe relative path. + NotImplementedError + If this is an anonymous remote store, which cannot write. + """ + _validate_key(key) + root = self.root + if root is not None: + dest = root / key + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(path), str(dest)) + else: + write = self.write + if write is None: + raise NotImplementedError( + f"Report store {self.url} is a public-read store, so put() is not supported. " + "Upload against a local path or a credentialed remote store." + ) + write.client().upload_file(str(path), write.bucket, key, ExtraArgs={"ContentType": content_type}) + logger.debug(f"ReportStore.put: {path} -> {key}") + return self.url_for(key) + + def preflight(self) -> None: + """ + Verify the store is reachable and usable before uploading to it. + + A local store's root is created if needed and checked for writability. + A writable remote store performs a cheap authenticated ``HEAD`` on a sentinel key, + which is expected to be absent, so a bad credential is caught before the upload starts. + + Raises + ------ + NativeStoreUnavailableError + If the store cannot be reached or used, with an operator-facing message. + """ + root = self.root + if root is not None: + try: + root.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise NativeStoreUnavailableError( + f"Local report store root {root} could not be created: {exc}" + ) from exc + if not os.access(root, os.W_OK): + raise NativeStoreUnavailableError(f"Local report store root {root} is not writable.") + logger.debug(f"Local report store ready at {root}") + return + write = self.write + if write is None: + return + + from botocore.exceptions import ClientError # noqa: PLC0415 - optional dependency + + try: + write.client().head_object(Bucket=write.bucket, Key=_PREFLIGHT_PROBE_KEY) + except ClientError as exc: + status = _http_status(exc) + if status == _HTTP_NOT_FOUND: + pass # authenticated, and the probe object is simply absent, so the store is usable + elif status in _AUTH_REJECTED_STATUSES: + raise NativeStoreUnavailableError( + f"Report store authentication failed (HTTP {status}) for bucket {write.bucket!r} at " + f"{write.endpoint_url}: the credentials were rejected or malformed. Check " + f"REF_REPORT_STORE_PROFILE, or REF_REPORT_STORE_ACCESS_KEY_ID / " + f"REF_REPORT_STORE_SECRET_ACCESS_KEY." + ) from exc + elif status == _HTTP_FORBIDDEN: + raise NativeStoreUnavailableError( + f"Report store access denied (HTTP 403) for bucket {write.bucket!r} at " + f"{write.endpoint_url}: the request was forbidden. The secret key may be wrong, " + f"or the token may lack object read and write on this bucket. Check the " + f"credentials and the token's permissions." + ) from exc + else: + raise NativeStoreUnavailableError( + f"Report store preflight failed (HTTP {status}) for bucket {write.bucket!r} at " + f"{write.endpoint_url}: {exc}" + ) from exc + logger.info(f"Report store authenticated: bucket {write.bucket!r} at {write.endpoint_url}") + + +class _ReportStoreConfigProtocol(Protocol): + """ + Structural protocol for the report-store config object expected by :func:`build_report_store`. + + Keeps ``climate_ref_core`` free of any import dependency on ``climate_ref``, so both + :class:`climate_ref.config.ReportStoreConfig` and test doubles satisfy it. + + ``s3_endpoint_url`` and ``bucket`` are non-secret routing config. Write credentials are + intentionally **not** part of this protocol, and are read from the environment instead. + """ + + @property + def url(self) -> str: ... + + @property + def s3_endpoint_url(self) -> str: ... + + @property + def bucket(self) -> str: ... + + +def build_report_store(config: _ReportStoreConfigProtocol, *, writable: bool) -> ReportStore: + """ + Build a :class:`ReportStore` from a report-store config object. + + With ``writable=False`` the returned store is anonymous and credential-free. + With ``writable=True`` and a remote URL the S3 endpoint and bucket come from the config, + and authentication is read from the environment + (``REF_REPORT_STORE_ACCESS_KEY_ID`` / ``REF_REPORT_STORE_SECRET_ACCESS_KEY``, + else ``REF_REPORT_STORE_PROFILE``, else boto3's default chain), + so secrets never live in the persisted config. + A local store is always readable and writable, so ``writable`` makes no difference to it. + + Parameters + ---------- + config + A config object providing ``url``, ``s3_endpoint_url`` and ``bucket``. + Typically ``app_config.report_store``. + writable + Whether the store must be able to write. + + Returns + ------- + : + The configured store. + + Raises + ------ + ValueError + If the URL scheme is unrecognised, or a writable remote store is requested + without an S3 endpoint / bucket configured. + """ + store = ReportStore(url=config.url) + if not writable or store.root is not None: + # A local store is already writable, and a read-only store needs no credentials. + return store + return ReportStore( + url=config.url, + write=S3WriteConfig( + endpoint_url=config.s3_endpoint_url, + bucket=config.bucket, + access_key_id=os.environ.get("REF_REPORT_STORE_ACCESS_KEY_ID", ""), + secret_access_key=os.environ.get("REF_REPORT_STORE_SECRET_ACCESS_KEY", ""), + profile=os.environ.get("REF_REPORT_STORE_PROFILE", ""), + ), + ) diff --git a/packages/climate-ref-core/tests/unit/regression/test_report_store.py b/packages/climate-ref-core/tests/unit/regression/test_report_store.py new file mode 100644 index 000000000..fcec64d29 --- /dev/null +++ b/packages/climate-ref-core/tests/unit/regression/test_report_store.py @@ -0,0 +1,180 @@ +from pathlib import Path + +import pytest +from botocore.exceptions import ClientError +from pytest_mock import MockerFixture + +from climate_ref_core.regression.report_store import ( + ReportStore, + build_report_store, +) +from climate_ref_core.regression.store import NativeStoreUnavailableError + +REMOTE_URL = "https://reports.example.com" +S3_ENDPOINT = "https://account.r2.cloudflarestorage.com" +BUCKET = "ref-baseline-reports" +KEY = "912/0c7e1d4abc12/index.html" +HTML = "text/html; charset=utf-8" + + +def _client_error(code: str, status: int, operation: str = "HeadObject") -> ClientError: + """Build a botocore ``ClientError`` with the given S3 error code / HTTP status.""" + return ClientError( + {"Error": {"Code": code, "Message": code}, "ResponseMetadata": {"HTTPStatusCode": status}}, + operation, + ) + + +@pytest.fixture() +def page(tmp_path: Path) -> Path: + p = tmp_path / "index.html" + p.write_text("

report

", encoding="utf-8") + return p + + +@pytest.fixture() +def local_store(tmp_path: Path) -> ReportStore: + return ReportStore(url=str(tmp_path / "reports")) + + +class _StubConfig: + """Minimal config double satisfying _ReportStoreConfigProtocol.""" + + def __init__(self, url: str, s3_endpoint_url: str = S3_ENDPOINT, bucket: str = BUCKET) -> None: + self._url = url + self._s3_endpoint_url = s3_endpoint_url + self._bucket = bucket + + @property + def url(self) -> str: + return self._url + + @property + def s3_endpoint_url(self) -> str: + return self._s3_endpoint_url + + @property + def bucket(self) -> str: + return self._bucket + + +class TestLocalStore: + def test_put_lands_the_file_under_the_key(self, local_store: ReportStore, page: Path) -> None: + url = local_store.put(KEY, page, HTML) + + assert local_store.root is not None + landed = local_store.root / KEY + assert landed.read_text(encoding="utf-8") == "

report

" + assert url == landed.absolute().as_uri() + assert url.startswith("file://") + assert url.endswith(KEY) + + def test_put_overwrites(self, local_store: ReportStore, page: Path, tmp_path: Path) -> None: + local_store.put(KEY, page, HTML) + newer = tmp_path / "newer.html" + newer.write_text("

newer

", encoding="utf-8") + + local_store.put(KEY, newer, HTML) + + assert local_store.root is not None + assert (local_store.root / KEY).read_text(encoding="utf-8") == "

newer

" + + @pytest.mark.parametrize("key", ["../x", "a/../../x", "/abs/index.html", "a b/index.html", ""]) + def test_put_rejects_an_unsafe_key(self, local_store: ReportStore, page: Path, key: str) -> None: + with pytest.raises(ValueError, match="report store key"): + local_store.put(key, page, HTML) + + def test_url_for_is_the_root_joined_with_the_key(self, local_store: ReportStore) -> None: + assert local_store.root is not None + assert local_store.url_for(KEY) == (local_store.root / KEY).absolute().as_uri() + + def test_preflight_creates_the_root(self, local_store: ReportStore) -> None: + local_store.preflight() + + assert local_store.root is not None + assert local_store.root.is_dir() + + def test_preflight_reports_an_uncreatable_root(self, tmp_path: Path) -> None: + blocker = tmp_path / "blocker" + blocker.write_text("not a directory", encoding="utf-8") + store = ReportStore(url=str(blocker / "reports")) + + with pytest.raises(NativeStoreUnavailableError, match="could not be created"): + store.preflight() + + +class TestRemoteStore: + """The credentialed R2 write path, with a mocked boto3 client.""" + + def _store(self, mocker: MockerFixture, client, **kwargs) -> ReportStore: + mocker.patch("climate_ref_core.regression.store._s3_client", return_value=client) + return build_report_store(_StubConfig(REMOTE_URL, **kwargs), writable=True) + + def test_url_for_is_served_from_the_base_url(self) -> None: + store = ReportStore(url="https://h") + + assert store.url_for("1/a/index.html") == "https://h/1/a/index.html" + + def test_url_for_ignores_a_trailing_slash(self) -> None: + assert ReportStore(url="https://h/").url_for("1/a/index.html") == "https://h/1/a/index.html" + + def test_put_sets_the_content_type(self, mocker: MockerFixture, page: Path) -> None: + client = mocker.MagicMock() + store = self._store(mocker, client) + + url = store.put(KEY, page, HTML) + + client.upload_file.assert_called_once_with(str(page), BUCKET, KEY, ExtraArgs={"ContentType": HTML}) + assert url == f"{REMOTE_URL}/{KEY}" + + def test_put_without_credentials_is_read_only(self, page: Path) -> None: + store = ReportStore(url=REMOTE_URL) + + with pytest.raises(NotImplementedError, match="public-read store"): + store.put(KEY, page, HTML) + + def test_preflight_accepts_a_missing_probe(self, mocker: MockerFixture) -> None: + client = mocker.MagicMock() + client.head_object.side_effect = _client_error("404", 404) + store = self._store(mocker, client) + + store.preflight() + + client.head_object.assert_called_once() + + def test_preflight_rejects_bad_credentials(self, mocker: MockerFixture) -> None: + client = mocker.MagicMock() + client.head_object.side_effect = _client_error("InvalidAccessKeyId", 401) + store = self._store(mocker, client) + + with pytest.raises(NativeStoreUnavailableError, match="REF_REPORT_STORE_ACCESS_KEY_ID"): + store.preflight() + + +class TestBuildReportStore: + def test_reads_the_report_store_env_vars(self, mocker: MockerFixture, monkeypatch) -> None: + monkeypatch.setenv("REF_REPORT_STORE_ACCESS_KEY_ID", "report-key") + monkeypatch.setenv("REF_REPORT_STORE_SECRET_ACCESS_KEY", "report-secret") + monkeypatch.setenv("REF_REPORT_STORE_PROFILE", "report-profile") + monkeypatch.setenv("REF_NATIVE_STORE_ACCESS_KEY_ID", "native-key") + monkeypatch.setenv("REF_NATIVE_STORE_SECRET_ACCESS_KEY", "native-secret") + monkeypatch.setenv("REF_NATIVE_STORE_PROFILE", "native-profile") + factory = mocker.patch( + "climate_ref_core.regression.store._s3_client", return_value=mocker.MagicMock() + ) + + store = build_report_store(_StubConfig(REMOTE_URL), writable=True) + store.put(KEY, Path(__file__), HTML) + + factory.assert_called_once_with(S3_ENDPOINT, "report-key", "report-secret", "report-profile") + + def test_read_only_needs_no_credentials(self) -> None: + store = build_report_store(_StubConfig(REMOTE_URL), writable=False) + + assert store.write is None + + def test_a_local_store_is_writable_without_credentials(self, tmp_path: Path) -> None: + store = build_report_store(_StubConfig(str(tmp_path / "reports")), writable=True) + + assert store.write is None + assert store.root == tmp_path / "reports" diff --git a/packages/climate-ref/src/climate_ref/baseline_report/render.py b/packages/climate-ref/src/climate_ref/baseline_report/render.py index d67414962..283df89a1 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/render.py +++ b/packages/climate-ref/src/climate_ref/baseline_report/render.py @@ -10,12 +10,15 @@ from pathlib import Path from typing import TYPE_CHECKING +from attrs import frozen from jinja2 import Environment, PackageLoader, select_autoescape from climate_ref.baseline_report.analyse import SHORT_DIGEST if TYPE_CHECKING: - from climate_ref.baseline_report.analyse import AnalysedCase, AnalysedReport + from collections.abc import Sequence + + from climate_ref.baseline_report.analyse import AnalysedCase, AnalysedReport, KindCounts def _format_bytes(size: int | None) -> str: @@ -137,7 +140,174 @@ def _build_env() -> Environment: return env +def _build_text_env() -> Environment: + """ + Build the Jinja environment the markdown comment is rendered with. + + Escaping is off, because the comment is markdown rather than HTML, + and a case label escaped as HTML entities would render literally on GitHub. + + Returns + ------- + : + The environment. + """ + return Environment( + loader=PackageLoader("climate_ref.baseline_report", "templates"), + autoescape=False, # noqa: S701 - markdown output, escaping would show as entities + trim_blocks=True, + lstrip_blocks=True, + ) + + _env = _build_env() +_text_env = _build_text_env() + + +@frozen +class CommentLink: + """One earlier report for the same pull request.""" + + label: str + """Short name for the report, typically the head sha it was built from.""" + + url: str + """Where that report is hosted.""" + + +@frozen +class CommentRow: + """One test case's row in the pull request comment table.""" + + label: str + """The test case's label, for example ``example/diag/case``.""" + + versions: str + """``v3 -> v4``, or ``new`` / ``removed`` when the case only exists on one side.""" + + images: str + """The ``+a ~c -r`` shorthand for image changes, or ``none``.""" + + text: str + """The shorthand for text changes.""" + + netcdf: str + """The shorthand for NetCDF changes.""" + + other: str + """The shorthand for everything else.""" + + url: str + """Link to the case's page in the hosted report.""" + + +def _shorthand(counts: KindCounts) -> str: + """ + Summarise one kind's changes as a short ``+a ~c -r`` string. + + Parameters + ---------- + counts + The tallied counts for one file kind. + + Returns + ------- + : + For example ``+1 ~2``, or ``none`` when nothing of that kind changed. + """ + parts = [ + f"+{counts.added}" if counts.added else "", + f"~{counts.changed}" if counts.changed else "", + f"-{counts.removed}" if counts.removed else "", + ] + return " ".join(p for p in parts if p) or "none" + + +def _versions(case: AnalysedCase) -> str: + """ + Describe how a case's version moved. + + Parameters + ---------- + case + The analysed case. + + Returns + ------- + : + ``v3 -> v4``, or ``new`` / ``removed`` when the case only exists on one side. + """ + if case.change.is_removed: + return "removed" + if case.change.is_new: + return "new" + base, head = case.change.base, case.change.head + if base is None or head is None: # pragma: no cover - is_new / is_removed already cover this + return "changed" + return f"v{base.test_case_version} -> v{head.test_case_version}" + + +def _comment_row(case: AnalysedCase, base_url: str) -> CommentRow: + """ + Build one table row from an analysed case. + + Parameters + ---------- + case + The analysed case. + base_url + Where the report is hosted, without a trailing slash. + + Returns + ------- + : + The row. + """ + by_kind = {count.label: _shorthand(count) for count in case.counts} + return CommentRow( + label=case.change.label, + versions=_versions(case), + images=by_kind["image"], + text=by_kind["text"], + netcdf=by_kind["netcdf"], + other=by_kind["other"], + url=f"{base_url}/{case.change.label}/index.html", + ) + + +def render_comment( + report: AnalysedReport, + base_url: str, + previous: Sequence[tuple[str, str]] = (), +) -> str: + """ + Render the pull request comment as markdown. + + The comment is one row per changed case and a link into the hosted report, + so it stays well inside GitHub's comment size limit however many files moved. + + Parameters + ---------- + report + The analysed report. + base_url + Where the report is hosted, for example ``https://reports.example/912/0c7e1d4abc12``. + A trailing slash is ignored. + previous + ``(label, url)`` pairs for earlier reports on the same pull request. + + Returns + ------- + : + The comment's markdown, ending in the marker the CI job finds it by. + """ + root = base_url.rstrip("/") + return _text_env.get_template("comment.md.j2").render( + report=report, + cases=[_comment_row(case, root) for case in report.cases], + index_url=f"{root}/index.html", + previous=[CommentLink(label=label, url=url) for label, url in previous], + ) def render_index(report: AnalysedReport) -> str: diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/comment.md.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/comment.md.j2 new file mode 100644 index 000000000..ef1ee8cd9 --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/comment.md.j2 @@ -0,0 +1,19 @@ +### Regression baseline diff + +{% if cases %} +{{ cases | length }} test case(s) changed against `{{ report.report.base_ref }}`. [Full report]({{ index_url }}) + +| case | versions | images | text | netcdf | other | | +| --- | --- | --- | --- | --- | --- | --- | +{% for case in cases %} +| `{{ case.label }}` | {{ case.versions }} | {{ case.images }} | {{ case.text }} | {{ case.netcdf }} | {{ case.other }} | [view]({{ case.url }}) | +{% endfor %} +{% else %} +No baseline manifests changed against `{{ report.report.base_ref }}`. +{% endif %} +{% if previous %} + +_Previous reports for this pull request: {% for p in previous %}[{{ p.label }}]({{ p.url }}){{ ", " if not loop.last }}{% endfor %}_ +{% endif %} + + diff --git a/packages/climate-ref/src/climate_ref/baseline_report/upload.py b/packages/climate-ref/src/climate_ref/baseline_report/upload.py new file mode 100644 index 000000000..379806316 --- /dev/null +++ b/packages/climate-ref/src/climate_ref/baseline_report/upload.py @@ -0,0 +1,76 @@ +""" +Push a rendered report into the report store. + +The store is a plain object store with no directory semantics, so every file is uploaded under an +explicit key and the content type has to be set per object or a browser will download the page +instead of rendering it. +""" + +from __future__ import annotations + +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING + +from loguru import logger + +if TYPE_CHECKING: + from climate_ref_core.regression.report_store import ReportStore + +CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", +} +"""Content types for the file kinds a report is made of.""" + +DEFAULT_CONTENT_TYPE = "application/octet-stream" +"""Served for anything else, which a browser will offer as a download.""" + + +def content_type_for(path: Path) -> str: + """ + Return the content type a report file should be served with. + + Parameters + ---------- + path + The file, which is classified by its extension. + + Returns + ------- + : + The MIME type, or :data:`DEFAULT_CONTENT_TYPE` for an unrecognised extension. + """ + return CONTENT_TYPES.get(path.suffix.lower(), DEFAULT_CONTENT_TYPE) + + +def upload_site(out_dir: Path, store: ReportStore, prefix: str) -> str: + """ + Upload every file under ``out_dir`` as ``prefix/``. + + Parameters + ---------- + out_dir + The rendered site. + store + The store to upload into. + prefix + The key prefix the report is published under, for example ``912/0c7e1d4abc12``. + Validated by :meth:`~climate_ref_core.regression.report_store.ReportStore.put`. + + Returns + ------- + : + The URL of the report's index page, whether or not that page exists. + """ + count = 0 + for path in sorted(out_dir.rglob("*")): + if not path.is_file(): + continue + key = str(PurePosixPath(prefix, *path.relative_to(out_dir).parts)) + store.put(key, path, content_type_for(path)) + count += 1 + logger.info(f"Uploaded {count} report file(s) under {prefix}") + return store.url_for(f"{prefix}/index.html") diff --git a/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py index 9063513fb..6d6167d8a 100644 --- a/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py +++ b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py @@ -24,7 +24,7 @@ @app.command(name="diff") -def diff_baselines( +def diff_baselines( # noqa: PLR0913 ctx: typer.Context, html_dir: Annotated[Path, typer.Option(help="Directory to write the HTML report into")], base: Annotated[ @@ -35,6 +35,14 @@ def diff_baselines( bool, typer.Option("--no-fetch", help="Skip blob downloads and report sizes only"), ] = False, + upload: Annotated[ + str | None, + typer.Option(help="Upload the report under this key prefix, e.g. 912/0c7e1d4abc12"), + ] = None, + comment_output: Annotated[ + Path | None, + typer.Option(help="Write the pull request comment markdown here"), + ] = None, ) -> None: """ Render an HTML report of the regression baselines changed on this branch. @@ -49,11 +57,14 @@ def diff_baselines( ref test-cases diff --html-dir build/baseline-diff ref test-cases diff --base origin/develop --html-dir build/baseline-diff ref test-cases diff --html-dir build/baseline-diff --no-fetch + ref test-cases diff --html-dir build/baseline-diff --upload 912/0c7e1d4abc12 """ from climate_ref.baseline_report.analyse import analyse from climate_ref.baseline_report.collect import collect - from climate_ref.baseline_report.render import write_site - from climate_ref_core.regression.store import build_native_store + from climate_ref.baseline_report.render import render_comment, write_site + from climate_ref.baseline_report.upload import upload_site + from climate_ref_core.regression.report_store import build_report_store + from climate_ref_core.regression.store import NativeStoreUnavailableError, build_native_store config: Config = ctx.obj.config console: Console = ctx.obj.console @@ -70,3 +81,24 @@ def diff_baselines( index = write_site(analysed, html_dir) console.print(f"Wrote {len(analysed.cases)} case page(s) to {index}") + + base_url = index.parent.resolve().as_uri() + if upload is not None: + try: + report_store = build_report_store(config.report_store, writable=True) + report_store.preflight() + upload_site(html_dir, report_store, upload) + base_url = report_store.url_for(upload) + except (NotImplementedError, ValueError, ImportError, NativeStoreUnavailableError) as exc: + logger.error( + f"Could not upload the report: {exc} Check REF_REPORT_STORE_URL, and for a remote " + "store REF_REPORT_STORE_ACCESS_KEY_ID / REF_REPORT_STORE_SECRET_ACCESS_KEY plus the " + "'climate-ref-core[aws]' extra." + ) + raise typer.Exit(code=1) from exc + console.print(f"Uploaded the report to {base_url}/index.html") + + if comment_output is not None: + comment_output.parent.mkdir(parents=True, exist_ok=True) + comment_output.write_text(render_comment(analysed, base_url), encoding="utf-8") + console.print(f"Wrote the pull request comment to {comment_output}") diff --git a/packages/climate-ref/src/climate_ref/config.py b/packages/climate-ref/src/climate_ref/config.py index 00cb95a75..09565f0a2 100644 --- a/packages/climate-ref/src/climate_ref/config.py +++ b/packages/climate-ref/src/climate_ref/config.py @@ -289,6 +289,54 @@ def _cache_dir_factory(self) -> Path: return resolve_cache_dir("native-baselines") +@config(prefix=env_prefix) +class ReportStoreConfig: + """ + Configuration for the public store that hosts baseline diff reports. + + A diff report is a small static site keyed by pull request and head sha, + so this store is addressed by path rather than by digest. + It is a different bucket to the native store because an R2 token cannot be scoped to a prefix, + so a token that can write reports must not also be able to overwrite baseline blobs. + + Write credentials are **not** stored here: the access-key id and secret-access-key are read from + ``REF_REPORT_STORE_ACCESS_KEY_ID`` / ``REF_REPORT_STORE_SECRET_ACCESS_KEY`` + (falling back to boto3's default credential chain) at upload time only, + so secrets never land in a serialised config. + """ + + url: str = env_field(name="REPORT_STORE_URL", default="https://reports.baselines.climate-ref.org") + """ + Base URL the reports are served from. + + Reports are served at ``{url}/{key}``. + Defaults to the production Climate-REF reports endpoint. + + Set ``REF_REPORT_STORE_URL`` to a local ``file:///path/to/dir`` (or a plain filesystem path) + to render and inspect an upload offline. + """ + + s3_endpoint_url: str = env_field( + name="REPORT_STORE_S3_ENDPOINT_URL", + default="https://2aa5172b2bba093c516027d6fa13cdc8.r2.cloudflarestorage.com", + ) + """ + S3 API endpoint for the writable (Cloudflare R2) backend, without the bucket. + + Non-secret routing config, consumed only when uploading a report. + Defaults to the production Climate-REF R2 account endpoint. + Set ``REF_REPORT_STORE_S3_ENDPOINT_URL`` to override (e.g. a staging account). + """ + + bucket: str = env_field(name="REPORT_STORE_BUCKET", default="ref-baseline-reports") + """ + Name of the writable (Cloudflare R2) reports bucket. + + Non-secret routing config, consumed only when uploading a report. + Set ``REF_REPORT_STORE_BUCKET`` to override. + """ + + @config(prefix=env_prefix) class ExecutorConfig: """ @@ -752,6 +800,7 @@ class Config: paths: PathConfig = Factory(PathConfig) native_store: NativeStoreConfig = Factory(NativeStoreConfig) + report_store: ReportStoreConfig = Factory(ReportStoreConfig) db: DbConfig = Factory(DbConfig) executor: ExecutorConfig = Factory(ExecutorConfig) diagnostic_providers: list[DiagnosticProviderConfig] = Factory(default_providers) # noqa: RUF009, RUF100 diff --git a/packages/climate-ref/tests/unit/baseline_report/test_render.py b/packages/climate-ref/tests/unit/baseline_report/test_render.py index 847ac5858..2a8a0a3df 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_render.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_render.py @@ -16,7 +16,7 @@ analyse, ) from climate_ref.baseline_report.collect import CaseChange, FileChange, FileKind, Report, classify -from climate_ref.baseline_report.render import render_case, render_index, write_site +from climate_ref.baseline_report.render import render_case, render_comment, render_index, write_site from climate_ref_core.regression.manifest import SCHEMA_VERSION, Manifest, NativeEntry from climate_ref_core.regression.store import NativeStore @@ -487,3 +487,106 @@ def test_an_absent_side_says_so_in_the_split_view(self, tmp_path): html = render_case(report, report.cases[0]) assert '
absent
' in html + + +BASE_URL = "https://reports.example/912/0c7e1d4abc12" + +MARKER = "" + + +def _rows(markdown: str) -> list[str]: + """Return the table's body rows.""" + lines = [line for line in markdown.splitlines() if line.startswith("| `")] + return lines + + +class TestComment: + def test_one_row_per_case(self, tmp_path): + report = _analysed( + [ + _case_change([], label="example/diag/a", base=_manifest(1), head=_manifest(2)), + _case_change([], label="pmp/diag/b", base=_manifest(1), head=_manifest(2)), + ], + tmp_path, + ) + + markdown = render_comment(report, BASE_URL) + + assert len(_rows(markdown)) == 2 + assert "`example/diag/a`" in markdown + assert "`pmp/diag/b`" in markdown + + def test_every_row_links_into_the_hosted_report(self, changed_image_case): + markdown = render_comment(changed_image_case, BASE_URL) + + assert f"[view]({BASE_URL}/example/diag/case/index.html)" in markdown + assert f"[Full report]({BASE_URL}/index.html)" in markdown + + def test_a_trailing_slash_on_the_base_url_is_ignored(self, changed_image_case): + markdown = render_comment(changed_image_case, BASE_URL + "/") + + assert "//index.html" not in markdown + + def test_rows_stay_small(self, changed_image_case): + markdown = render_comment(changed_image_case, BASE_URL) + + assert all(len(row.encode("utf-8")) <= 300 for row in _rows(markdown)) + + def test_counts_use_the_short_form(self, tmp_path): + report = _analysed( + [ + _case_change( + [ + _change("added.png", None, _entry("1")), + _change("moved.png", _entry("2"), _entry("3")), + _change("gone.txt", _entry("4"), None), + ], + base=_manifest(3), + head=_manifest(4), + ) + ], + tmp_path, + ) + + row = _rows(render_comment(report, BASE_URL))[0] + + assert "| v3 -> v4 |" in row + assert "| +1 ~1 |" in row + assert "| -1 |" in row + assert "| none |" in row + + @pytest.mark.parametrize( + "base, head, expected", + [(_manifest(3), None, "removed"), (None, _manifest(4), "new")], + ) + def test_a_one_sided_case_says_so(self, tmp_path, base, head, expected): + report = _analysed([_case_change([], base=base, head=head)], tmp_path) + + assert f"| {expected} |" in _rows(render_comment(report, BASE_URL))[0] + + def test_zero_cases_renders_the_no_change_message(self, tmp_path): + markdown = render_comment(_analysed([], tmp_path), BASE_URL) + + assert "No baseline manifests changed against `origin/main`." in markdown + assert "| case |" not in markdown + assert MARKER in markdown + + def test_the_marker_appears_once(self, changed_image_case): + assert render_comment(changed_image_case, BASE_URL).count(MARKER) == 1 + + def test_previous_reports_are_listed(self, changed_image_case): + markdown = render_comment( + changed_image_case, + BASE_URL, + previous=[("abc1234", "https://reports.example/912/abc1234/index.html")], + ) + + assert "[abc1234](https://reports.example/912/abc1234/index.html)" in markdown + + def test_no_previous_reports_leaves_the_line_out(self, changed_image_case): + assert "Previous reports" not in render_comment(changed_image_case, BASE_URL) + + def test_labels_are_not_html_escaped(self, tmp_path): + report = _analysed([_case_change([], base=_manifest(3), head=_manifest(4))], tmp_path) + + assert "->" not in render_comment(report, BASE_URL) diff --git a/packages/climate-ref/tests/unit/baseline_report/test_upload.py b/packages/climate-ref/tests/unit/baseline_report/test_upload.py new file mode 100644 index 000000000..f3b972820 --- /dev/null +++ b/packages/climate-ref/tests/unit/baseline_report/test_upload.py @@ -0,0 +1,86 @@ +"""Tests for pushing a rendered report into the report store.""" + +from pathlib import Path + +import pytest +from attrs import define, field + +from climate_ref.baseline_report.upload import DEFAULT_CONTENT_TYPE, content_type_for, upload_site + +PREFIX = "912/0c7e1d4abc12" + + +@define +class RecordingStore: + """A report store that records what was put rather than storing it.""" + + url: str = "https://reports.example" + puts: list[tuple[str, Path, str]] = field(factory=list) + + def put(self, key: str, path: Path, content_type: str) -> str: + self.puts.append((key, path, content_type)) + return self.url_for(key) + + def url_for(self, key: str) -> str: + return f"{self.url}/{key}" + + +@pytest.fixture +def site(tmp_path: Path) -> Path: + """A three-file site with one page nested a directory deep.""" + out = tmp_path / "site" + (out / "example" / "diag").mkdir(parents=True) + (out / "index.html").write_text("

index

", encoding="utf-8") + (out / "report.css").write_text("body {}", encoding="utf-8") + (out / "example" / "diag" / "index.html").write_text("

case

", encoding="utf-8") + return out + + +@pytest.mark.parametrize( + "name, expected", + [ + ("index.html", "text/html; charset=utf-8"), + ("report.CSS", "text/css; charset=utf-8"), + ("report.js", "text/javascript; charset=utf-8"), + ("plot.png", "image/png"), + ("plot.svg", "image/svg+xml"), + ("blob.bin", DEFAULT_CONTENT_TYPE), + ("no-extension", DEFAULT_CONTENT_TYPE), + ], +) +def test_content_type_for(name, expected): + assert content_type_for(Path(name)) == expected + + +class TestUploadSite: + def test_every_file_lands_under_the_prefix(self, site): + store = RecordingStore() + + upload_site(site, store, PREFIX) + + assert sorted(key for key, _, _ in store.puts) == [ + f"{PREFIX}/example/diag/index.html", + f"{PREFIX}/index.html", + f"{PREFIX}/report.css", + ] + + def test_content_types_are_set(self, site): + store = RecordingStore() + + upload_site(site, store, PREFIX) + + by_key = {key: content_type for key, _, content_type in store.puts} + assert by_key[f"{PREFIX}/index.html"] == "text/html; charset=utf-8" + assert by_key[f"{PREFIX}/report.css"] == "text/css; charset=utf-8" + + def test_returns_the_index_url(self, site): + store = RecordingStore() + + assert upload_site(site, store, PREFIX) == f"https://reports.example/{PREFIX}/index.html" + + def test_directories_are_not_uploaded(self, site): + store = RecordingStore() + + upload_site(site, store, PREFIX) + + assert all(path.is_file() for _, path, _ in store.puts) diff --git a/packages/climate-ref/tests/unit/cli/test_test_cases.py b/packages/climate-ref/tests/unit/cli/test_test_cases.py index 3801b9e2e..b2e4e453d 100644 --- a/packages/climate-ref/tests/unit/cli/test_test_cases.py +++ b/packages/climate-ref/tests/unit/cli/test_test_cases.py @@ -2930,3 +2930,98 @@ def test_a_changed_case_gets_a_page(self, invoke_cli, mocker, tmp_path): assert page.exists() assert page.read_text().count("") == 1 + + def test_an_unsafe_upload_prefix_exits_one(self, invoke_cli, mocker, tmp_path, monkeypatch): + monkeypatch.setenv("REF_REPORT_STORE_URL", str(tmp_path / "reports")) + + repo = MagicMock() + repo.working_tree_dir = str(tmp_path) + repo.git.diff.return_value = "" + repo.head.commit.hexsha = "a" * 40 + mocker.patch("climate_ref.cli.test_cases.diff.get_repo_for_path", return_value=repo) + + invoke_cli( + [ + "test-cases", + "diff", + "--html-dir", + str(tmp_path / "out"), + "--no-fetch", + "--upload", + "../escape", + ], + expected_exit_code=1, + ) + + def test_the_comment_links_locally_without_upload(self, invoke_cli, mocker, tmp_path): + from climate_ref_core.regression.manifest import SCHEMA_VERSION, Manifest + + rel_path = "packages/climate-ref-example/tests/test-data/diag/case/manifest.json" + manifest_path = tmp_path / rel_path + manifest_path.parent.mkdir(parents=True) + Manifest( + schema=SCHEMA_VERSION, + test_case_version=4, + diagnostic_version=1, + committed={}, + native={}, + ).dump(manifest_path) + + repo = MagicMock() + repo.working_tree_dir = str(tmp_path) + repo.git.diff.return_value = rel_path + repo.git.show.return_value = json.dumps( + { + "schema": SCHEMA_VERSION, + "test_case_version": 3, + "diagnostic_version": 1, + "committed": {}, + "native": {}, + } + ) + repo.head.commit.hexsha = "c" * 40 + mocker.patch("climate_ref.cli.test_cases.diff.get_repo_for_path", return_value=repo) + + out = tmp_path / "out" + comment = tmp_path / "comment.md" + invoke_cli( + [ + "test-cases", + "diff", + "--html-dir", + str(out), + "--no-fetch", + "--comment-output", + str(comment), + ] + ) + + assert f"{out.resolve().as_uri()}/index.html" in comment.read_text() diff --git a/packages/climate-ref/tests/unit/test_config.py b/packages/climate-ref/tests/unit/test_config.py index c2edeb509..85ddace6b 100644 --- a/packages/climate-ref/tests/unit/test_config.py +++ b/packages/climate-ref/tests/unit/test_config.py @@ -226,6 +226,11 @@ def test_defaults(self, monkeypatch, mocker): "bucket": "ref-baselines-public", "cache_dir": str(resolve_cache_dir("native-baselines")), }, + "report_store": { + "url": "https://reports.baselines.climate-ref.org", + "s3_endpoint_url": "https://2aa5172b2bba093c516027d6fa13cdc8.r2.cloudflarestorage.com", + "bucket": "ref-baseline-reports", + }, "paths": { "log": f"{default_path}/log", "results": f"{default_path}/results", @@ -256,6 +261,19 @@ def test_from_env_variables(self, monkeypatch, config): assert config_new.paths.results == Path("/my/test/executions") assert config_new.cmip6_parser == "drs" + def test_report_store_defaults(self, config): + assert config.report_store.url == "https://reports.baselines.climate-ref.org" + assert config.report_store.bucket == "ref-baseline-reports" + + def test_report_store_from_env_variables(self, monkeypatch, config): + monkeypatch.setenv("REF_REPORT_STORE_URL", "file:///tmp/ref-reports") + monkeypatch.setenv("REF_REPORT_STORE_BUCKET", "staging-reports") + + config_new = config.refresh() + + assert config_new.report_store.url == "file:///tmp/ref-reports" + assert config_new.report_store.bucket == "staging-reports" + def test_measure_resources_defaults_on(self, config): assert config.executor.measure_resources is True From 4240e8c3594975dec906687e8c64fd6875986574 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 12:35:38 +1000 Subject: [PATCH 58/64] fix: address the review of the report upload - `build_report_store` now validates its own routing, so a blank endpoint or bucket names `REF_REPORT_STORE_*` rather than the native store's variables. - The comment table is driven off the report's kinds, the way the index page already is, so a new file kind gets a column instead of being dropped. - One environment builder serves both templates, since only escaping differs between them. --- .../regression/report_store.py | 10 ++++ .../unit/regression/test_report_store.py | 11 ++++ .../src/climate_ref/baseline_report/render.py | 55 +++++-------------- .../baseline_report/templates/comment.md.j2 | 6 +- .../src/climate_ref/cli/test_cases/diff.py | 4 +- .../tests/unit/baseline_report/test_render.py | 17 +++++- 6 files changed, 55 insertions(+), 48 deletions(-) diff --git a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py index e2288f102..6b34d7d6b 100644 --- a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py +++ b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py @@ -286,6 +286,16 @@ def build_report_store(config: _ReportStoreConfigProtocol, *, writable: bool) -> if not writable or store.root is not None: # A local store is already writable, and a read-only store needs no credentials. return store + # Checked here rather than in S3WriteConfig, whose message names the native store's env vars. + if not config.s3_endpoint_url: + raise ValueError( + "Uploading a report needs an S3 endpoint URL. Set REF_REPORT_STORE_S3_ENDPOINT_URL " + "(e.g. https://.r2.cloudflarestorage.com)." + ) + if not config.bucket: + raise ValueError( + "Uploading a report needs a bucket name. Set REF_REPORT_STORE_BUCKET (e.g. ref-baseline-reports)." + ) return ReportStore( url=config.url, write=S3WriteConfig( diff --git a/packages/climate-ref-core/tests/unit/regression/test_report_store.py b/packages/climate-ref-core/tests/unit/regression/test_report_store.py index fcec64d29..9b639cd96 100644 --- a/packages/climate-ref-core/tests/unit/regression/test_report_store.py +++ b/packages/climate-ref-core/tests/unit/regression/test_report_store.py @@ -173,6 +173,17 @@ def test_read_only_needs_no_credentials(self) -> None: assert store.write is None + @pytest.mark.parametrize( + "kwargs, expected", + [ + ({"s3_endpoint_url": ""}, "REF_REPORT_STORE_S3_ENDPOINT_URL"), + ({"bucket": ""}, "REF_REPORT_STORE_BUCKET"), + ], + ) + def test_missing_routing_names_the_report_store_env_var(self, kwargs, expected: str) -> None: + with pytest.raises(ValueError, match=expected): + build_report_store(_StubConfig(REMOTE_URL, **kwargs), writable=True) + def test_a_local_store_is_writable_without_credentials(self, tmp_path: Path) -> None: store = build_report_store(_StubConfig(str(tmp_path / "reports")), writable=True) diff --git a/packages/climate-ref/src/climate_ref/baseline_report/render.py b/packages/climate-ref/src/climate_ref/baseline_report/render.py index 283df89a1..445c5f033 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/render.py +++ b/packages/climate-ref/src/climate_ref/baseline_report/render.py @@ -117,9 +117,15 @@ def _format_short(digest: str | None) -> str: return digest[:SHORT_DIGEST] -def _build_env() -> Environment: +def _build_env(*, escape: bool) -> Environment: """ - Build the Jinja environment the report is rendered with. + Build a Jinja environment for the templates in this package. + + Parameters + ---------- + escape + Whether to escape values. Off for the markdown comment, where an escaped case label + would render as HTML entities on GitHub. Returns ------- @@ -128,7 +134,7 @@ def _build_env() -> Environment: """ env = Environment( loader=PackageLoader("climate_ref.baseline_report", "templates"), - autoescape=select_autoescape(["html", "j2"]), + autoescape=select_autoescape(["html", "j2"]) if escape else False, # noqa: S701 trim_blocks=True, lstrip_blocks=True, ) @@ -140,28 +146,8 @@ def _build_env() -> Environment: return env -def _build_text_env() -> Environment: - """ - Build the Jinja environment the markdown comment is rendered with. - - Escaping is off, because the comment is markdown rather than HTML, - and a case label escaped as HTML entities would render literally on GitHub. - - Returns - ------- - : - The environment. - """ - return Environment( - loader=PackageLoader("climate_ref.baseline_report", "templates"), - autoescape=False, # noqa: S701 - markdown output, escaping would show as entities - trim_blocks=True, - lstrip_blocks=True, - ) - - -_env = _build_env() -_text_env = _build_text_env() +_env = _build_env(escape=True) +_text_env = _build_env(escape=False) @frozen @@ -185,17 +171,8 @@ class CommentRow: versions: str """``v3 -> v4``, or ``new`` / ``removed`` when the case only exists on one side.""" - images: str - """The ``+a ~c -r`` shorthand for image changes, or ``none``.""" - - text: str - """The shorthand for text changes.""" - - netcdf: str - """The shorthand for NetCDF changes.""" - - other: str - """The shorthand for everything else.""" + counts: tuple[str, ...] + """The ``+a ~c -r`` shorthand per file kind, in the report's column order.""" url: str """Link to the case's page in the hosted report.""" @@ -263,14 +240,10 @@ def _comment_row(case: AnalysedCase, base_url: str) -> CommentRow: : The row. """ - by_kind = {count.label: _shorthand(count) for count in case.counts} return CommentRow( label=case.change.label, versions=_versions(case), - images=by_kind["image"], - text=by_kind["text"], - netcdf=by_kind["netcdf"], - other=by_kind["other"], + counts=tuple(_shorthand(count) for count in case.counts), url=f"{base_url}/{case.change.label}/index.html", ) diff --git a/packages/climate-ref/src/climate_ref/baseline_report/templates/comment.md.j2 b/packages/climate-ref/src/climate_ref/baseline_report/templates/comment.md.j2 index ef1ee8cd9..0c0749ddc 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/templates/comment.md.j2 +++ b/packages/climate-ref/src/climate_ref/baseline_report/templates/comment.md.j2 @@ -3,10 +3,10 @@ {% if cases %} {{ cases | length }} test case(s) changed against `{{ report.report.base_ref }}`. [Full report]({{ index_url }}) -| case | versions | images | text | netcdf | other | | -| --- | --- | --- | --- | --- | --- | --- | +| case | versions |{% for kind in report.kinds %} {{ kind }} |{% endfor %} | +| --- | --- |{% for kind in report.kinds %} --- |{% endfor %} --- | {% for case in cases %} -| `{{ case.label }}` | {{ case.versions }} | {{ case.images }} | {{ case.text }} | {{ case.netcdf }} | {{ case.other }} | [view]({{ case.url }}) | +| `{{ case.label }}` | {{ case.versions }} |{% for count in case.counts %} {{ count }} |{% endfor %} [view]({{ case.url }}) | {% endfor %} {% else %} No baseline manifests changed against `{{ report.report.base_ref }}`. diff --git a/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py index 6d6167d8a..9908c7761 100644 --- a/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py +++ b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py @@ -87,7 +87,7 @@ def diff_baselines( # noqa: PLR0913 try: report_store = build_report_store(config.report_store, writable=True) report_store.preflight() - upload_site(html_dir, report_store, upload) + index_url = upload_site(html_dir, report_store, upload) base_url = report_store.url_for(upload) except (NotImplementedError, ValueError, ImportError, NativeStoreUnavailableError) as exc: logger.error( @@ -96,7 +96,7 @@ def diff_baselines( # noqa: PLR0913 "'climate-ref-core[aws]' extra." ) raise typer.Exit(code=1) from exc - console.print(f"Uploaded the report to {base_url}/index.html") + console.print(f"Uploaded the report to {index_url}") if comment_output is not None: comment_output.parent.mkdir(parents=True, exist_ok=True) diff --git a/packages/climate-ref/tests/unit/baseline_report/test_render.py b/packages/climate-ref/tests/unit/baseline_report/test_render.py index 2a8a0a3df..718780964 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_render.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_render.py @@ -496,8 +496,7 @@ def test_an_absent_side_says_so_in_the_split_view(self, tmp_path): def _rows(markdown: str) -> list[str]: """Return the table's body rows.""" - lines = [line for line in markdown.splitlines() if line.startswith("| `")] - return lines + return [line for line in markdown.splitlines() if line.startswith("| `")] class TestComment: @@ -564,6 +563,20 @@ def test_a_one_sided_case_says_so(self, tmp_path, base, head, expected): assert f"| {expected} |" in _rows(render_comment(report, BASE_URL))[0] + def test_the_header_carries_a_column_per_kind(self, changed_image_case): + markdown = render_comment(changed_image_case, BASE_URL) + + header = next(line for line in markdown.splitlines() if line.startswith("| case |")) + assert header.count("|") == len(changed_image_case.kinds) + 4 + for kind in changed_image_case.kinds: + assert f"| {kind} |" in header + + def test_every_row_has_the_same_column_count_as_the_header(self, changed_image_case): + markdown = render_comment(changed_image_case, BASE_URL) + + header = next(line for line in markdown.splitlines() if line.startswith("| case |")) + assert all(row.count("|") == header.count("|") for row in _rows(markdown)) + def test_zero_cases_renders_the_no_change_message(self, tmp_path): markdown = render_comment(_analysed([], tmp_path), BASE_URL) From 5583b2683d72e8dc1d956d93ea4a34c6a2ee9db4 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 12:39:04 +1000 Subject: [PATCH 59/64] fix: report an absent credential instead of a traceback `preflight` only wrapped botocore's `ClientError`, so `NoCredentialsError` (raised when neither the env vars, the profile nor boto3's default chain resolve anything) escaped the CLI's handler. A CI run with an unset secret is the likeliest way this fails, so it now gets the same operator-facing message as a rejected credential. --- .../regression/report_store.py | 9 +++++++- .../unit/regression/test_report_store.py | 10 ++++++++- .../tests/unit/baseline_report/test_render.py | 22 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py index 6b34d7d6b..a07feba4d 100644 --- a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py +++ b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py @@ -200,10 +200,17 @@ def preflight(self) -> None: if write is None: return - from botocore.exceptions import ClientError # noqa: PLC0415 - optional dependency + from botocore.exceptions import BotoCoreError, ClientError # noqa: PLC0415 - optional dep try: write.client().head_object(Bucket=write.bucket, Key=_PREFLIGHT_PROBE_KEY) + except BotoCoreError as exc: + # Covers NoCredentialsError, where the whole chain resolved nothing, and DNS failures. + raise NativeStoreUnavailableError( + f"Report store could not be reached for bucket {write.bucket!r} at " + f"{write.endpoint_url}: {exc} Check REF_REPORT_STORE_PROFILE, or " + f"REF_REPORT_STORE_ACCESS_KEY_ID / REF_REPORT_STORE_SECRET_ACCESS_KEY." + ) from exc except ClientError as exc: status = _http_status(exc) if status == _HTTP_NOT_FOUND: diff --git a/packages/climate-ref-core/tests/unit/regression/test_report_store.py b/packages/climate-ref-core/tests/unit/regression/test_report_store.py index 9b639cd96..86dbdc2d9 100644 --- a/packages/climate-ref-core/tests/unit/regression/test_report_store.py +++ b/packages/climate-ref-core/tests/unit/regression/test_report_store.py @@ -1,7 +1,7 @@ from pathlib import Path import pytest -from botocore.exceptions import ClientError +from botocore.exceptions import ClientError, NoCredentialsError from pytest_mock import MockerFixture from climate_ref_core.regression.report_store import ( @@ -142,6 +142,14 @@ def test_preflight_accepts_a_missing_probe(self, mocker: MockerFixture) -> None: client.head_object.assert_called_once() + def test_preflight_reports_absent_credentials(self, mocker: MockerFixture) -> None: + client = mocker.MagicMock() + client.head_object.side_effect = NoCredentialsError() + store = self._store(mocker, client) + + with pytest.raises(NativeStoreUnavailableError, match="REF_REPORT_STORE_ACCESS_KEY_ID"): + store.preflight() + def test_preflight_rejects_bad_credentials(self, mocker: MockerFixture) -> None: client = mocker.MagicMock() client.head_object.side_effect = _client_error("InvalidAccessKeyId", 401) diff --git a/packages/climate-ref/tests/unit/baseline_report/test_render.py b/packages/climate-ref/tests/unit/baseline_report/test_render.py index 718780964..2f34a49a9 100644 --- a/packages/climate-ref/tests/unit/baseline_report/test_render.py +++ b/packages/climate-ref/tests/unit/baseline_report/test_render.py @@ -577,6 +577,28 @@ def test_every_row_has_the_same_column_count_as_the_header(self, changed_image_c header = next(line for line in markdown.splitlines() if line.startswith("| case |")) assert all(row.count("|") == header.count("|") for row in _rows(markdown)) + def test_each_count_sits_under_its_own_kind(self, tmp_path): + report = _analysed( + [ + _case_change( + [_change("added.png", None, _entry("1")), _change("gone.txt", _entry("4"), None)], + base=_manifest(3), + head=_manifest(4), + ) + ], + tmp_path, + ) + markdown = render_comment(report, BASE_URL) + + header = [ + c.strip() + for c in next(line for line in markdown.splitlines() if line.startswith("| case |")).split("|") + ] + cells = [c.strip() for c in _rows(markdown)[0].split("|")] + assert cells[header.index("image")] == "+1" + assert cells[header.index("text")] == "-1" + assert cells[header.index("netcdf")] == "none" + def test_zero_cases_renders_the_no_change_message(self, tmp_path): markdown = render_comment(_analysed([], tmp_path), BASE_URL) From 3c66966e9090e27959507ce906acbbecac514dec Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 12:40:50 +1000 Subject: [PATCH 60/64] fix: name the reports bucket ref-baselines-reports Matches the `ref-baselines-` prefix the existing `ref-baselines-public` bucket uses. --- .../src/climate_ref_core/regression/report_store.py | 3 ++- .../tests/unit/regression/test_report_store.py | 2 +- packages/climate-ref/src/climate_ref/config.py | 2 +- packages/climate-ref/tests/unit/test_config.py | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py index a07feba4d..0eead1702 100644 --- a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py +++ b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py @@ -301,7 +301,8 @@ def build_report_store(config: _ReportStoreConfigProtocol, *, writable: bool) -> ) if not config.bucket: raise ValueError( - "Uploading a report needs a bucket name. Set REF_REPORT_STORE_BUCKET (e.g. ref-baseline-reports)." + "Uploading a report needs a bucket name. Set REF_REPORT_STORE_BUCKET " + "(e.g. ref-baselines-reports)." ) return ReportStore( url=config.url, diff --git a/packages/climate-ref-core/tests/unit/regression/test_report_store.py b/packages/climate-ref-core/tests/unit/regression/test_report_store.py index 86dbdc2d9..a0c7081e8 100644 --- a/packages/climate-ref-core/tests/unit/regression/test_report_store.py +++ b/packages/climate-ref-core/tests/unit/regression/test_report_store.py @@ -12,7 +12,7 @@ REMOTE_URL = "https://reports.example.com" S3_ENDPOINT = "https://account.r2.cloudflarestorage.com" -BUCKET = "ref-baseline-reports" +BUCKET = "ref-baselines-reports" KEY = "912/0c7e1d4abc12/index.html" HTML = "text/html; charset=utf-8" diff --git a/packages/climate-ref/src/climate_ref/config.py b/packages/climate-ref/src/climate_ref/config.py index 09565f0a2..27ac9bc4e 100644 --- a/packages/climate-ref/src/climate_ref/config.py +++ b/packages/climate-ref/src/climate_ref/config.py @@ -328,7 +328,7 @@ class ReportStoreConfig: Set ``REF_REPORT_STORE_S3_ENDPOINT_URL`` to override (e.g. a staging account). """ - bucket: str = env_field(name="REPORT_STORE_BUCKET", default="ref-baseline-reports") + bucket: str = env_field(name="REPORT_STORE_BUCKET", default="ref-baselines-reports") """ Name of the writable (Cloudflare R2) reports bucket. diff --git a/packages/climate-ref/tests/unit/test_config.py b/packages/climate-ref/tests/unit/test_config.py index 85ddace6b..089ae049b 100644 --- a/packages/climate-ref/tests/unit/test_config.py +++ b/packages/climate-ref/tests/unit/test_config.py @@ -229,7 +229,7 @@ def test_defaults(self, monkeypatch, mocker): "report_store": { "url": "https://reports.baselines.climate-ref.org", "s3_endpoint_url": "https://2aa5172b2bba093c516027d6fa13cdc8.r2.cloudflarestorage.com", - "bucket": "ref-baseline-reports", + "bucket": "ref-baselines-reports", }, "paths": { "log": f"{default_path}/log", @@ -263,7 +263,7 @@ def test_from_env_variables(self, monkeypatch, config): def test_report_store_defaults(self, config): assert config.report_store.url == "https://reports.baselines.climate-ref.org" - assert config.report_store.bucket == "ref-baseline-reports" + assert config.report_store.bucket == "ref-baselines-reports" def test_report_store_from_env_variables(self, monkeypatch, config): monkeypatch.setenv("REF_REPORT_STORE_URL", "file:///tmp/ref-reports") From 5379b6d08ad7865014744231d7f1b4138e362686 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 12:44:57 +1000 Subject: [PATCH 61/64] refactor: tidy the report upload after review - Key validation now delegates to `safe_path`, the containment primitive the rest of the regression package already uses, so a symlink escape and a NUL byte are caught too. - One Jinja environment again. Escaping keys off the template name, so the markdown comment renders unescaped without a second environment that HTML could reach by mistake. - A bad `--upload` prefix now reports the prefix rather than advising a credential check. --- .../regression/report_store.py | 42 ++++++++++--------- .../src/climate_ref/baseline_report/render.py | 21 ++++------ .../src/climate_ref/baseline_report/upload.py | 7 +++- .../src/climate_ref/cli/test_cases/diff.py | 24 +++++++---- 4 files changed, 51 insertions(+), 43 deletions(-) diff --git a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py index 0eead1702..eb564b736 100644 --- a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py +++ b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py @@ -17,12 +17,14 @@ import os import re import shutil -from pathlib import Path, PurePosixPath +from pathlib import Path from typing import Protocol from attrs import field, frozen from loguru import logger +from climate_ref_core.paths import safe_path + from .store import ( _AUTH_REJECTED_STATUSES, _HTTP_FORBIDDEN, @@ -37,39 +39,39 @@ _KEY_PATTERN = re.compile(r"[A-Za-z0-9._/-]+") -def _validate_key(key: str) -> str: +def _validate_key(key: str, base: Path | None = None) -> Path: """ - Check a report key is a safe relative path and return it. + Check a report key is a safe relative path and return the path it names. - A key becomes a path under the local root, so a key that escapes the root or carries - shell-hostile characters is rejected before it is ever joined. + The character class is the object-key rule, narrower than a filesystem path because the key + also has to survive a URL. Containment is left to :func:`safe_path`, which is what the rest + of the regression package validates paths with. Parameters ---------- key The key to check, for example ``912/0c7e1d4abc12/index.html``. + base + The local store root the key is joined onto, or ``None`` for a remote store, + where there is no directory to escape. Returns ------- : - The key, unchanged. + ``base / key`` when a base is given, otherwise the key as a relative path. Raises ------ ValueError - If the key is empty, absolute, contains a ``..`` segment, or uses characters - outside ``[A-Za-z0-9._/-]``. + If the key uses characters outside ``[A-Za-z0-9._/-]``, is empty, absolute, or + contains a ``..`` segment, or if it escapes ``base``. """ - if not key or not _KEY_PATTERN.fullmatch(key): + if not _KEY_PATTERN.fullmatch(key): raise ValueError( - f"Invalid report store key {key!r}: keys must be non-empty and may only contain " - "letters, digits, dot, underscore, hyphen and forward slash." + f"Unsafe report store key {key!r}: may only contain letters, digits, dot, " + "underscore, hyphen and forward slash." ) - if key.startswith("/"): - raise ValueError(f"Invalid report store key {key!r}: keys must be relative, not absolute.") - if ".." in PurePosixPath(key).parts: - raise ValueError(f"Invalid report store key {key!r}: keys must not contain a '..' segment.") - return key + return safe_path(key, base, label="report store key") @frozen @@ -120,10 +122,10 @@ def url_for(self, key: str) -> str: ValueError If the key is not a safe relative path. """ - _validate_key(key) root = self.root if root is not None: - return (root / key).absolute().as_uri() + return _validate_key(key, root).absolute().as_uri() + _validate_key(key) return f"{self.url.rstrip('/')}/{key}" def put(self, key: str, path: Path, content_type: str) -> str: @@ -154,13 +156,13 @@ def put(self, key: str, path: Path, content_type: str) -> str: NotImplementedError If this is an anonymous remote store, which cannot write. """ - _validate_key(key) root = self.root if root is not None: - dest = root / key + dest = _validate_key(key, root) dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(str(path), str(dest)) else: + _validate_key(key) write = self.write if write is None: raise NotImplementedError( diff --git a/packages/climate-ref/src/climate_ref/baseline_report/render.py b/packages/climate-ref/src/climate_ref/baseline_report/render.py index 445c5f033..9bad05163 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/render.py +++ b/packages/climate-ref/src/climate_ref/baseline_report/render.py @@ -117,15 +117,12 @@ def _format_short(digest: str | None) -> str: return digest[:SHORT_DIGEST] -def _build_env(*, escape: bool) -> Environment: +def _build_env() -> Environment: """ - Build a Jinja environment for the templates in this package. + Build the Jinja environment the templates in this package are rendered with. - Parameters - ---------- - escape - Whether to escape values. Off for the markdown comment, where an escaped case label - would render as HTML entities on GitHub. + Escaping keys off the template name, so ``comment.md.j2`` renders unescaped. + It is markdown, where an escaped case label would show as HTML entities on GitHub. Returns ------- @@ -134,7 +131,7 @@ def _build_env(*, escape: bool) -> Environment: """ env = Environment( loader=PackageLoader("climate_ref.baseline_report", "templates"), - autoescape=select_autoescape(["html", "j2"]) if escape else False, # noqa: S701 + autoescape=select_autoescape(enabled_extensions=("html.j2", "html")), trim_blocks=True, lstrip_blocks=True, ) @@ -146,8 +143,7 @@ def _build_env(*, escape: bool) -> Environment: return env -_env = _build_env(escape=True) -_text_env = _build_env(escape=False) +_env = _build_env() @frozen @@ -219,8 +215,7 @@ def _versions(case: AnalysedCase) -> str: if case.change.is_new: return "new" base, head = case.change.base, case.change.head - if base is None or head is None: # pragma: no cover - is_new / is_removed already cover this - return "changed" + assert base is not None and head is not None return f"v{base.test_case_version} -> v{head.test_case_version}" @@ -275,7 +270,7 @@ def render_comment( The comment's markdown, ending in the marker the CI job finds it by. """ root = base_url.rstrip("/") - return _text_env.get_template("comment.md.j2").render( + return _env.get_template("comment.md.j2").render( report=report, cases=[_comment_row(case, root) for case in report.cases], index_url=f"{root}/index.html", diff --git a/packages/climate-ref/src/climate_ref/baseline_report/upload.py b/packages/climate-ref/src/climate_ref/baseline_report/upload.py index 379806316..4a68d8f0c 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/upload.py +++ b/packages/climate-ref/src/climate_ref/baseline_report/upload.py @@ -23,7 +23,12 @@ ".png": "image/png", ".svg": "image/svg+xml", } -"""Content types for the file kinds a report is made of.""" +""" +Content types for the file kinds a report is made of. + +A deliberate allowlist rather than :mod:`mimetypes`, whose answers vary with the host's +registry, and which would not add the charset a browser needs on the text types. +""" DEFAULT_CONTENT_TYPE = "application/octet-stream" """Served for anything else, which a browser will offer as a download.""" diff --git a/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py index 9908c7761..445875cb1 100644 --- a/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py +++ b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py @@ -82,23 +82,29 @@ def diff_baselines( # noqa: PLR0913 console.print(f"Wrote {len(analysed.cases)} case page(s) to {index}") - base_url = index.parent.resolve().as_uri() + base_url = None if upload is not None: try: report_store = build_report_store(config.report_store, writable=True) - report_store.preflight() - index_url = upload_site(html_dir, report_store, upload) base_url = report_store.url_for(upload) - except (NotImplementedError, ValueError, ImportError, NativeStoreUnavailableError) as exc: + except ValueError as exc: + logger.error( + f"{exc} Set --upload to a relative key prefix such as 912/0c7e1d4abc12, and check " + "REF_REPORT_STORE_URL, REF_REPORT_STORE_S3_ENDPOINT_URL and REF_REPORT_STORE_BUCKET." + ) + raise typer.Exit(code=1) from exc + try: + report_store.preflight() + console.print(f"Uploaded the report to {upload_site(html_dir, report_store, upload)}") + except (ImportError, NativeStoreUnavailableError) as exc: logger.error( - f"Could not upload the report: {exc} Check REF_REPORT_STORE_URL, and for a remote " - "store REF_REPORT_STORE_ACCESS_KEY_ID / REF_REPORT_STORE_SECRET_ACCESS_KEY plus the " - "'climate-ref-core[aws]' extra." + f"Could not upload the report: {exc} Check REF_REPORT_STORE_ACCESS_KEY_ID / " + "REF_REPORT_STORE_SECRET_ACCESS_KEY and the 'climate-ref-core[aws]' extra." ) raise typer.Exit(code=1) from exc - console.print(f"Uploaded the report to {index_url}") if comment_output is not None: comment_output.parent.mkdir(parents=True, exist_ok=True) - comment_output.write_text(render_comment(analysed, base_url), encoding="utf-8") + markdown = render_comment(analysed, base_url or index.parent.resolve().as_uri()) + comment_output.write_text(markdown, encoding="utf-8") console.print(f"Wrote the pull request comment to {comment_output}") From 8bb533af74d1b1eddd4a8ad2fbbf050bb83874cf Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 12:47:26 +1000 Subject: [PATCH 62/64] fix: keep an unexpected key from tracebacking mid-upload A case label outside the key character class would have escaped the handler once the upload had started. It exits 1 with the rest now. The `--upload` prefix still gets its own message, because that one is a typed argument rather than something derived from the repository. --- packages/climate-ref/src/climate_ref/cli/test_cases/diff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py index 445875cb1..da7f3cf7b 100644 --- a/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py +++ b/packages/climate-ref/src/climate_ref/cli/test_cases/diff.py @@ -96,7 +96,7 @@ def diff_baselines( # noqa: PLR0913 try: report_store.preflight() console.print(f"Uploaded the report to {upload_site(html_dir, report_store, upload)}") - except (ImportError, NativeStoreUnavailableError) as exc: + except (ImportError, ValueError, NativeStoreUnavailableError) as exc: logger.error( f"Could not upload the report: {exc} Check REF_REPORT_STORE_ACCESS_KEY_ID / " "REF_REPORT_STORE_SECRET_ACCESS_KEY and the 'climate-ref-core[aws]' extra." From 78de3d6b6b048d0c5c859f85da94d0f11d35c6fb Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 13:02:35 +1000 Subject: [PATCH 63/64] test: cover the report store's error paths Pins the messages an operator sees when the local root is unwritable, when the bucket denies the request, and when the CLI cannot use the store at all. --- changelog/912.feature.md | 6 +--- .../regression/report_store.py | 10 +++---- .../unit/regression/test_report_store.py | 28 +++++++++++++++++++ .../src/climate_ref/baseline_report/upload.py | 11 ++++---- .../tests/unit/cli/test_test_cases.py | 24 ++++++++++++++++ 5 files changed, 64 insertions(+), 15 deletions(-) diff --git a/changelog/912.feature.md b/changelog/912.feature.md index 87a66be0a..ed9f4c456 100644 --- a/changelog/912.feature.md +++ b/changelog/912.feature.md @@ -1,8 +1,4 @@ `ref test-cases diff --upload ` publishes the report to a public object store, and `--comment-output ` writes the pull request comment markdown that links into it. -The comment is one table row per changed case, so its size no longer grows with the diff. -Reports go to their own store, configured by `REF_REPORT_STORE_URL`, `REF_REPORT_STORE_BUCKET` -and `REF_REPORT_STORE_S3_ENDPOINT_URL`, with credentials read from -`REF_REPORT_STORE_ACCESS_KEY_ID` / `REF_REPORT_STORE_SECRET_ACCESS_KEY` at upload time. -It is a separate bucket to the baselines store because an R2 token cannot be scoped to a prefix. +Reports are stored in their own bucket, separate from the baselines. diff --git a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py index eb564b736..33cddf37d 100644 --- a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py +++ b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py @@ -6,12 +6,12 @@ content-addressed and must stay that way, so this is a sibling class rather than a new method on it. Reports live in their own public bucket, served at ``{url}/{key}``. -The bucket is separate from the baselines bucket because an R2 token cannot be scoped to a prefix, -so a token that can write reports must not also be able to overwrite baseline blobs. -Write credentials are resolved from ``REF_REPORT_STORE_ACCESS_KEY_ID`` / -``REF_REPORT_STORE_SECRET_ACCESS_KEY``, then ``REF_REPORT_STORE_PROFILE``, then boto3's default -chain. They are never read from the persisted config. +Write credentials are resolved from the following in order: + +- ``REF_REPORT_STORE_ACCESS_KEY_ID`` / ``REF_REPORT_STORE_SECRET_ACCESS_KEY``, +- ``REF_REPORT_STORE_PROFILE``, +- boto3's default chain """ import os diff --git a/packages/climate-ref-core/tests/unit/regression/test_report_store.py b/packages/climate-ref-core/tests/unit/regression/test_report_store.py index a0c7081e8..0d5f7d020 100644 --- a/packages/climate-ref-core/tests/unit/regression/test_report_store.py +++ b/packages/climate-ref-core/tests/unit/regression/test_report_store.py @@ -94,6 +94,14 @@ def test_preflight_creates_the_root(self, local_store: ReportStore) -> None: assert local_store.root is not None assert local_store.root.is_dir() + def test_preflight_reports_an_unwritable_root( + self, local_store: ReportStore, mocker: MockerFixture + ) -> None: + mocker.patch("climate_ref_core.regression.report_store.os.access", return_value=False) + + with pytest.raises(NativeStoreUnavailableError, match="not writable"): + local_store.preflight() + def test_preflight_reports_an_uncreatable_root(self, tmp_path: Path) -> None: blocker = tmp_path / "blocker" blocker.write_text("not a directory", encoding="utf-8") @@ -158,6 +166,26 @@ def test_preflight_rejects_bad_credentials(self, mocker: MockerFixture) -> None: with pytest.raises(NativeStoreUnavailableError, match="REF_REPORT_STORE_ACCESS_KEY_ID"): store.preflight() + @pytest.mark.parametrize( + "code, status, expected", + [ + ("AccessDenied", 403, "access denied"), + ("InternalError", 500, "preflight failed"), + ], + ) + def test_preflight_reports_other_failures( + self, mocker: MockerFixture, code: str, status: int, expected: str + ) -> None: + client = mocker.MagicMock() + client.head_object.side_effect = _client_error(code, status) + store = self._store(mocker, client) + + with pytest.raises(NativeStoreUnavailableError, match=expected): + store.preflight() + + def test_a_read_only_remote_store_has_nothing_to_preflight(self) -> None: + ReportStore(url=REMOTE_URL).preflight() + class TestBuildReportStore: def test_reads_the_report_store_env_vars(self, mocker: MockerFixture, monkeypatch) -> None: diff --git a/packages/climate-ref/src/climate_ref/baseline_report/upload.py b/packages/climate-ref/src/climate_ref/baseline_report/upload.py index 4a68d8f0c..3233b4e86 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/upload.py +++ b/packages/climate-ref/src/climate_ref/baseline_report/upload.py @@ -1,9 +1,10 @@ """ Push a rendered report into the report store. -The store is a plain object store with no directory semantics, so every file is uploaded under an -explicit key and the content type has to be set per object or a browser will download the page -instead of rendering it. +The files are uploaded to +The store is a plain object store with no directory semantics, +so every file is uploaded under an explicit key. +The content type has to be set per object, or a browser will download the page instead of rendering it. """ from __future__ import annotations @@ -26,8 +27,8 @@ """ Content types for the file kinds a report is made of. -A deliberate allowlist rather than :mod:`mimetypes`, whose answers vary with the host's -registry, and which would not add the charset a browser needs on the text types. +A deliberate allowlist rather than :mod:`mimetypes`, whose answers vary with the host's registry, +and which would not add the charset a browser needs on the text types. """ DEFAULT_CONTENT_TYPE = "application/octet-stream" diff --git a/packages/climate-ref/tests/unit/cli/test_test_cases.py b/packages/climate-ref/tests/unit/cli/test_test_cases.py index b2e4e453d..7166cf1c0 100644 --- a/packages/climate-ref/tests/unit/cli/test_test_cases.py +++ b/packages/climate-ref/tests/unit/cli/test_test_cases.py @@ -2981,6 +2981,30 @@ def test_an_unsafe_upload_prefix_exits_one(self, invoke_cli, mocker, tmp_path, m expected_exit_code=1, ) + def test_an_unusable_report_store_exits_one(self, invoke_cli, mocker, tmp_path, monkeypatch): + blocker = tmp_path / "blocker" + blocker.write_text("not a directory") + monkeypatch.setenv("REF_REPORT_STORE_URL", str(blocker / "reports")) + + repo = MagicMock() + repo.working_tree_dir = str(tmp_path) + repo.git.diff.return_value = "" + repo.head.commit.hexsha = "a" * 40 + mocker.patch("climate_ref.cli.test_cases.diff.get_repo_for_path", return_value=repo) + + invoke_cli( + [ + "test-cases", + "diff", + "--html-dir", + str(tmp_path / "out"), + "--no-fetch", + "--upload", + "912/abc", + ], + expected_exit_code=1, + ) + def test_the_comment_links_locally_without_upload(self, invoke_cli, mocker, tmp_path): from climate_ref_core.regression.manifest import SCHEMA_VERSION, Manifest From c723f73b0f7421ce4d3090b1c8bb0fdce7d4732b Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 13:09:50 +1000 Subject: [PATCH 64/64] refactor: share one transport between the native and report stores The two stores differ only in how a key is chosen: the native store derives it from the content, the report store is handed one. Everything under that was duplicated, so this pulls it into `store.py` and leaves each class holding just its addressing rules. - `_preflight_store` replaces two near-identical `preflight` bodies. - `_write_config_from_env` replaces the credential lookup in both factories. - `S3WriteConfig` carries the env prefix it was resolved from, so it names the right variable in an error and the report factory no longer needs its own pre-checks. - `_StoreConfigProtocol` replaces the two copies, with the native one adding `cache_dir`. The native store picks up the report store's `BotoCoreError` handling as a result, so a missing credential there reports a message instead of a traceback. --- .../regression/report_store.py | 115 +--------- .../src/climate_ref_core/regression/store.py | 205 +++++++++++------- .../unit/regression/test_report_store.py | 2 +- .../src/climate_ref/baseline_report/upload.py | 1 - 4 files changed, 138 insertions(+), 185 deletions(-) diff --git a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py index 33cddf37d..3ccec9450 100644 --- a/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py +++ b/packages/climate-ref-core/src/climate_ref_core/regression/report_store.py @@ -2,8 +2,10 @@ Named-key store for hosted baseline diff reports. A diff report is a small static site, so its objects are addressed by path rather than by digest. -That makes it the opposite of :class:`~climate_ref_core.regression.store.NativeStore`, which is -content-addressed and must stay that way, so this is a sibling class rather than a new method on it. +That is the one thing it does not share with :class:`~climate_ref_core.regression.store.NativeStore`, +which is content-addressed and must stay that way. +Everything below the key (where the store lives, how it authenticates, preflight, putting a file) +is the shared transport in :mod:`~climate_ref_core.regression.store`. Reports live in their own public bucket, served at ``{url}/{key}``. @@ -14,11 +16,9 @@ - boto3's default chain """ -import os import re import shutil from pathlib import Path -from typing import Protocol from attrs import field, frozen from loguru import logger @@ -26,14 +26,11 @@ from climate_ref_core.paths import safe_path from .store import ( - _AUTH_REJECTED_STATUSES, - _HTTP_FORBIDDEN, - _HTTP_NOT_FOUND, - _PREFLIGHT_PROBE_KEY, - NativeStoreUnavailableError, S3WriteConfig, - _http_status, _local_root, + _preflight_store, + _StoreConfigProtocol, + _write_config_from_env, ) _KEY_PATTERN = re.compile(r"[A-Za-z0-9._/-]+") @@ -177,90 +174,15 @@ def preflight(self) -> None: """ Verify the store is reachable and usable before uploading to it. - A local store's root is created if needed and checked for writability. - A writable remote store performs a cheap authenticated ``HEAD`` on a sentinel key, - which is expected to be absent, so a bad credential is caught before the upload starts. - Raises ------ NativeStoreUnavailableError If the store cannot be reached or used, with an operator-facing message. """ - root = self.root - if root is not None: - try: - root.mkdir(parents=True, exist_ok=True) - except OSError as exc: - raise NativeStoreUnavailableError( - f"Local report store root {root} could not be created: {exc}" - ) from exc - if not os.access(root, os.W_OK): - raise NativeStoreUnavailableError(f"Local report store root {root} is not writable.") - logger.debug(f"Local report store ready at {root}") - return - write = self.write - if write is None: - return - - from botocore.exceptions import BotoCoreError, ClientError # noqa: PLC0415 - optional dep - - try: - write.client().head_object(Bucket=write.bucket, Key=_PREFLIGHT_PROBE_KEY) - except BotoCoreError as exc: - # Covers NoCredentialsError, where the whole chain resolved nothing, and DNS failures. - raise NativeStoreUnavailableError( - f"Report store could not be reached for bucket {write.bucket!r} at " - f"{write.endpoint_url}: {exc} Check REF_REPORT_STORE_PROFILE, or " - f"REF_REPORT_STORE_ACCESS_KEY_ID / REF_REPORT_STORE_SECRET_ACCESS_KEY." - ) from exc - except ClientError as exc: - status = _http_status(exc) - if status == _HTTP_NOT_FOUND: - pass # authenticated, and the probe object is simply absent, so the store is usable - elif status in _AUTH_REJECTED_STATUSES: - raise NativeStoreUnavailableError( - f"Report store authentication failed (HTTP {status}) for bucket {write.bucket!r} at " - f"{write.endpoint_url}: the credentials were rejected or malformed. Check " - f"REF_REPORT_STORE_PROFILE, or REF_REPORT_STORE_ACCESS_KEY_ID / " - f"REF_REPORT_STORE_SECRET_ACCESS_KEY." - ) from exc - elif status == _HTTP_FORBIDDEN: - raise NativeStoreUnavailableError( - f"Report store access denied (HTTP 403) for bucket {write.bucket!r} at " - f"{write.endpoint_url}: the request was forbidden. The secret key may be wrong, " - f"or the token may lack object read and write on this bucket. Check the " - f"credentials and the token's permissions." - ) from exc - else: - raise NativeStoreUnavailableError( - f"Report store preflight failed (HTTP {status}) for bucket {write.bucket!r} at " - f"{write.endpoint_url}: {exc}" - ) from exc - logger.info(f"Report store authenticated: bucket {write.bucket!r} at {write.endpoint_url}") - - -class _ReportStoreConfigProtocol(Protocol): - """ - Structural protocol for the report-store config object expected by :func:`build_report_store`. - - Keeps ``climate_ref_core`` free of any import dependency on ``climate_ref``, so both - :class:`climate_ref.config.ReportStoreConfig` and test doubles satisfy it. - - ``s3_endpoint_url`` and ``bucket`` are non-secret routing config. Write credentials are - intentionally **not** part of this protocol, and are read from the environment instead. - """ + _preflight_store(self.root, self.write, "Report store") - @property - def url(self) -> str: ... - @property - def s3_endpoint_url(self) -> str: ... - - @property - def bucket(self) -> str: ... - - -def build_report_store(config: _ReportStoreConfigProtocol, *, writable: bool) -> ReportStore: +def build_report_store(config: _StoreConfigProtocol, *, writable: bool) -> ReportStore: """ Build a :class:`ReportStore` from a report-store config object. @@ -295,24 +217,7 @@ def build_report_store(config: _ReportStoreConfigProtocol, *, writable: bool) -> if not writable or store.root is not None: # A local store is already writable, and a read-only store needs no credentials. return store - # Checked here rather than in S3WriteConfig, whose message names the native store's env vars. - if not config.s3_endpoint_url: - raise ValueError( - "Uploading a report needs an S3 endpoint URL. Set REF_REPORT_STORE_S3_ENDPOINT_URL " - "(e.g. https://.r2.cloudflarestorage.com)." - ) - if not config.bucket: - raise ValueError( - "Uploading a report needs a bucket name. Set REF_REPORT_STORE_BUCKET " - "(e.g. ref-baselines-reports)." - ) return ReportStore( url=config.url, - write=S3WriteConfig( - endpoint_url=config.s3_endpoint_url, - bucket=config.bucket, - access_key_id=os.environ.get("REF_REPORT_STORE_ACCESS_KEY_ID", ""), - secret_access_key=os.environ.get("REF_REPORT_STORE_SECRET_ACCESS_KEY", ""), - profile=os.environ.get("REF_REPORT_STORE_PROFILE", ""), - ), + write=_write_config_from_env(config.s3_endpoint_url, config.bucket, "REF_REPORT_STORE"), ) diff --git a/packages/climate-ref-core/src/climate_ref_core/regression/store.py b/packages/climate-ref-core/src/climate_ref_core/regression/store.py index 784f7e325..c50030e4c 100644 --- a/packages/climate-ref-core/src/climate_ref_core/regression/store.py +++ b/packages/climate-ref-core/src/climate_ref_core/regression/store.py @@ -213,6 +213,9 @@ class S3WriteConfig: profile Named AWS/R2 profile to authenticate with, or ``""`` for the default session. Ignored when explicit ``access_key_id`` / ``secret_access_key`` are supplied. + env_prefix + Prefix of the environment variables this config was resolved from, for example + ``REF_REPORT_STORE``. Only used to name the right variable in an error message. """ endpoint_url: str @@ -220,17 +223,18 @@ class S3WriteConfig: access_key_id: str = field(default="", repr=False) secret_access_key: str = field(default="", repr=False) profile: str = "" + env_prefix: str = "REF_NATIVE_STORE" def __attrs_post_init__(self) -> None: """Fail fast at construction (mint startup) when the routing config is missing.""" if not self.endpoint_url: raise ValueError( - "R2 native store requires an S3 endpoint URL. Set REF_NATIVE_STORE_S3_ENDPOINT_URL " + f"R2 store requires an S3 endpoint URL. Set {self.env_prefix}_S3_ENDPOINT_URL " "(e.g. https://.eu.r2.cloudflarestorage.com)." ) if not self.bucket: raise ValueError( - "R2 native store requires a bucket name. Set REF_NATIVE_STORE_BUCKET (e.g. ref-baselines)." + f"R2 store requires a bucket name. Set {self.env_prefix}_BUCKET (e.g. ref-baselines)." ) def client(self) -> Any: @@ -238,6 +242,114 @@ def client(self) -> Any: return _s3_client(self.endpoint_url, self.access_key_id, self.secret_access_key, self.profile) +def _preflight_store(root: Path | None, write: S3WriteConfig | None, label: str) -> None: + """ + Verify a store is reachable and usable before relying on it. + + A local store's root is created if needed and checked for writability. + A writable remote store performs a cheap authenticated ``HEAD`` on a sentinel key, + which is expected to be absent: + a ``404`` means the request authenticated and the store is usable, + while ``401`` / ``403`` become actionable errors, + so a misconfigured credential is caught before the (slow) upload rather than after. + ``head_object`` is used rather than ``head_bucket``, + so the check works with least-privilege, object-scoped tokens. + + An anonymous remote store has nothing to verify up front, since it has no credentials. + + Parameters + ---------- + root + The local store root, or ``None`` when the store is remote. + write + How a remote store writes, or ``None`` when it is anonymous. + label + How the store is named in an error message, for example ``"Native store"``. + + Raises + ------ + NativeStoreUnavailableError + If the store cannot be reached or used, with an operator-facing message. + """ + if root is not None: + try: + root.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise NativeStoreUnavailableError( + f"Local {label.lower()} root {root} could not be created: {exc}" + ) from exc + if not os.access(root, os.W_OK): + raise NativeStoreUnavailableError(f"Local {label.lower()} root {root} is not writable.") + logger.debug(f"Local {label.lower()} ready at {root}") + return + if write is None: + return + + from botocore.exceptions import BotoCoreError, ClientError # noqa: PLC0415 - optional dependency + + creds = ( + f"Check {write.env_prefix}_PROFILE, or {write.env_prefix}_ACCESS_KEY_ID / " + f"{write.env_prefix}_SECRET_ACCESS_KEY." + ) + where = f"for bucket {write.bucket!r} at {write.endpoint_url}" + try: + write.client().head_object(Bucket=write.bucket, Key=_PREFLIGHT_PROBE_KEY) + except BotoCoreError as exc: + # Covers NoCredentialsError, where the whole chain resolved nothing, and DNS failures. + raise NativeStoreUnavailableError(f"{label} could not be reached {where}: {exc} {creds}") from exc + except ClientError as exc: + status = _http_status(exc) + if status == _HTTP_NOT_FOUND: + pass # authenticated, and the probe object is simply absent, so the store is usable + elif status in _AUTH_REJECTED_STATUSES: + raise NativeStoreUnavailableError( + f"{label} authentication failed (HTTP {status}) {where}: the credentials were " + f"rejected or malformed. {creds}" + ) from exc + elif status == _HTTP_FORBIDDEN: + raise NativeStoreUnavailableError( + f"{label} access denied (HTTP 403) {where}: the request was forbidden. The secret " + f"key may be wrong, or the token may lack object read and write on this bucket. " + f"Check the credentials and the token's permissions." + ) from exc + else: + raise NativeStoreUnavailableError( + f"{label} preflight failed (HTTP {status}) {where}: {exc}" + ) from exc + logger.info(f"{label} authenticated: bucket {write.bucket!r} at {write.endpoint_url}") + + +def _write_config_from_env(endpoint_url: str, bucket: str, env_prefix: str) -> S3WriteConfig: + """ + Build an :class:`S3WriteConfig` from non-secret routing plus credentials in the environment. + + Credentials are read here, at build time, so they never live in the persisted config. + + Parameters + ---------- + endpoint_url + S3 API endpoint for the bucket's account, without the bucket. + bucket + Name of the bucket to write to. + env_prefix + Prefix of the credential environment variables, for example ``REF_NATIVE_STORE``. + + Returns + ------- + : + The write config, with empty credentials where the environment supplies none, + which falls through to the named profile and then boto3's default chain. + """ + return S3WriteConfig( + endpoint_url=endpoint_url, + bucket=bucket, + access_key_id=os.environ.get(f"{env_prefix}_ACCESS_KEY_ID", ""), + secret_access_key=os.environ.get(f"{env_prefix}_SECRET_ACCESS_KEY", ""), + profile=os.environ.get(f"{env_prefix}_PROFILE", ""), + env_prefix=env_prefix, + ) + + @frozen class NativeStore: """ @@ -421,77 +533,20 @@ def preflight(self) -> None: """ Verify the store is reachable and usable before relying on it. - A local store's root is created if needed and checked for writability. - A writable remote store performs a cheap authenticated ``HEAD`` on a sentinel key, - which is expected to be absent: - a ``404`` means the request authenticated and the store is usable, - while ``401`` / ``403`` become actionable errors, - so a misconfigured credential is caught before the (slow) diagnostic run rather than after. - ``head_object`` is used rather than ``head_bucket``, - so the check works with least-privilege, object-scoped tokens. - - An anonymous remote store has nothing to verify up front. - It has no credentials, and every read is hash-checked per blob. - Raises ------ NativeStoreUnavailableError If the store cannot be reached or used, with an operator-facing message. """ - root = self.root - if root is not None: - try: - root.mkdir(parents=True, exist_ok=True) - except OSError as exc: - raise NativeStoreUnavailableError( - f"Local native store root {root} could not be created: {exc}" - ) from exc - if not os.access(root, os.W_OK): - raise NativeStoreUnavailableError(f"Local native store root {root} is not writable.") - logger.debug(f"Local native store ready at {root}") - return - write = self.write - if write is None: - return + _preflight_store(self.root, self.write, "Native store") - from botocore.exceptions import ClientError # noqa: PLC0415 - optional dependency - try: - write.client().head_object(Bucket=write.bucket, Key=_PREFLIGHT_PROBE_KEY) - except ClientError as exc: - status = _http_status(exc) - if status == _HTTP_NOT_FOUND: - pass # authenticated, and the probe object is simply absent, so the store is usable - elif status in _AUTH_REJECTED_STATUSES: - raise NativeStoreUnavailableError( - f"Native store authentication failed (HTTP {status}) for bucket {write.bucket!r} at " - f"{write.endpoint_url}: the credentials were rejected or malformed. Check " - f"REF_NATIVE_STORE_PROFILE, or REF_NATIVE_STORE_ACCESS_KEY_ID / " - f"REF_NATIVE_STORE_SECRET_ACCESS_KEY." - ) from exc - elif status == _HTTP_FORBIDDEN: - raise NativeStoreUnavailableError( - f"Native store access denied (HTTP 403) for bucket {write.bucket!r} at " - f"{write.endpoint_url}: the request was forbidden. The secret key may be wrong, " - f"or the token may lack object read and write on this bucket. Check the " - f"credentials and the token's permissions." - ) from exc - else: - raise NativeStoreUnavailableError( - f"Native store preflight failed (HTTP {status}) for bucket {write.bucket!r} at " - f"{write.endpoint_url}: {exc}" - ) from exc - logger.info(f"Native store authenticated: bucket {write.bucket!r} at {write.endpoint_url}") - - -class _NativeStoreConfigProtocol(Protocol): +class _StoreConfigProtocol(Protocol): """ - Structural protocol for the native-store config object expected by :func:`build_native_store`. - - Both :class:`climate_ref.config.NativeStoreConfig` and test doubles satisfy - this interface without an import dependency on the app package. + Structural protocol for the store config objects the factories accept. - This keeps ``climate_ref_core`` free of any import dependency on ``climate_ref``. + Both :mod:`climate_ref.config` classes and test doubles satisfy it without an import + dependency on the app package, which keeps ``climate_ref_core`` free of one. ``s3_endpoint_url`` and ``bucket`` are non-secret routing config consumed only by a writable remote store. Write credentials are intentionally **not** part of this protocol. @@ -501,9 +556,6 @@ class _NativeStoreConfigProtocol(Protocol): @property def url(self) -> str: ... - @property - def cache_dir(self) -> Path: ... - @property def s3_endpoint_url(self) -> str: ... @@ -511,14 +563,17 @@ def s3_endpoint_url(self) -> str: ... def bucket(self) -> str: ... +class _NativeStoreConfigProtocol(_StoreConfigProtocol, Protocol): + """The store config plus the cache directory an anonymous native read needs.""" + + @property + def cache_dir(self) -> Path: ... + + def build_native_store(config: _NativeStoreConfigProtocol, *, writable: bool) -> NativeStore: """ Build a :class:`NativeStore` from a native-store config object. - Accepts any object that exposes ``url``, ``cache_dir``, ``s3_endpoint_url`` and ``bucket`` - (satisfying :class:`_NativeStoreConfigProtocol`), so callers pass ``config.native_store`` - rather than the full :class:`~climate_ref.config.Config`. - With ``writable=False`` the returned store is anonymous and credential-free, which suits the CI read and replay paths. With ``writable=True`` and a remote URL the S3 endpoint and bucket come from the config, @@ -554,11 +609,5 @@ def build_native_store(config: _NativeStoreConfigProtocol, *, writable: bool) -> return NativeStore( url=config.url, cache_dir=config.cache_dir, - write=S3WriteConfig( - endpoint_url=config.s3_endpoint_url, - bucket=config.bucket, - access_key_id=os.environ.get("REF_NATIVE_STORE_ACCESS_KEY_ID", ""), - secret_access_key=os.environ.get("REF_NATIVE_STORE_SECRET_ACCESS_KEY", ""), - profile=os.environ.get("REF_NATIVE_STORE_PROFILE", ""), - ), + write=_write_config_from_env(config.s3_endpoint_url, config.bucket, "REF_NATIVE_STORE"), ) diff --git a/packages/climate-ref-core/tests/unit/regression/test_report_store.py b/packages/climate-ref-core/tests/unit/regression/test_report_store.py index 0d5f7d020..613879e9d 100644 --- a/packages/climate-ref-core/tests/unit/regression/test_report_store.py +++ b/packages/climate-ref-core/tests/unit/regression/test_report_store.py @@ -97,7 +97,7 @@ def test_preflight_creates_the_root(self, local_store: ReportStore) -> None: def test_preflight_reports_an_unwritable_root( self, local_store: ReportStore, mocker: MockerFixture ) -> None: - mocker.patch("climate_ref_core.regression.report_store.os.access", return_value=False) + mocker.patch("climate_ref_core.regression.store.os.access", return_value=False) with pytest.raises(NativeStoreUnavailableError, match="not writable"): local_store.preflight() diff --git a/packages/climate-ref/src/climate_ref/baseline_report/upload.py b/packages/climate-ref/src/climate_ref/baseline_report/upload.py index 3233b4e86..d14e8dd34 100644 --- a/packages/climate-ref/src/climate_ref/baseline_report/upload.py +++ b/packages/climate-ref/src/climate_ref/baseline_report/upload.py @@ -1,7 +1,6 @@ """ Push a rendered report into the report store. -The files are uploaded to The store is a plain object store with no directory semantics, so every file is uploaded under an explicit key. The content type has to be set per object, or a browser will download the page instead of rendering it.